From 2ce789fe7cdc93d4a013054a7d22fa4598f42fa4 Mon Sep 17 00:00:00 2001 From: Eric Heikes Date: Wed, 30 Apr 2025 21:55:51 -0700 Subject: [PATCH 01/48] feat: First pass of splitting CLI & lib --- package-lock.json | 90 ++++++-- package.json | 3 +- packages/tts-cli/lib/file-extensions.js | 10 - packages/tts-cli/lib/generate-speech.js | 116 ---------- packages/tts-cli/lib/providers/aws.js | 65 ------ packages/tts-cli/lib/providers/gcp.js | 86 -------- packages/tts-cli/package.json | 12 +- packages/tts-cli/test/build-info.spec.js | 45 ---- packages/tts-cli/test/cli.spec.js | 198 ++++++++++++++---- packages/tts-cli/test/combine.spec.js | 83 -------- packages/tts-cli/test/file-extensions.spec.js | 31 --- packages/tts-cli/test/helpers.js | 3 +- packages/tts-cli/tts.js | 73 +++++-- packages/tts-lib/index.js | 1 + packages/{tts-cli => tts-lib}/lib/cleanup.js | 11 +- .../{tts-cli => tts-lib}/lib/combine-parts.js | 19 +- packages/tts-lib/lib/generate-speech.js | 43 ++++ packages/tts-lib/lib/provider.js | 14 ++ packages/tts-lib/lib/providers/aws.js | 111 ++++++++++ packages/tts-lib/lib/providers/gcp.js | 128 +++++++++++ .../{tts-cli => tts-lib}/lib/split-text.js | 9 +- .../{tts-cli => tts-lib}/lib/text-chunk.js | 4 + packages/tts-lib/package.json | 44 +++- .../{tts-cli => tts-lib}/test/cleanup.spec.js | 6 +- .../test/combine-encoded-audio.spec.js | 0 .../test/combine-raw-audio.spec.js | 0 packages/tts-lib/test/combine.spec.js | 73 +++++++ .../test/create-manifest.spec.js | 0 .../test/generate-all.spec.js | 12 +- .../test/generate-speech.spec.js | 3 +- packages/tts-lib/test/helpers.js | 69 ++++++ packages/tts-lib/test/jasmine.json | 7 + packages/tts-lib/test/provider.spec.js | 26 +++ .../test/providers/aws.spec.js | 182 +++++++++++++++- .../test/providers/gcp.spec.js | 160 ++++++++++++-- .../test/split-text.spec.js | 57 ++--- .../test/text-chunk.spec.js | 0 37 files changed, 1172 insertions(+), 622 deletions(-) delete mode 100644 packages/tts-cli/lib/file-extensions.js delete mode 100644 packages/tts-cli/lib/generate-speech.js delete mode 100644 packages/tts-cli/lib/providers/aws.js delete mode 100644 packages/tts-cli/lib/providers/gcp.js delete mode 100644 packages/tts-cli/test/build-info.spec.js delete mode 100644 packages/tts-cli/test/combine.spec.js delete mode 100644 packages/tts-cli/test/file-extensions.spec.js rename packages/{tts-cli => tts-lib}/lib/cleanup.js (55%) rename packages/{tts-cli => tts-lib}/lib/combine-parts.js (78%) create mode 100644 packages/tts-lib/lib/generate-speech.js create mode 100644 packages/tts-lib/lib/provider.js create mode 100644 packages/tts-lib/lib/providers/aws.js create mode 100644 packages/tts-lib/lib/providers/gcp.js rename packages/{tts-cli => tts-lib}/lib/split-text.js (93%) rename packages/{tts-cli => tts-lib}/lib/text-chunk.js (93%) rename packages/{tts-cli => tts-lib}/test/cleanup.spec.js (88%) rename packages/{tts-cli => tts-lib}/test/combine-encoded-audio.spec.js (100%) rename packages/{tts-cli => tts-lib}/test/combine-raw-audio.spec.js (100%) create mode 100644 packages/tts-lib/test/combine.spec.js rename packages/{tts-cli => tts-lib}/test/create-manifest.spec.js (100%) rename packages/{tts-cli => tts-lib}/test/generate-all.spec.js (81%) rename packages/{tts-cli => tts-lib}/test/generate-speech.spec.js (98%) create mode 100644 packages/tts-lib/test/helpers.js create mode 100644 packages/tts-lib/test/jasmine.json create mode 100644 packages/tts-lib/test/provider.spec.js rename packages/{tts-cli => tts-lib}/test/providers/aws.spec.js (54%) rename packages/{tts-cli => tts-lib}/test/providers/gcp.spec.js (66%) rename packages/{tts-cli => tts-lib}/test/split-text.spec.js (50%) rename packages/{tts-cli => tts-lib}/test/text-chunk.spec.js (100%) diff --git a/package-lock.json b/package-lock.json index 4287360..f719116 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,8 @@ "packages/*" ], "dependencies": { - "tts-cli": "file:packages/tts-cli" + "tts-cli": "file:packages/tts-cli", + "tts-lib": "file:packages/tts-lib" }, "devDependencies": { "cross-env": "^5.2.0", @@ -4139,6 +4140,16 @@ ], "license": "MIT" }, + "node_modules/fs-extra": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-2.1.2.tgz", + "integrity": "sha512-9ztMtDZtSKC78V8mev+k31qaTabbmuH5jatdvPBMikrFHvw5BqlYnQIn/WGK3WHeRooSTkRvLa2IPlaHjPq5Sg==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.1.2", + "jsonfile": "^2.1.0" + } + }, "node_modules/fs.realpath": { "version": "1.0.0", "dev": true, @@ -5206,6 +5217,15 @@ "json5": "lib/cli.js" } }, + "node_modules/jsonfile": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-2.4.0.tgz", + "integrity": "sha512-PKllAqbgLgxHaj8TElYymKCAgrASebJrWpTnEkOaTowt23VKXXN0sUeriJ+eh7y6ufb/CC5ap11pz71/cM0hUw==", + "license": "MIT", + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, "node_modules/jsx-ast-utils": { "version": "2.4.1", "dev": true, @@ -8080,23 +8100,17 @@ "version": "5.4.0", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/client-polly": "^3.651.1", - "@google-cloud/text-to-speech": "^5.0.1", - "async": "^3.0.0", "debug": "^3.1.0", "fs-extra": "^2.0.0", "listr2": "^8.0.2", - "minimist": "^1.2.0", - "sax": "^1.2.4", - "sentence-splitter": "^5.0.0", - "tempfile": "^1.1.1", - "zen-observable": "^0.10.0" + "minimist": "^1.2.0" }, "bin": { "tts": "tts.js" }, "devDependencies": { - "codecov": "^3.6.5" + "codecov": "^3.6.5", + "proxyquire": "^2.1.3" } }, "packages/tts-cli/node_modules/debug": { @@ -8106,30 +8120,60 @@ "ms": "^2.1.1" } }, - "packages/tts-cli/node_modules/fs-extra": { - "version": "2.1.2", + "packages/tts-cli/node_modules/proxyquire": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/proxyquire/-/proxyquire-2.1.3.tgz", + "integrity": "sha512-BQWfCqYM+QINd+yawJz23tbBM40VIGXOdDw3X344KcclI/gtBbdWF6SlQ4nK/bYhF9d27KYug9WzljHC6B9Ysg==", + "dev": true, "license": "MIT", "dependencies": { - "graceful-fs": "^4.1.2", - "jsonfile": "^2.1.0" - } - }, - "packages/tts-cli/node_modules/jsonfile": { - "version": "2.4.0", - "license": "MIT", - "optionalDependencies": { - "graceful-fs": "^4.1.6" + "fill-keys": "^1.0.2", + "module-not-found-error": "^1.0.1", + "resolve": "^1.11.1" } }, "packages/tts-lib": { "version": "1.0.0-pre.1", "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/client-polly": "^3.651.1", + "@google-cloud/text-to-speech": "^5.0.1", + "async": "^3.0.0", + "debug": "^3.1.0", + "fs-extra": "^2.0.0", + "sax": "^1.2.4", + "sentence-splitter": "^5.0.0", + "tempfile": "^1.1.1", + "zen-observable": "^0.10.0" + }, "devDependencies": { - "codecov": "^3.6.5" + "codecov": "^3.6.5", + "proxyquire": "^2.1.3" + } + }, + "packages/tts-lib/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "packages/tts-lib/node_modules/proxyquire": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/proxyquire/-/proxyquire-2.1.3.tgz", + "integrity": "sha512-BQWfCqYM+QINd+yawJz23tbBM40VIGXOdDw3X344KcclI/gtBbdWF6SlQ4nK/bYhF9d27KYug9WzljHC6B9Ysg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-keys": "^1.0.2", + "module-not-found-error": "^1.0.1", + "resolve": "^1.11.1" } }, "packages/web-tts": { - "version": "0.2.4", + "version": "0.2.5", "license": "Apache-2.0", "dependencies": { "execa": "^4.0.3", diff --git a/package.json b/package.json index 9b9e0d5..7cd2801 100644 --- a/package.json +++ b/package.json @@ -36,7 +36,8 @@ "standard": "^17.1.0" }, "dependencies": { - "tts-cli": "file:packages/tts-cli" + "tts-cli": "file:packages/tts-cli", + "tts-lib": "file:packages/tts-lib" }, "resolutions": { "**/@grpc/grpc-js": ">=1.1.8", diff --git a/packages/tts-cli/lib/file-extensions.js b/packages/tts-cli/lib/file-extensions.js deleted file mode 100644 index 95e352c..0000000 --- a/packages/tts-cli/lib/file-extensions.js +++ /dev/null @@ -1,10 +0,0 @@ -exports.extensionFor = (format, service) => { - if (format === 'mp3') { - return 'mp3' - } else if (format === 'ogg' || format === 'ogg_vorbis') { - return 'ogg' - } else if (format === 'pcm') { - return service === 'gcp' ? 'wav' : 'pcm' - } - throw new Error(`No known file extension for "${format}" format`) -} diff --git a/packages/tts-cli/lib/generate-speech.js b/packages/tts-cli/lib/generate-speech.js deleted file mode 100644 index 1614e72..0000000 --- a/packages/tts-cli/lib/generate-speech.js +++ /dev/null @@ -1,116 +0,0 @@ -const async = require('async') -const debug = require('debug') -const fs = require('fs-extra') -const tempfile = require('tempfile') -const { extensionFor } = require('./file-extensions') -const { sanitizeOpts } = require('./sanitize-opts') - -/** - * Creates an object containing all the data. - */ -exports.buildInfo = (text, instance, task, ctx) => { - return Object.assign({ - opts: ctx.opts, - task, - tempfile: tempfile(`.${extensionFor(ctx.opts.format, ctx.service)}`), - text - }, instance.buildPart(text, task, ctx.opts)) -} - -/** - * Writes down all the temp files for ffmpeg to read in. - * Returns the text filename. - */ -exports.createManifest = parts => { - const txtFile = tempfile('.txt') - debug('createManifest')(`Creating ${txtFile} for manifest`) - const contents = parts.map(info => { - return `file '${info.tempfile}'` - }).join('\n') - debug('createManifest')(`Writing manifest contents:\n${contents}`) - fs.writeFileSync(txtFile, contents, 'utf8') - return txtFile -} - -/** - * Calls the API for each text part (throttled). Returns a Promise. - */ -exports.generateAll = (parts, opts, func, task) => { - const count = parts.length - task.title = `Convert to audio (0/${count})` - return (new Promise((resolve, reject) => { - debug('generateAll')(`Requesting ${count} audio segments, ${opts.limit} at a time`) - async.eachOfLimit( - parts, - opts.limit, - func, - err => { - debug('generateAll')(`Requested all parts, with error ${err}`) - if (err) { - return reject(err) - } - task.title = task.title.replace(/\d+\//, `${count}/`) - resolve(parts) - } - ) - })) -} - -/** - * Returns a Promise with the temporary audio file. - */ -exports.generateSpeech = (ctx, task) => { - const strParts = ctx.parts - - // Add in the default options. - ctx.opts = Object.assign({}, { - accessKey: ctx.args['access-key'], - effect: ctx.args.effect, - email: ctx.args.email, - engine: ctx.args.engine, - ffmpeg: ctx.args.ffmpeg || 'ffmpeg', - format: ctx.args.format || 'mp3', - gain: ctx.args.gain ? parseFloat(ctx.args.gain) : undefined, - gender: ctx.args.gender, - language: ctx.args.language || (ctx.service === 'gcp' ? 'en-US' : undefined), - lexicon: ctx.args.lexicon, - limit: Number(ctx.args.throttle) || 5, // eslint-disable-line no-magic-numbers - pitch: ctx.args.pitch ? parseFloat(ctx.args.pitch) : undefined, - privateKey: ctx.args['private-key'], - projectFile: ctx.args['project-file'], - projectId: ctx.args['project-id'], - region: ctx.args.region || 'us-east-1', - sampleRate: ctx.args['sample-rate'] ? Number(ctx.args['sample-rate']) : undefined, - secretKey: ctx.args['secret-key'], - speed: ctx.args.speed ? parseFloat(ctx.args.speed) : undefined, - type: ctx.args.type || 'text', - voice: ctx.args.voice || (ctx.service === 'gcp' ? undefined : 'Joanna') - }) - if (typeof ctx.opts.effect !== 'undefined' && !Array.isArray(ctx.opts.effect)) { - ctx.opts.effect = [ctx.opts.effect] - } - if (typeof ctx.opts.lexicon !== 'undefined' && !Array.isArray(ctx.opts.lexicon)) { - ctx.opts.lexicon = [ctx.opts.lexicon] - } - if (ctx.service === 'aws' && ctx.opts.format === 'ogg_vorbis') { - debug('generateSpeech')('Warning: Format "ogg_vorbis" is deprecated; use "ogg" instead') - ctx.opts.format = 'ogg' - } - if (ctx.args['private-key-file']) { - debug('generateSpeech')(`Reading private key from ${ctx.args['private-key-file']}`) - ctx.opts.privateKey = fs.readFileSync(ctx.args['private-key-file'], 'utf8') - } - debug('generateSpeech')(`Options: ${JSON.stringify(sanitizeOpts(ctx.opts))}`) - - const provider = require(`./providers/${ctx.service}`) - const instance = provider.create(ctx.opts) - - // Compile the text parts and options together in a packet. - const parts = strParts.map(part => exports.buildInfo(part, instance, task, ctx)) - - return exports.generateAll(parts, ctx.opts, instance.generate.bind(instance), task) - .then(exports.createManifest) - .then(manifest => { - ctx.manifestFile = manifest - }) -} diff --git a/packages/tts-cli/lib/providers/aws.js b/packages/tts-cli/lib/providers/aws.js deleted file mode 100644 index 8acbc1b..0000000 --- a/packages/tts-cli/lib/providers/aws.js +++ /dev/null @@ -1,65 +0,0 @@ -const { Polly, SynthesizeSpeechCommand } = require('@aws-sdk/client-polly') -const debug = require('debug') -const fs = require('fs-extra') - -const PollyProvider = function (opts) { - this.instance = new Polly({ - credentials: { - accessKeyId: opts.accessKey, - secretAccessKey: opts.secretKey - }, - region: opts.region - }) -} - -exports.PollyProvider = PollyProvider - -PollyProvider.prototype.buildPart = function () { - return { - send: this.instance.send.bind(this.instance) - } -} - -/** - * Calls the Polly API with the given info. - */ -PollyProvider.prototype.generate = (info, i, callback) => { - info.task.title = info.task.title.replace(/\d+\//, `${i}/`) - - const command = new SynthesizeSpeechCommand({ - Engine: info.opts.engine, - LanguageCode: info.opts.language, - LexiconNames: info.opts.lexicon, - OutputFormat: info.opts.format === 'ogg' ? 'ogg_vorbis' : info.opts.format, - SampleRate: info.opts.sampleRate ? String(info.opts.sampleRate) : undefined, - Text: info.text, - TextType: info.opts.type, - VoiceId: info.opts.voice - }) - - debug('generate')('Making request to Amazon Web Services') - info.send(command).then(response => { - debug('generate')(`Writing audio content to ${info.tempfile}`) - const fileStream = fs.createWriteStream(info.tempfile) - response.AudioStream.pipe(fileStream) - fileStream.on('finish', () => { - fileStream.close() - callback() - }) - fileStream.on('error', err => { - debug('generate')(`Error writing: ${err.message}`) - return callback(err) - }) - }, err => { - debug('generate')(`Error during request: ${err.message}`) - return callback(err) - }) -} - -/** - * Create an AWS Polly instance. - */ -exports.create = opts => { - debug('create')(`Creating AWS Polly instance in ${opts.region}`) - return new PollyProvider(opts) -} diff --git a/packages/tts-cli/lib/providers/gcp.js b/packages/tts-cli/lib/providers/gcp.js deleted file mode 100644 index 4757ffa..0000000 --- a/packages/tts-cli/lib/providers/gcp.js +++ /dev/null @@ -1,86 +0,0 @@ -const debug = require('debug') -const fs = require('fs-extra') -const path = require('path') -const GoogleClient = require('@google-cloud/text-to-speech').TextToSpeechClient - -const GoogleProvider = function (opts) { - try { - this.instance = new GoogleClient({ - credentials: opts.email || opts.privateKey - ? { - client_email: opts.email, - private_key: opts.privateKey - } - : undefined, - keyFilename: opts.projectFile ? path.resolve(opts.projectFile) : undefined, - projectId: opts.projectId - }) - } catch (err) { - /* istanbul ignore next */ - this.instance = null - } -} - -exports.GoogleProvider = GoogleProvider - -GoogleProvider.prototype.buildPart = function () { - return { - synthesizer: this.instance.synthesizeSpeech.bind(this.instance) - } -} - -/** - * Calls the Google Cloud API with the given info. - */ -GoogleProvider.prototype.generate = (info, i, callback) => { - info.task.title = info.task.title.replace(/\d+\//, `${i}/`) - - const request = { - input: info.opts.type === 'ssml' - ? { ssml: info.text } - : { text: info.text }, - voice: { - ssmlGender: info.opts.gender ? String(info.opts.gender).toUpperCase() : undefined, - languageCode: info.opts.language, - name: info.opts.voice - }, - audioConfig: { - audioEncoding: info.opts.format === 'pcm' - ? 'LINEAR16' - : info.opts.format === 'ogg' ? 'OGG_OPUS' : 'MP3', - effectsProfileId: info.opts.effect, - pitch: info.opts.pitch, - sampleRateHertz: info.opts.sampleRate, - speakingRate: info.opts.speed, - volumeGainDb: info.opts.gain - } - } - const opts = { - retry: null - } - - debug('generate')('Making request to Google Cloud Platform') - info.synthesizer(request, opts, (err, response) => { - if (err) { - debug('generate')(`Error during request: ${err.message}`) - return callback(err) - } - - debug('generate')(`Writing audio content to ${info.tempfile}`) - fs.writeFile(info.tempfile, response.audioContent, 'binary', err => { - if (err) { - debug('generate')(`Error writing: ${err.message}`) - return callback(err) - } - callback() - }) - }) -} - -/** - * Create a Google Cloud TTS instance. - */ -exports.create = opts => { - debug('create')('Creating Google Cloud TTS instance') - return new GoogleProvider(opts) -} diff --git a/packages/tts-cli/package.json b/packages/tts-cli/package.json index f0f4a26..fb50773 100644 --- a/packages/tts-cli/package.json +++ b/packages/tts-cli/package.json @@ -35,20 +35,14 @@ }, "homepage": "https://github.com/eheikes/tts/tree/master/packages/tts-cli#readme", "dependencies": { - "@aws-sdk/client-polly": "^3.651.1", - "@google-cloud/text-to-speech": "^5.0.1", - "async": "^3.0.0", "debug": "^3.1.0", "fs-extra": "^2.0.0", "listr2": "^8.0.2", - "minimist": "^1.2.0", - "sax": "^1.2.4", - "sentence-splitter": "^5.0.0", - "tempfile": "^1.1.1", - "zen-observable": "^0.10.0" + "minimist": "^1.2.0" }, "devDependencies": { - "codecov": "^3.6.5" + "codecov": "^3.6.5", + "proxyquire": "^2.1.3" }, "standard": { "env": { diff --git a/packages/tts-cli/test/build-info.spec.js b/packages/tts-cli/test/build-info.spec.js deleted file mode 100644 index bd9b882..0000000 --- a/packages/tts-cli/test/build-info.spec.js +++ /dev/null @@ -1,45 +0,0 @@ -describe('buildInfo()', () => { - const task = {} - const text = 'foobar' - const format = 'mp3' - const instance = { foo: 1, bar: 2 } - const ctx = { - opts: { format } - } - - let buildInfo, output - - beforeEach(() => { - ({ buildInfo } = require('./helpers').loadLib('generate-speech')) - output = buildInfo(text, { buildPart: () => instance }, task, ctx) - }) - - it('should return an object', () => { - expect(output).toEqual(jasmine.any(Object)) - }) - - it('should have an "opts" property with the original options', () => { - expect(output.opts).toEqual(ctx.opts) - }) - - it('should have a "task" property', () => { - expect(output.task).toEqual(task) - }) - - it('should have a "tempfile" property', () => { - expect(output.tempfile).toEqual(jasmine.any(String)) - }) - - it('should have an appropriate file extension for the tempfile', () => { - expect(output.tempfile).toMatch(`\\.${format}$`) - }) - - it('should have a "text" property with the original text', () => { - expect(output.text).toBe(text) - }) - - it('should add in the instance\'s properties', () => { - expect(output.foo).toBe(instance.foo) - expect(output.bar).toBe(instance.bar) - }) -}) diff --git a/packages/tts-cli/test/cli.spec.js b/packages/tts-cli/test/cli.spec.js index f1a5423..d5793e7 100644 --- a/packages/tts-cli/test/cli.spec.js +++ b/packages/tts-cli/test/cli.spec.js @@ -1,15 +1,44 @@ const proxyquire = require('proxyquire') describe('CLI', () => { + const manifestFile = 'manifest file' const outputFile = 'output-file' + const parts = ['part1', 'part2'] + const tempFile = 'temp file' let cli - let args, minimist + let args + let mocks + let minimist + let cleanup + let createProvider beforeEach(() => { args = { _: [outputFile] } minimist = jasmine.createSpy('minimist').and.callFake(() => args) - cli = proxyquire('../tts', { minimist }) + cleanup = jasmine.createSpy('cleanup') + createProvider = jasmine.createSpy('createProvider').and.returnValue({ + combineAudio: () => { + return Promise.resolve(tempFile) + }, + generateSpeech: () => { + return Promise.resolve(manifestFile) + }, + splitText: () => { + return Promise.resolve(parts) + } + }) + mocks = { + 'fs-extra': { + readFileSync: () => 'file contents' + }, + minimist, + '../tts-lib/lib/cleanup': { cleanup }, + '../tts-lib/lib/provider': { + createProvider + } + } + cli = proxyquire('../tts', mocks) }) it('should construct an array of tasks', () => { @@ -23,10 +52,6 @@ describe('CLI', () => { expect(cli.context.args).toBe(args) }) - it('should pass the max character count to Listr', () => { - expect(cli.context.maxCharacterCount).toEqual(jasmine.any(Number)) - }) - it('should pass the process object to Listr', () => { expect(cli.context.process).toEqual(jasmine.any(Object)) expect(cli.context.process.argv).toEqual(jasmine.any(Array)) @@ -37,7 +62,7 @@ describe('CLI', () => { beforeEach(() => { args = { _: [inputFile, outputFile] } - cli = proxyquire('../tts', { minimist }) + cli = proxyquire('../tts', mocks) }) it('should use the first argument for the input filename', () => { @@ -52,7 +77,7 @@ describe('CLI', () => { describe('when only 1 argument is given', () => { beforeEach(() => { args = { _: [outputFile] } - cli = proxyquire('../tts', { minimist }) + cli = proxyquire('../tts', mocks) }) it('should use null for the input filename', () => { @@ -64,48 +89,143 @@ describe('CLI', () => { }) }) - describe('when the "aws" service is specified', () => { - beforeEach(() => { - args = { _: [outputFile], service: 'aws' } - cli = proxyquire('../tts', { minimist }) - }) + it('should create an AWS service', () => { + args = { _: [outputFile], service: 'aws' } + cli = proxyquire('../tts', mocks) + expect(cli.context.service).toBe('aws') + }) - it('should save that as the service', () => { - expect(cli.context.service).toBe('aws') - }) + it('should create a GCP service', () => { + args = { _: [outputFile], service: 'gcp' } + cli = proxyquire('../tts', mocks) + expect(cli.context.service).toBe('gcp') + }) - it('should set the appropriate maxCharacterCount', () => { - expect(cli.context.maxCharacterCount).toBe(1500) - }) + it('should create a default service', () => { + args = { _: [outputFile] } + cli = proxyquire('../tts', mocks) + expect(cli.context.service).toBe('aws') }) - describe('when the "gcp" service is specified', () => { - beforeEach(() => { - args = { _: [outputFile], service: 'gcp' } - cli = proxyquire('../tts', { minimist }) + it('should create the service with options derived from the CLI arguments', () => { + createProvider.calls.reset() + args = { + _: [outputFile], + 'access-key': 'test access key', + effect: 'test effect', + email: 'test email', + engine: 'test engine', + ffmpeg: 'test ffmpeg', + format: 'test format', + gain: '11.13', + gender: 'test gender', + language: 'test language', + lexicon: 'test lexicon', + pitch: '2.3', + 'private-key': 'test private key', + 'project-file': 'test project file', + 'project-id': 'test project id', + region: 'test region', + 'sample-rate': '16000', + 'secret-key': 'test secret key', + service: 'aws', + speed: '2.5', + throttle: '4', + type: 'test type', + voice: 'test voice' + } + cli = proxyquire('../tts', mocks) + expect(createProvider).toHaveBeenCalledWith('aws', { + accessKey: 'test access key', + effect: ['test effect'], + email: 'test email', + engine: 'test engine', + ffmpeg: 'test ffmpeg', + format: 'test format', + gain: 11.13, + gender: 'test gender', + language: 'test language', + lexicon: ['test lexicon'], + limit: 4, + pitch: 2.3, + privateKey: 'test private key', + projectFile: 'test project file', + projectId: 'test project id', + region: 'test region', + sampleRate: 16000, + secretKey: 'test secret key', + speed: 2.5, + type: 'test type', + voice: 'test voice' }) + }) - it('should save that as the service', () => { - expect(cli.context.service).toBe('gcp') + it('should create the service with default options', () => { + createProvider.calls.reset() + args = { _: [outputFile] } + cli = proxyquire('../tts', mocks) + expect(createProvider).toHaveBeenCalledWith('aws', { + accessKey: undefined, + effect: undefined, + email: undefined, + engine: undefined, + ffmpeg: 'ffmpeg', + format: 'mp3', + gain: undefined, + gender: undefined, + language: undefined, + lexicon: undefined, + limit: 5, + pitch: undefined, + privateKey: undefined, + projectFile: undefined, + projectId: undefined, + region: 'us-east-1', + sampleRate: undefined, + secretKey: undefined, + speed: undefined, + type: 'text', + voice: undefined }) + }) - it('should set the appropriate maxCharacterCount', () => { - expect(cli.context.maxCharacterCount).toBe(5000) - }) + it('should use private key in private key file', () => { + createProvider.calls.reset() + args = { _: [outputFile], 'private-key-file': 'foobar' } + cli = proxyquire('../tts', mocks) + const opts = createProvider.calls.mostRecent().args[1] + expect(opts.privateKey).toBe('file contents') }) - describe('when no service is specified', () => { - beforeEach(() => { - args = { _: [outputFile] } - cli = proxyquire('../tts', { minimist }) - }) + it('should call splitText() in the text-splitting task', async () => { + args = { _: [outputFile] } + cli = proxyquire('../tts', mocks) + const context = { text: 'test text' } + await cli.tasks[1].task(context) + expect(context.parts).toEqual(parts) + }) - it('should use the default service', () => { - expect(cli.context.service).toBe('aws') - }) + it('should call generateSpeech() in the speech-generation task', async () => { + args = { _: [outputFile] } + cli = proxyquire('../tts', mocks) + const context = { parts } + await cli.tasks[2].task(context, {}) + expect(context.manifestFile).toBe(manifestFile) + }) - it('should set the appropriate maxCharacterCount', () => { - expect(cli.context.maxCharacterCount).toBe(1500) - }) + it('should call combineAudio() in the combine-audio task', async () => { + args = { _: [outputFile] } + cli = proxyquire('../tts', mocks) + const context = { manifestFile } + await cli.tasks[3].task(context) + expect(context.tempFile).toBe(tempFile) + }) + + it('should call cleanup() in the cleanup task', async () => { + args = { _: [outputFile] } + cli = proxyquire('../tts', mocks) + const context = { manifestFile } + await cli.tasks[4].task(context) + expect(cleanup).toHaveBeenCalledWith(manifestFile) }) }) diff --git a/packages/tts-cli/test/combine.spec.js b/packages/tts-cli/test/combine.spec.js deleted file mode 100644 index f4d8ffd..0000000 --- a/packages/tts-cli/test/combine.spec.js +++ /dev/null @@ -1,83 +0,0 @@ -describe('combine()', () => { - const testManifest = 'manifest.txt' - - let combine, fs, spawn - let ctx - - beforeEach(() => { - ctx = { - manifestFile: testManifest, - opts: { - ffmpeg: 'ffmpeg', - format: 'mp3' - } - } - const lib = require('./helpers').loadLib('combine-parts') - combine = lib.combine - fs = lib.fs - spawn = lib.spawn - }) - - describe('when the format is MP3', () => { - it('should call combineEncodedAudio()', () => { - return combine(ctx).then(() => { - // We can't spy on combineEncodedAudio() directly, so look at its internals. - expect(spawn).toHaveBeenCalled() - expect(spawn.calls.mostRecent().args[0]).toBe(ctx.opts.ffmpeg) - }) - }) - }) - - describe('when the format is PCM', () => { - describe('and the service is AWS', () => { - it('should call combineRawAudio()', () => { - ctx.service = 'aws' - ctx.opts.format = 'pcm' - return combine(ctx).then(() => { - // We can't spy on combineRawAudio() directly, so look at its internals. - expect(fs.createFileSync).toHaveBeenCalled() - }) - }) - }) - - describe('and the service is GCP', () => { - it('should call combineRawAudio()', () => { - ctx.service = 'gcm' - ctx.opts.format = 'pcm' - return combine(ctx).then(() => { - // We can't spy on combineEncodedAudio() directly, so look at its internals. - expect(spawn).toHaveBeenCalled() - expect(spawn.calls.mostRecent().args[0]).toBe(ctx.opts.ffmpeg) - }) - }) - }) - }) - - describe('when it succeeds', () => { - beforeEach(() => { - ctx.opts.format = 'pcm' - return combine(ctx) - }) - - it('should return the new filename', () => { - expect(ctx.tempFile).toMatch(/\.pcm$/) - }) - }) - - describe('when it fails', () => { - let result - - beforeEach(() => { - spawn.on.and.callFake((type, callback) => { - if (type === 'error') { callback() } - }) - return combine(ctx).catch(response => { - result = response - }) - }) - - it('should return a rejected promise with the error', () => { - expect(result.message).toMatch('Could not start ffmpeg process') - }) - }) -}) diff --git a/packages/tts-cli/test/file-extensions.spec.js b/packages/tts-cli/test/file-extensions.spec.js deleted file mode 100644 index 445956b..0000000 --- a/packages/tts-cli/test/file-extensions.spec.js +++ /dev/null @@ -1,31 +0,0 @@ -const { extensionFor } = require('../lib/file-extensions') - -describe('extensionFor()', () => { - it('should return "mp3" for the MP3 format', () => { - expect(extensionFor('mp3', 'aws')).toBe('mp3') - expect(extensionFor('mp3', 'gcp')).toBe('mp3') - }) - - it('should return "ogg" for the Ogg format', () => { - expect(extensionFor('ogg', 'aws')).toBe('ogg') - expect(extensionFor('ogg', 'gcp')).toBe('ogg') - }) - - it('should return "ogg" for the (deprecated) Ogg Vorbis format', () => { - expect(extensionFor('ogg_vorbis', 'aws')).toBe('ogg') - }) - - it('should return "pcm" for the PCM format on AWS', () => { - expect(extensionFor('pcm', 'aws')).toBe('pcm') - }) - - it('should return "wav" for the PCM format on GCP', () => { - expect(extensionFor('pcm', 'gcp')).toBe('wav') - }) - - it('should throw an error for unknown formats', () => { - expect(() => { - extensionFor('foo', 'aws') - }).toThrow() - }) -}) diff --git a/packages/tts-cli/test/helpers.js b/packages/tts-cli/test/helpers.js index 3d5aec1..2037310 100644 --- a/packages/tts-cli/test/helpers.js +++ b/packages/tts-cli/test/helpers.js @@ -1,3 +1,4 @@ +// TODO clean this up const async = require('async') const originalFs = require('fs') const proxyquire = require('proxyquire') @@ -49,8 +50,6 @@ exports.loadLib = (file) => { // Load the library module. const lib = proxyquire(`../lib/${file}`, { - './providers/aws': providerStub, - './providers/gcp': providerStub, async, child_process: { spawn }, // eslint-disable-line camelcase 'fs-extra': fs diff --git a/packages/tts-cli/tts.js b/packages/tts-cli/tts.js index 4a510e4..8eda4aa 100755 --- a/packages/tts-cli/tts.js +++ b/packages/tts-cli/tts.js @@ -1,17 +1,17 @@ #!/usr/bin/env node /** - * Takes a text file and calls the AWS Polly API + * Takes a text file and calls the appropriate TTS API * to convert it to an audio file. */ const debug = require('debug')('tts-cli') +const fs = require('fs-extra') +const { createProvider } = require('../tts-lib/lib/provider') +const { cleanup } = require('../tts-lib/lib/cleanup') + const { checkUsage } = require('./lib/check-usage') -const { cleanup } = require('./lib/cleanup') -const { combine } = require('./lib/combine-parts') -const { generateSpeech } = require('./lib/generate-speech') const { moveTempFile } = require('./lib/move-temp-file') const { readText } = require('./lib/read-text') const { sanitizeOpts } = require('./lib/sanitize-opts') -const { splitText } = require('./lib/split-text') const args = require('minimist')(process.argv.slice(2)) debug('called with arguments', JSON.stringify(sanitizeOpts(args))) @@ -29,38 +29,85 @@ debug('output:', outputFilename) // Check the usage. checkUsage(args, process) +// Set the options. +const opts = Object.assign({}, { + accessKey: args['access-key'], + effect: args.effect, + email: args.email, + engine: args.engine, + ffmpeg: args.ffmpeg || 'ffmpeg', + format: args.format || 'mp3', + gain: args.gain ? parseFloat(args.gain) : undefined, + gender: args.gender, + language: args.language, + lexicon: args.lexicon, + limit: Number(args.throttle) || 5, // eslint-disable-line no-magic-numbers + pitch: args.pitch ? parseFloat(args.pitch) : undefined, + privateKey: args['private-key'], + projectFile: args['project-file'], + projectId: args['project-id'], + region: args.region || 'us-east-1', + sampleRate: args['sample-rate'] ? Number(args['sample-rate']) : undefined, + secretKey: args['secret-key'], + speed: args.speed ? parseFloat(args.speed) : undefined, + type: args.type || 'text', + voice: args.voice +}) +if (typeof opts.effect !== 'undefined' && !Array.isArray(opts.effect)) { + opts.effect = [opts.effect] +} +if (typeof opts.lexicon !== 'undefined' && !Array.isArray(opts.lexicon)) { + opts.lexicon = [opts.lexicon] +} +if (args['private-key-file']) { + debug(`Reading private key from ${args['private-key-file']}`) + opts.privateKey = fs.readFileSync(args['private-key-file'], 'utf8') +} +debug(`Options: ${JSON.stringify(sanitizeOpts(opts))}`) + +// Create the service provider. +const service = args.service || 'aws' +const provider = createProvider(service, opts) + // Define the tasks and options. const tasks = [{ title: 'Reading text', - task: readText + task: readText // TODO make more functional, no ctx }, { title: 'Splitting text', - task: splitText + task: async (ctx) => { + ctx.parts = await provider.splitText(ctx.text) + } }, { title: 'Convert to audio', - task: generateSpeech + task: async (ctx, task) => { + ctx.manifestFile = await provider.generateSpeech(ctx.parts, task) + } }, { title: 'Combine audio', - task: combine + task: async (ctx) => { + ctx.tempFile = await provider.combineAudio(ctx.manifestFile) + } }, { title: 'Clean up', - task: cleanup + task: async (ctx) => { + await cleanup(ctx.manifestFile) + } }, { title: 'Saving file', task: moveTempFile }] -const service = args.service || 'aws' const context = { args, input, - maxCharacterCount: service === 'gcp' ? 5000 : 1500, outputFilename, process, service } // Run the tasks. -if (require.main === module) /* istanbul ignore next */{ +/* istanbul ignore next */ +if (require.main === module) { const { Listr } = require('listr2') const list = new Listr(tasks, { renderer: debug.enabled ? 'silent' : 'default' diff --git a/packages/tts-lib/index.js b/packages/tts-lib/index.js index c198d64..9848637 100644 --- a/packages/tts-lib/index.js +++ b/packages/tts-lib/index.js @@ -1 +1,2 @@ // Nothing yet, sorry! +// TODO diff --git a/packages/tts-cli/lib/cleanup.js b/packages/tts-lib/lib/cleanup.js similarity index 55% rename from packages/tts-cli/lib/cleanup.js rename to packages/tts-lib/lib/cleanup.js index d5085dc..5f61ec1 100644 --- a/packages/tts-cli/lib/cleanup.js +++ b/packages/tts-lib/lib/cleanup.js @@ -1,19 +1,18 @@ const debug = require('debug')('cleanup') -const fs = require('fs-extra') +const fs = require('fs-extra') // TODO not needed anymore? /** * Deletes the manifest and its files. */ -exports.cleanup = ctx => { - const manifestFile = ctx.manifestFile - const manifest = fs.readFileSync(manifestFile, 'utf8') +exports.cleanup = async (manifestFile) => { + const manifest = fs.readFileSync(manifestFile, 'utf8') // TODO use async debug(`Manifest is ${manifest}`) const regexpState = /^file\s+'(.*)'$/gm let match while ((match = regexpState.exec(manifest)) !== null) { debug(`Deleting temporary file ${match[1]}`) - fs.removeSync(match[1]) + fs.removeSync(match[1]) // TODO use async } debug(`Deleting manifest file ${manifestFile}`) - fs.removeSync(manifestFile) + fs.removeSync(manifestFile) // TODO use async } diff --git a/packages/tts-cli/lib/combine-parts.js b/packages/tts-lib/lib/combine-parts.js similarity index 78% rename from packages/tts-cli/lib/combine-parts.js rename to packages/tts-lib/lib/combine-parts.js index a866366..e366777 100644 --- a/packages/tts-cli/lib/combine-parts.js +++ b/packages/tts-lib/lib/combine-parts.js @@ -1,8 +1,6 @@ const spawn = require('child_process').spawn const debug = require('debug') const fs = require('fs-extra') -const tempfile = require('tempfile') -const { extensionFor } = require('./file-extensions') /** * Combines MP3 or OGG files into one file. @@ -61,15 +59,12 @@ exports.combineRawAudio = (manifestFile, outputFile) => { * Combines all the parts into one file. * Resolves with the new filename. */ -exports.combine = (ctx) => { - const manifestFile = ctx.manifestFile - const opts = ctx.opts - const newFile = tempfile(`.${extensionFor(opts.format, ctx.service)}`) +exports.combine = async (manifestFile, newFile, format = 'encoded', ffmpegBinary = 'ffmpeg') => { debug('combine')(`Combining files into ${newFile}`) - const combiner = opts.format === 'pcm' && ctx.service === 'aws' - ? exports.combineRawAudio(manifestFile, newFile) - : exports.combineEncodedAudio(opts.ffmpeg, manifestFile, newFile) - return combiner.then(() => { - ctx.tempFile = newFile - }) + if (format === 'raw') { + await exports.combineRawAudio(manifestFile, newFile) + } else { + await exports.combineEncodedAudio(ffmpegBinary, manifestFile, newFile) + } + return newFile } diff --git a/packages/tts-lib/lib/generate-speech.js b/packages/tts-lib/lib/generate-speech.js new file mode 100644 index 0000000..bc030b9 --- /dev/null +++ b/packages/tts-lib/lib/generate-speech.js @@ -0,0 +1,43 @@ +const async = require('async') +const debug = require('debug') +const fs = require('fs-extra') +const tempfile = require('tempfile') + +/** + * Writes down all the temp files for ffmpeg to read in. + * Returns the text filename. + */ +exports.createManifest = parts => { + const txtFile = tempfile('.txt') + debug('createManifest')(`Creating ${txtFile} for manifest`) + const contents = parts.map(info => { + return `file '${info.tempfile}'` + }).join('\n') + debug('createManifest')(`Writing manifest contents:\n${contents}`) + fs.writeFileSync(txtFile, contents, 'utf8') + return txtFile +} + +/** + * Calls the API for each text part (throttled). Returns a Promise. + */ +exports.generateAll = (parts, limit, func, task) => { + const count = parts.length + task.title = `Convert to audio (0/${count})` + return (new Promise((resolve, reject) => { + debug('generateAll')(`Requesting ${count} audio segments, ${limit} at a time`) + async.eachOfLimit( + parts, + limit, + func, + err => { + debug('generateAll')(`Requested all parts, with error ${err}`) + if (err) { + return reject(err) + } + task.title = task.title.replace(/\d+\//, `${count}/`) + resolve(parts) + } + ) + })) +} diff --git a/packages/tts-lib/lib/provider.js b/packages/tts-lib/lib/provider.js new file mode 100644 index 0000000..c8e8339 --- /dev/null +++ b/packages/tts-lib/lib/provider.js @@ -0,0 +1,14 @@ +const { AwsProvider } = require('./providers/aws') +const { GcpProvider } = require('./providers/gcp') + +const providers = { + aws: AwsProvider, + gcp: GcpProvider +} + +exports.createProvider = (service, opts) => { + if (!providers[service]) { + throw new Error(`Unsupported service: ${service}`) + } + return new providers[service](opts) +} diff --git a/packages/tts-lib/lib/providers/aws.js b/packages/tts-lib/lib/providers/aws.js new file mode 100644 index 0000000..4eb5d50 --- /dev/null +++ b/packages/tts-lib/lib/providers/aws.js @@ -0,0 +1,111 @@ +const { Polly, SynthesizeSpeechCommand } = require('@aws-sdk/client-polly') +const debug = require('debug') +const fs = require('fs-extra') +const tempfile = require('tempfile') +const { combine } = require('../combine-parts') +const { createManifest, generateAll } = require('../generate-speech') +const { splitText } = require('../split-text') + +const AwsProvider = function (opts = {}) { + this.name = 'AWS' + this.maxCharacterCount = 1500 + + this.opts = opts + if (this.opts.format === 'ogg_vorbis') { + debug('aws')('Warning: Format "ogg_vorbis" is deprecated; use "ogg" instead') + this.opts.format = 'ogg' + } + if (typeof this.opts.voice === 'undefined') { + debug('aws')('Setting default voice to "Joanna"') + this.opts.voice = 'Joanna' + } + + debug('create')(`Creating AWS Polly instance in ${this.opts.region}`) + this.instance = new Polly({ + credentials: { + accessKeyId: this.opts.accessKey, + secretAccessKey: this.opts.secretKey + }, + region: this.opts.region + }) +} + +AwsProvider.prototype.extensionFor = (format) => { + if (format === 'mp3') { + return 'mp3' + } else if (format === 'ogg' || format === 'ogg_vorbis') { + return 'ogg' + } else if (format === 'pcm') { + return 'pcm' + } + throw new Error(`No known file extension for "${format}" format`) +} + +AwsProvider.prototype.splitText = function (text) { // TODO put on parent + return splitText(text, this.maxCharacterCount, this.opts.type) +} + +AwsProvider.prototype.combineAudio = function (manifestFile) { // TODO put on parent + const newFile = tempfile(`.${this.extensionFor(this.opts.format)}`) + return combine(manifestFile, newFile, this.opts.format === 'pcm' ? 'raw' : 'encoded', this.opts.ffmpeg) +} + +/** + * Creates an object containing all the data. + */ +AwsProvider.prototype.buildInfo = function (text, task) { + return { + task, + tempfile: tempfile(`.${this.extensionFor(this.opts.format)}`), + text, + send: this.instance.send.bind(this.instance) + } +} + +/** + * Calls the Polly API with the given info. + */ +AwsProvider.prototype.generate = function (info, i, callback) { + info.task.title = info.task.title.replace(/\d+\//, `${i}/`) + + const command = new SynthesizeSpeechCommand({ + Engine: info.opts.engine, + LanguageCode: info.opts.language, + LexiconNames: info.opts.lexicon, + OutputFormat: info.opts.format === 'ogg' ? 'ogg_vorbis' : info.opts.format, + SampleRate: info.opts.sampleRate ? String(info.opts.sampleRate) : undefined, + Text: info.text, + TextType: info.opts.type, + VoiceId: info.opts.voice + }) + + debug('generate')('Making request to Amazon Web Services') + info.send(command).then(response => { + debug('generate')(`Writing audio content to ${info.tempfile}`) + const fileStream = fs.createWriteStream(info.tempfile) + response.AudioStream.pipe(fileStream) + fileStream.on('finish', () => { + fileStream.close() + callback() + }) + fileStream.on('error', err => { + debug('generate')(`Error writing: ${err.message}`) + return callback(err) + }) + }, err => { + debug('generate')(`Error during request: ${err.message}`) + return callback(err) + }) +} + +/** + * Returns a Promise with the temporary audio file. + */ +AwsProvider.prototype.generateSpeech = async function (strParts, task) { // TODO put on parent + // Compile the text parts and options together in a packet. + const parts = strParts.map(part => this.buildInfo(part, task)) + const updatedParts = await generateAll(parts, this.opts.limit, this.generate.bind(this), task) + return createManifest(updatedParts) +} + +exports.AwsProvider = AwsProvider diff --git a/packages/tts-lib/lib/providers/gcp.js b/packages/tts-lib/lib/providers/gcp.js new file mode 100644 index 0000000..7a5b9d5 --- /dev/null +++ b/packages/tts-lib/lib/providers/gcp.js @@ -0,0 +1,128 @@ +const debug = require('debug') +const fs = require('fs-extra') +const path = require('path') +const tempfile = require('tempfile') +const GoogleClient = require('@google-cloud/text-to-speech').TextToSpeechClient +const { combine } = require('../combine-parts') +const { createManifest, generateAll } = require('../generate-speech') +const { splitText } = require('../split-text') + +const GcpProvider = function (opts = {}) { + this.name = 'GCP' + this.maxCharacterCount = 5000 + + this.opts = opts + if (typeof this.opts.language === 'undefined') { + debug('gcp')('Setting default language to "en-US"') + this.opts.language = 'en-US' + } + + debug('create')('Creating Google Cloud TTS instance') + try { + this.instance = new GoogleClient({ + credentials: this.opts.email || this.opts.privateKey + ? { + client_email: this.opts.email, + private_key: this.opts.privateKey + } + : undefined, + keyFilename: this.opts.projectFile ? path.resolve(this.opts.projectFile) : undefined, + projectId: this.opts.projectId + }) + } catch (err) { + /* istanbul ignore next */ + this.instance = null + } +} + +GcpProvider.prototype.extensionFor = (format) => { + if (format === 'mp3') { + return 'mp3' + } else if (format === 'ogg' || format === 'ogg_vorbis') { + return 'ogg' + } else if (format === 'pcm') { + return 'wav' + } + throw new Error(`No known file extension for "${format}" format`) +} + +GcpProvider.prototype.splitText = function (text) { // TODO put on parent + return splitText(text, this.maxCharacterCount, this.opts.type) +} + +GcpProvider.prototype.combineAudio = function (manifestFile) { // TODO put on parent + const newFile = tempfile(`.${this.extensionFor(this.opts.format)}`) + return combine(manifestFile, newFile, 'encoded', this.opts.ffmpeg) +} + +/** + * Creates an object containing all the data. + */ +GcpProvider.prototype.buildInfo = function (text, task) { + return { + task, + tempfile: tempfile(`.${this.extensionFor(this.opts.format)}`), + text, + synthesizer: this.instance.synthesizeSpeech.bind(this.instance) + } +} + +/** + * Calls the Google Cloud API with the given info. + */ +GcpProvider.prototype.generate = function (info, i, callback) { + info.task.title = info.task.title.replace(/\d+\//, `${i}/`) + + const request = { + input: info.opts.type === 'ssml' + ? { ssml: info.text } + : { text: info.text }, + voice: { + ssmlGender: info.opts.gender ? String(info.opts.gender).toUpperCase() : undefined, + languageCode: info.opts.language, + name: info.opts.voice + }, + audioConfig: { + audioEncoding: info.opts.format === 'pcm' + ? 'LINEAR16' + : info.opts.format === 'ogg' ? 'OGG_OPUS' : 'MP3', + effectsProfileId: info.opts.effect, + pitch: info.opts.pitch, + sampleRateHertz: info.opts.sampleRate, + speakingRate: info.opts.speed, + volumeGainDb: info.opts.gain + } + } + const opts = { + retry: null + } + + debug('generate')('Making request to Google Cloud Platform') + info.synthesizer(request, opts, (err, response) => { + if (err) { + debug('generate')(`Error during request: ${err.message}`) + return callback(err) + } + + debug('generate')(`Writing audio content to ${info.tempfile}`) + fs.writeFile(info.tempfile, response.audioContent, 'binary', err => { + if (err) { + debug('generate')(`Error writing: ${err.message}`) + return callback(err) + } + callback() + }) + }) +} + +/** + * Returns a Promise with the temporary audio file. + */ +GcpProvider.prototype.generateSpeech = async function (strParts, task) { + // Compile the text parts and options together in a packet. + const parts = strParts.map(part => this.buildInfo(part, task)) + const updatedParts = await generateAll(parts, this.opts.limit, this.generate.bind(this), task) + return createManifest(updatedParts) +} + +exports.GcpProvider = GcpProvider diff --git a/packages/tts-cli/lib/split-text.js b/packages/tts-lib/lib/split-text.js similarity index 93% rename from packages/tts-cli/lib/split-text.js rename to packages/tts-lib/lib/split-text.js index 0866776..eb40186 100644 --- a/packages/tts-cli/lib/split-text.js +++ b/packages/tts-lib/lib/split-text.js @@ -97,11 +97,8 @@ const chunkXml = (xml, maxCharacterCount) => { /** * Splits a string of text into chunks. */ -exports.splitText = (ctx) => { - const text = ctx.text - const maxCharacterCount = ctx.maxCharacterCount - const opts = ctx.args || {} - const chunker = opts.type === 'ssml' ? chunkXml : chunkText +exports.splitText = (text, maxCharacterCount, type = 'text') => { + const chunker = type === 'ssml' ? chunkXml : chunkText return chunker(text, maxCharacterCount).then(parts => { debug('splitText')('Stripping whitespace') return parts.map(str => { @@ -111,7 +108,5 @@ exports.splitText = (ctx) => { // Trim whitespace from the ends. return str.trim() }) - }).then(parts => { - ctx.parts = parts }) } diff --git a/packages/tts-cli/lib/text-chunk.js b/packages/tts-lib/lib/text-chunk.js similarity index 93% rename from packages/tts-cli/lib/text-chunk.js rename to packages/tts-lib/lib/text-chunk.js index f4460b6..b3edc44 100644 --- a/packages/tts-cli/lib/text-chunk.js +++ b/packages/tts-lib/lib/text-chunk.js @@ -46,10 +46,12 @@ const chunkText = (text, maxLength) => { j++ } safety3++ + /* istanbul ignore next */ if (safety3 > words.length + epsilon) { throw new Error('Infinite loop') } } // If there is an unfilled chunk remaining, add it to the list. + /* istanbul ignore else: I don't think this condition is ever false... */ if (chunk !== '') { chunks.push(chunk) } @@ -65,11 +67,13 @@ const chunkText = (text, maxLength) => { chunk += newChunk i++ safety2++ + /* istanbul ignore next */ if (safety2 > sentences.length + epsilon) { throw new Error('Infinite loop') } } chunks.push(chunk) } safety++ + /* istanbul ignore next */ if (safety > sentences.length + epsilon) { throw new Error('Infinite loop') } } diff --git a/packages/tts-lib/package.json b/packages/tts-lib/package.json index b6223b6..15ac696 100644 --- a/packages/tts-lib/package.json +++ b/packages/tts-lib/package.json @@ -19,11 +19,51 @@ "type": "git", "url": "git+https://github.com/eheikes/tts.git" }, - "scripts": {}, + "scripts": { + "lint": "standard --env jasmine --fix --verbose", + "report-coverage": "codecov", + "test": "cross-env JASMINE_CONFIG_PATH=test/jasmine.json nyc jasmine" + }, "bugs": { "url": "https://github.com/eheikes/tts/issues" }, + "dependencies": { + "@aws-sdk/client-polly": "^3.651.1", + "@google-cloud/text-to-speech": "^5.0.1", + "async": "^3.0.0", + "debug": "^3.1.0", + "fs-extra": "^2.0.0", + "sax": "^1.2.4", + "sentence-splitter": "^5.0.0", + "tempfile": "^1.1.1", + "zen-observable": "^0.10.0" + }, "devDependencies": { - "codecov": "^3.6.5" + "codecov": "^3.6.5", + "proxyquire": "^2.1.3" + }, + "standard": { + "env": { + "node": true + } + }, + "nyc": { + "check-coverage": true, + "per-file": false, + "lines": 95, + "statements": 95, + "functions": 95, + "branches": 95, + "include": [ + "lib/**/*.js", + "*.js" + ], + "reporter": [ + "lcov", + "text-summary" + ], + "cache": false, + "all": false, + "report-dir": "./coverage" } } diff --git a/packages/tts-cli/test/cleanup.spec.js b/packages/tts-lib/test/cleanup.spec.js similarity index 88% rename from packages/tts-cli/test/cleanup.spec.js rename to packages/tts-lib/test/cleanup.spec.js index 4414be9..e6681ed 100644 --- a/packages/tts-cli/test/cleanup.spec.js +++ b/packages/tts-lib/test/cleanup.spec.js @@ -3,19 +3,15 @@ describe('cleanup()', () => { const tempFilenames = ['foo.mp3', 'bar.mp3'] let cleanup, fs - let ctx beforeEach(() => { ({ cleanup, fs } = require('./helpers').loadLib('cleanup')) - ctx = { - manifestFile: manifestFilename - } }) beforeEach(() => { const manifestContents = tempFilenames.map(filename => `file '${filename}'`).join('\n') fs.readFileSync.and.callFake(() => manifestContents) - return cleanup(ctx) + return cleanup(manifestFilename) }) it('should delete the manifest file', () => { diff --git a/packages/tts-cli/test/combine-encoded-audio.spec.js b/packages/tts-lib/test/combine-encoded-audio.spec.js similarity index 100% rename from packages/tts-cli/test/combine-encoded-audio.spec.js rename to packages/tts-lib/test/combine-encoded-audio.spec.js diff --git a/packages/tts-cli/test/combine-raw-audio.spec.js b/packages/tts-lib/test/combine-raw-audio.spec.js similarity index 100% rename from packages/tts-cli/test/combine-raw-audio.spec.js rename to packages/tts-lib/test/combine-raw-audio.spec.js diff --git a/packages/tts-lib/test/combine.spec.js b/packages/tts-lib/test/combine.spec.js new file mode 100644 index 0000000..e67194e --- /dev/null +++ b/packages/tts-lib/test/combine.spec.js @@ -0,0 +1,73 @@ +const tempfile = require('tempfile') + +describe('combine()', () => { + const testManifest = 'manifest.txt' + + let combine, fs, spawn + + beforeEach(() => { + const lib = require('./helpers').loadLib('combine-parts') + combine = lib.combine + fs = lib.fs + spawn = lib.spawn + }) + + describe('when the format is encoded audio', () => { + it('should call combineEncodedAudio()', () => { + return combine(testManifest, tempfile(), 'encoded', 'ffmpeg-test').then(() => { + // We can't spy on combineEncodedAudio() directly, so look at its internals. // TODO is this true? + expect(spawn).toHaveBeenCalled() + expect(spawn.calls.mostRecent().args[0]).toBe('ffmpeg-test') + }) + }) + }) + + describe('when the format is raw', () => { + it('should call combineRawAudio()', () => { + return combine(testManifest, tempfile(), 'raw').then(() => { + // We can't spy on combineRawAudio() directly, so look at its internals. // TODO is this true? + expect(fs.createFileSync).toHaveBeenCalled() + }) + }) + }) + + describe('default format', () => { + it('should call combineEncodedAudio()', () => { + return combine(testManifest, tempfile()).then(() => { + // We can't spy on combineEncodedAudio() directly, so look at its internals. // TODO is this true? + expect(spawn).toHaveBeenCalled() + expect(spawn.calls.mostRecent().args[0]).toBe('ffmpeg') + }) + }) + }) + + describe('when it succeeds', () => { + const destFile = tempfile() + let result + + beforeEach(async () => { + result = await combine(testManifest, destFile, 'encoded') + }) + + it('should return the new filename', () => { + expect(result).toBe(destFile) + }) + }) + + describe('when it fails', () => { + let result + + beforeEach(() => { + spawn.on.and.callFake((type, callback) => { + if (type === 'error') { callback() } + }) + return combine(testManifest, tempfile(), 'encoded').catch(response => { + result = response + }) + }) + + it('should return a rejected promise with the error', () => { + expect(result.message).toMatch('Could not start ffmpeg process') + }) + }) +}) diff --git a/packages/tts-cli/test/create-manifest.spec.js b/packages/tts-lib/test/create-manifest.spec.js similarity index 100% rename from packages/tts-cli/test/create-manifest.spec.js rename to packages/tts-lib/test/create-manifest.spec.js diff --git a/packages/tts-cli/test/generate-all.spec.js b/packages/tts-lib/test/generate-all.spec.js similarity index 81% rename from packages/tts-cli/test/generate-all.spec.js rename to packages/tts-lib/test/generate-all.spec.js index f13afdb..e787939 100644 --- a/packages/tts-cli/test/generate-all.spec.js +++ b/packages/tts-lib/test/generate-all.spec.js @@ -17,7 +17,7 @@ describe('generateAll()', () => { }) it('should asynchronously call the function for each of the parts', done => { - generateAll(textParts, { limit: testLimit }, iteratorFunction, task).then(() => { + generateAll(textParts, testLimit, iteratorFunction, task).then(() => { const [parts] = async.eachOfLimit.calls.mostRecent().args expect(parts).toEqual(textParts) expect(parts.length).toBe(textParts.length) @@ -26,7 +26,7 @@ describe('generateAll()', () => { }) it('should limit the async calls according to the option', done => { - generateAll(textParts, { limit: testLimit }, iteratorFunction, task).then(() => { + generateAll(textParts, testLimit, iteratorFunction, task).then(() => { const [, limit] = async.eachOfLimit.calls.mostRecent().args expect(limit).toBe(testLimit) }).then(done) @@ -37,7 +37,7 @@ describe('generateAll()', () => { async.eachOfLimit.and.callFake((parts, opts, func, callback) => { callback(new Error('reject async')) }) - generateAll(textParts, {}, iteratorFunction, task).catch(() => { + generateAll(textParts, 1, iteratorFunction, task).catch(() => { done() }) }) @@ -57,13 +57,13 @@ describe('generateAll()', () => { describe('when all requests succeed', () => { it('should respond with the original parts', done => { - generateAll(textParts, { limit: testLimit }, iteratorFunction, task).then(response => { + generateAll(textParts, testLimit, iteratorFunction, task).then(response => { expect(response).toEqual(textParts) }).then(done) }) it('should show the final count', done => { - generateAll(textParts, { limit: testLimit }, iteratorFunction, task).then(() => { + generateAll(textParts, testLimit, iteratorFunction, task).then(() => { expect(task.title).toMatch(`\\(${textParts.length}/`) }).then(done) }) @@ -79,7 +79,7 @@ describe('generateAll()', () => { }) it('should return a rejected promise with the error', done => { - generateAll(textParts, { limit: testLimit }, iteratorFunction, task).catch(err => { + generateAll(textParts, testLimit, iteratorFunction, task).catch(err => { expect(err.message).toBe(testError) }).then(done) }) diff --git a/packages/tts-cli/test/generate-speech.spec.js b/packages/tts-lib/test/generate-speech.spec.js similarity index 98% rename from packages/tts-cli/test/generate-speech.spec.js rename to packages/tts-lib/test/generate-speech.spec.js index 05696a1..4e9fc20 100644 --- a/packages/tts-cli/test/generate-speech.spec.js +++ b/packages/tts-lib/test/generate-speech.spec.js @@ -1,4 +1,5 @@ -describe('generateSpeech()', () => { +// TODO reconcile this +xdescribe('generateSpeech()', () => { let fsStub let generateSpeech let ctx, task diff --git a/packages/tts-lib/test/helpers.js b/packages/tts-lib/test/helpers.js new file mode 100644 index 0000000..426887f --- /dev/null +++ b/packages/tts-lib/test/helpers.js @@ -0,0 +1,69 @@ +// TODO clean up this file +const async = require('async') +const originalFs = require('fs') +const proxyquire = require('proxyquire') + +exports.loadLib = (file) => { + // Spy on the async module. + spyOn(async, 'eachOfLimit').and.callThrough() + + // Stub out the fs(-extra) module with spies. + const fs = jasmine.createSpyObj('fs', [ + 'appendFileSync', + 'createFileSync', + 'createWriteStream', + 'move', + 'readFile', + 'readFileSync', + 'removeSync', + 'truncateSync', + 'writeFile', + 'writeFileSync' + ]) + fs.createWriteStream.and.callFake(filename => { + const stream = originalFs.createWriteStream(filename) + return stream + }) + fs.move.and.callFake((src, dest, opts, callback) => { callback() }) + fs.writeFile.and.callFake((dest, data, opts, callback) => { callback() }) + + // Stub out a provider. + const providerStub = { + create: () => ({ + buildPart: () => ({}), + generate: (item, key, callback) => callback(null, null) + }) + } + + const spawnOnSpy = jasmine.createSpy('spawn.on').and.callFake((type, callback) => { + if (type === 'close') { callback() } + }) + const spawnStderrOn = jasmine.createSpy('spawn.stderr.on') + const spawn = jasmine.createSpy('spawn').and.callFake(() => { + return { + on: spawnOnSpy, + stderr: { + on: spawnStderrOn + } + } + }) + + // Load the library module. + const lib = proxyquire(`../lib/${file}`, { + './providers/aws': providerStub, + './providers/gcp': providerStub, + async, + child_process: { spawn }, // eslint-disable-line camelcase + 'fs-extra': fs + }) + + // Add the spies for inspection. + lib.async = async + lib.fs = fs + lib.provider = providerStub + lib.spawn = spawn + lib.spawn.on = spawnOnSpy + lib.spawn.stderr = { on: spawnStderrOn } + + return lib +} diff --git a/packages/tts-lib/test/jasmine.json b/packages/tts-lib/test/jasmine.json new file mode 100644 index 0000000..7ae68eb --- /dev/null +++ b/packages/tts-lib/test/jasmine.json @@ -0,0 +1,7 @@ +{ + "spec_dir": "test", + "spec_files": [ + "**/*.spec.js" + ], + "random": false +} diff --git a/packages/tts-lib/test/provider.spec.js b/packages/tts-lib/test/provider.spec.js new file mode 100644 index 0000000..521322a --- /dev/null +++ b/packages/tts-lib/test/provider.spec.js @@ -0,0 +1,26 @@ +const { createProvider } = require('../lib/provider') +const { AwsProvider } = require('../lib/providers/aws') +const { GcpProvider } = require('../lib/providers/gcp') + +describe('provider', () => { + beforeEach(() => { + }) + + describe('createProvider()', () => { + it('should create an AWS provider', () => { + const provider = createProvider('aws') + expect(provider).toBeInstanceOf(AwsProvider) + }) + + it('should create a GCP provider', () => { + const provider = createProvider('gcp') + expect(provider).toBeInstanceOf(GcpProvider) + }) + + it('should throw an error for an unknown provider', () => { + expect(() => { + createProvider('foobar', {}) + }).toThrowError(/Unsupported service: foobar/) + }) + }) +}) diff --git a/packages/tts-cli/test/providers/aws.spec.js b/packages/tts-lib/test/providers/aws.spec.js similarity index 54% rename from packages/tts-cli/test/providers/aws.spec.js rename to packages/tts-lib/test/providers/aws.spec.js index a668295..058ab17 100644 --- a/packages/tts-cli/test/providers/aws.spec.js +++ b/packages/tts-lib/test/providers/aws.spec.js @@ -1,26 +1,91 @@ const { Polly } = require('@aws-sdk/client-polly') const fs = require('fs') +const proxyquire = require('proxyquire') const { Readable } = require('stream') const tempfile = require('tempfile') describe('AWS provider', () => { - let create + const chunks = ['hello world'] + const manifestFile = 'manifest.txt' + const parts = ['foo.txt', 'bar.txt'] + const text = 'hello world' + + let combineStub + let createManifestStub let fsStub - let PollyProvider + let generateAllStub + let splitTextStub + let AwsProvider let provider beforeEach(() => { - ({ create, fs: fsStub, PollyProvider } = require('../helpers').loadLib('providers/aws')) - provider = create({ + combineStub = jasmine.createSpy('combine') + createManifestStub = jasmine.createSpy('createManifest').and.returnValue(manifestFile) + fsStub = jasmine.createSpyObj('fs', ['createWriteStream']) + fsStub.createWriteStream.and.callFake(filename => { + const stream = fs.createWriteStream(filename) + return stream + }) + generateAllStub = jasmine.createSpy('generateAll').and.returnValue(Promise.resolve(parts)) + splitTextStub = jasmine.createSpy('splitText').and.returnValue(chunks) + ;({ AwsProvider } = proxyquire('../../lib/providers/aws', { + 'fs-extra': fsStub, + '../combine-parts': { + combine: combineStub + }, + '../generate-speech': { + createManifest: createManifestStub, + generateAll: generateAllStub + }, + '../split-text': { + splitText: splitTextStub + } + })) + provider = new AwsProvider({ region: 'aws-west-1', accessKey: 'ACCESS KEY', - secretKey: 'SECRET KEY' + secretKey: 'SECRET KEY', + format: 'mp3' }) }) - describe('create()', () => { - it('should create a provider instance', () => { - expect(provider).toEqual(jasmine.any(PollyProvider)) + describe('constructor', () => { + it('should allow for no options', () => { + expect(() => { + provider = new AwsProvider() + }).not.toThrow() + }) + + it('should set the name', () => { + expect(provider.name).toBe('AWS') + }) + + it('should set the max character count to 1500', () => { + expect(provider.maxCharacterCount).toBe(1500) + }) + + it('should set an "ogg_vorbis" format to "ogg"', () => { + provider = new AwsProvider({ + region: 'aws-west-1', + accessKey: 'ACCESS KEY', + secretKey: 'SECRET KEY', + format: 'ogg_vorbis' + }) + expect(provider.opts.format).toBe('ogg') + }) + + it('should set the voice', () => { + provider = new AwsProvider({ + region: 'aws-west-1', + accessKey: 'ACCESS KEY', + secretKey: 'SECRET KEY', + voice: 'Oliver' + }) + expect(provider.opts.voice).toBe('Oliver') + }) + + it('should set the voice to "Joanna" if not defined', () => { + expect(provider.opts.voice).toBe('Joanna') }) it('should have an underlying AWS object', () => { @@ -36,11 +101,83 @@ describe('AWS provider', () => { }) }) - describe('buildPart()', () => { - it('should return an object with a `send` property', () => { - expect(provider.buildPart()).toEqual({ + describe('extensionFor()', () => { + it('should return "mp3" for the MP3 format', () => { + expect(provider.extensionFor('mp3')).toBe('mp3') + }) + + it('should return "ogg" for the Ogg format', () => { + expect(provider.extensionFor('ogg')).toBe('ogg') + }) + + it('should return "ogg" for the (deprecated) Ogg Vorbis format', () => { + expect(provider.extensionFor('ogg_vorbis')).toBe('ogg') + }) + + it('should return "pcm" for the PCM format', () => { + expect(provider.extensionFor('pcm')).toBe('pcm') + }) + + it('should throw an error for unknown formats', () => { + expect(() => { + provider.extensionFor('foo') + }).toThrow() + }) + }) + + describe('splitText()', () => { + it('should call the splitText routine with the correct parameters', () => { + const result = provider.splitText(text) + expect(splitTextStub).toHaveBeenCalledWith(text, provider.maxCharacterCount, provider.opts.type) + expect(result).toEqual(chunks) + }) + }) + + describe('combineAudio()', () => { + it('should call combine() for raw audio', async () => { + provider = new AwsProvider({ + region: 'aws-west-1', + accessKey: 'ACCESS KEY', + secretKey: 'SECRET KEY', + format: 'pcm', + ffmpeg: 'ffmpeg-test' + }) + await provider.combineAudio('foobar') + const args = combineStub.calls.mostRecent().args + expect(args[0]).toBe('foobar') + expect(args[1]).toMatch(/\.pcm$/) + expect(args[2]).toBe('raw') + expect(args[3]).toBe('ffmpeg-test') + }) + + it('should call combine() for encoded audio', async () => { + provider = new AwsProvider({ + region: 'aws-west-1', + accessKey: 'ACCESS KEY', + secretKey: 'SECRET KEY', + format: 'mp3', + ffmpeg: 'ffmpeg-test' + }) + await provider.combineAudio('foobar') + const args = combineStub.calls.mostRecent().args + expect(args[0]).toBe('foobar') + expect(args[1]).toMatch(/\.mp3$/) + expect(args[2]).toBe('encoded') + expect(args[3]).toBe('ffmpeg-test') + }) + }) + + describe('buildInfo()', () => { + it('should return an object with the expected properties', () => { + const task = { foo: 1, bar: 2 } + const info = provider.buildInfo('hello world', task) + expect(info).toEqual({ + task, + tempfile: jasmine.any(String), + text: 'hello world', send: jasmine.any(Function) }) + expect(info.tempfile).toMatch(/\.mp3$/) }) }) @@ -238,4 +375,27 @@ describe('AWS provider', () => { }) }) }) + + describe('generateSpeech()', () => { + it('should call generateAll()', async () => { + const task = {} + await provider.generateSpeech(chunks, task) + expect(generateAllStub).toHaveBeenCalledWith([{ + task, + tempfile: jasmine.any(String), + text: chunks[0], + send: jasmine.any(Function) + }], provider.opts.limit, jasmine.any(Function), task) + }) + + it('should call createManifest()', async () => { + await provider.generateSpeech(chunks, {}) + expect(createManifestStub).toHaveBeenCalledWith(parts) + }) + + it('should return the manifest file', async () => { + const result = await provider.generateSpeech(chunks, {}) + expect(result).toBe(manifestFile) + }) + }) }) diff --git a/packages/tts-cli/test/providers/gcp.spec.js b/packages/tts-lib/test/providers/gcp.spec.js similarity index 66% rename from packages/tts-cli/test/providers/gcp.spec.js rename to packages/tts-lib/test/providers/gcp.spec.js index 54daf3e..0229cf2 100644 --- a/packages/tts-cli/test/providers/gcp.spec.js +++ b/packages/tts-lib/test/providers/gcp.spec.js @@ -1,27 +1,80 @@ const TextToSpeechClient = require('@google-cloud/text-to-speech').TextToSpeechClient const fs = require('fs') const path = require('path') +const proxyquire = require('proxyquire') const tempfile = require('tempfile') describe('Google Cloud provider', () => { + const chunks = ['hello world'] + const manifestFile = 'manifest.txt' + const parts = ['foo.txt', 'bar.txt'] + const text = 'hello world' + + let combineStub + let createManifestStub let fsStub - let create - let GoogleProvider + let generateAllStub + let splitTextStub + let GcpProvider let provider beforeEach(() => { - ({ fs: fsStub, create, GoogleProvider } = require('../helpers').loadLib('providers/gcp')) - provider = create({ + combineStub = jasmine.createSpy('combine') + createManifestStub = jasmine.createSpy('createManifest').and.returnValue(manifestFile) + fsStub = jasmine.createSpyObj('fs', ['writeFile']) + fsStub.writeFile.and.callFake((dest, data, opts, callback) => { callback() }) + generateAllStub = jasmine.createSpy('generateAll').and.returnValue(Promise.resolve(parts)) + splitTextStub = jasmine.createSpy('splitText').and.returnValue(chunks) + ;({ GcpProvider } = proxyquire('../../lib/providers/gcp', { + 'fs-extra': fsStub, + '../combine-parts': { + combine: combineStub + }, + '../generate-speech': { + createManifest: createManifestStub, + generateAll: generateAllStub + }, + '../split-text': { + splitText: splitTextStub + } + })) + provider = new GcpProvider({ email: 'foo@example.com', privateKey: 'private key', projectFile: 'project-file.json', - projectId: 'project ID' + projectId: 'project ID', + format: 'mp3' }) }) - describe('create()', () => { - it('should create a provider instance', () => { - expect(provider).toEqual(jasmine.any(GoogleProvider)) + describe('constructor', () => { + it('should allow for no options', () => { + expect(() => { + provider = new GcpProvider() + }).not.toThrow() + }) + + it('should set the name', () => { + expect(provider.name).toBe('GCP') + }) + + it('should set the max character count to 5000', () => { + expect(provider.maxCharacterCount).toBe(5000) + }) + + it('should set the language to "en-US" if not defined', () => { + expect(provider.opts.language).toBe('en-US') + }) + + it('should set the language', () => { + provider = new GcpProvider({ + email: 'foo@example.com', + privateKey: 'private key', + projectFile: 'project-file.json', + projectId: 'project ID', + language: 'en-GB' + }) + expect(provider.opts.language).toBe('en-GB') }) it('should have an underlying Google Cloud object', () => { @@ -43,7 +96,7 @@ describe('Google Cloud provider', () => { }) it('should leave out the project file if not specified', () => { - provider = create({ + provider = new GcpProvider({ email: 'foo@example.com', 'private-key': 'private key' }) @@ -57,7 +110,7 @@ describe('Google Cloud provider', () => { }) it('should use the project file as-is if an absolute path', () => { - provider = create({ + provider = new GcpProvider({ email: 'foo@example.com', privateKey: 'fake key', projectFile: path.resolve('project-file.json') @@ -75,16 +128,72 @@ describe('Google Cloud provider', () => { } fs.writeFileSync(filename, JSON.stringify(fakeProject), 'utf8') expect(() => { - create({ 'project-file': filename }) + new GcpProvider({ 'project-file': filename }) // eslint-disable-line no-new }).not.toThrow() }) }) - describe('buildPart()', () => { - it('should return an object with a `synthesizer` property', () => { - expect(provider.buildPart()).toEqual({ + describe('extensionFor()', () => { + it('should return "mp3" for the MP3 format', () => { + expect(provider.extensionFor('mp3')).toBe('mp3') + }) + + it('should return "ogg" for the Ogg format', () => { + expect(provider.extensionFor('ogg')).toBe('ogg') + }) + + it('should return "ogg" for the (deprecated) Ogg Vorbis format', () => { + expect(provider.extensionFor('ogg_vorbis')).toBe('ogg') + }) + + it('should return "wav" for the PCM format', () => { + expect(provider.extensionFor('pcm')).toBe('wav') + }) + + it('should throw an error for unknown formats', () => { + expect(() => { + provider.extensionFor('foo') + }).toThrow() + }) + }) + + describe('splitText()', () => { + it('should call the splitText routine with the correct parameters', () => { + const result = provider.splitText(text) + expect(splitTextStub).toHaveBeenCalledWith(text, provider.maxCharacterCount, provider.opts.type) + expect(result).toEqual(chunks) + }) + }) + + describe('combineAudio()', () => { + it('should call combine() for the audio', async () => { + provider = new GcpProvider({ + email: 'foo@example.com', + privateKey: 'fake key', + projectFile: path.resolve('project-file.json'), + format: 'mp3', + ffmpeg: 'ffmpeg-test' + }) + await provider.combineAudio('foobar') + const args = combineStub.calls.mostRecent().args + expect(args[0]).toBe('foobar') + expect(args[1]).toMatch(/\.mp3$/) + expect(args[2]).toBe('encoded') + expect(args[3]).toBe('ffmpeg-test') + }) + }) + + describe('buildInfo()', () => { + it('should return an object with the expected properties', () => { + const task = { foo: 1, bar: 2 } + const info = provider.buildInfo('hello world', task) + expect(info).toEqual({ + task, + tempfile: jasmine.any(String), + text: 'hello world', synthesizer: jasmine.any(Function) }) + expect(info.tempfile).toMatch(/\.mp3$/) }) }) @@ -334,4 +443,27 @@ describe('Google Cloud provider', () => { }) }) }) + + describe('generateSpeech()', () => { + it('should call generateAll()', async () => { + const task = {} + await provider.generateSpeech(chunks, task) + expect(generateAllStub).toHaveBeenCalledWith([{ + task, + tempfile: jasmine.any(String), + text: chunks[0], + synthesizer: jasmine.any(Function) + }], provider.opts.limit, jasmine.any(Function), task) + }) + + it('should call createManifest()', async () => { + await provider.generateSpeech(chunks, {}) + expect(createManifestStub).toHaveBeenCalledWith(parts) + }) + + it('should return the manifest file', async () => { + const result = await provider.generateSpeech(chunks, {}) + expect(result).toBe(manifestFile) + }) + }) }) diff --git a/packages/tts-cli/test/split-text.spec.js b/packages/tts-lib/test/split-text.spec.js similarity index 50% rename from packages/tts-cli/test/split-text.spec.js rename to packages/tts-lib/test/split-text.spec.js index 2177223..0d4ca7b 100644 --- a/packages/tts-cli/test/split-text.spec.js +++ b/packages/tts-lib/test/split-text.spec.js @@ -3,36 +3,27 @@ describe('splitText()', () => { const testData = 'hello world' let splitText - let ctx beforeEach(() => { ({ splitText } = require('./helpers').loadLib('split-text')) - ctx = { - args: {}, - text: testData, - maxCharacterCount: maxChars - } }) it('should split the text into an array of parts', done => { - splitText(ctx).then(() => { - expect(ctx.parts).toEqual([testData]) + splitText(testData, maxChars).then((parts) => { + expect(parts).toEqual([testData]) }).then(done) }) it('should split the text by the given number of characters', done => { - ctx.maxCharacterCount = 2 - splitText(ctx).then(() => { - expect(ctx.parts).toEqual(['he', 'll', 'o', 'wo', 'rl', 'd']) + splitText(testData, 2).then((parts) => { + expect(parts).toEqual(['he', 'll', 'o', 'wo', 'rl', 'd']) }).then(done) }) it('should propagate SSML tags through the chunks', done => { - ctx.text = 'Hello there world' - ctx.maxCharacterCount = 6 - ctx.args = { type: 'ssml' } - splitText(ctx).then(() => { - expect(ctx.parts).toEqual([ + const text = 'Hello there world' + splitText(text, 6, 'ssml').then((parts) => { + expect(parts).toEqual([ 'Hello', 'there', 'world' @@ -41,11 +32,9 @@ describe('splitText()', () => { }) it('should work when SSML tags are duplicated in sequence', done => { - ctx.text = '

Section 1

Introduction

' - ctx.maxCharacterCount = 1500 - ctx.args = { type: 'ssml' } - splitText(ctx).then(() => { - expect(ctx.parts).toEqual([ + const text = '

Section 1

Introduction

' + splitText(text, 1500, 'ssml').then((parts) => { + expect(parts).toEqual([ '

Section 1

', '

Introduction

' ]) @@ -53,10 +42,9 @@ describe('splitText()', () => { }) it('should NOT propagate SSML tags for non-SSML text', done => { - ctx.text = 'Hello there world' - ctx.maxCharacterCount = 6 - splitText(ctx).then(() => { - expect(ctx.parts).toEqual([ + const text = 'Hello there world' + splitText(text, 6).then((parts) => { + expect(parts).toEqual([ 'Hello', 'there', @@ -68,24 +56,23 @@ describe('splitText()', () => { }) it('should condense whitespace', done => { - ctx.text = 'hello world' - splitText(ctx).then(() => { - expect(ctx.parts).toEqual(['hello world']) + const text = 'hello world' + splitText(text, maxChars).then((parts) => { + expect(parts).toEqual(['hello world']) }).then(done) }) it('should trim whitespace from the ends', done => { - ctx.text = ' hello world ' - splitText(ctx).then(() => { - expect(ctx.parts).toEqual(['hello world']) + const text = ' hello world ' + splitText(text, maxChars).then((parts) => { + expect(parts).toEqual(['hello world']) }).then(done) }) - describe('when no args are specified', () => { + describe('when type is undefined', () => { it('should still work', () => { - ctx.args = undefined - return splitText(ctx).then(() => { - expect(ctx.parts).toEqual([testData]) + return splitText(testData, maxChars, undefined).then((parts) => { + expect(parts).toEqual([testData]) }) }) }) diff --git a/packages/tts-cli/test/text-chunk.spec.js b/packages/tts-lib/test/text-chunk.spec.js similarity index 100% rename from packages/tts-cli/test/text-chunk.spec.js rename to packages/tts-lib/test/text-chunk.spec.js From 9610c10c448192fcba178516f7abaf76d0d1d096 Mon Sep 17 00:00:00 2001 From: Eric Heikes Date: Thu, 8 May 2025 21:45:26 -0700 Subject: [PATCH 02/48] build(deps): Move dev deps into packages --- package-lock.json | 35 +++++++++++------------------------ package.json | 5 ----- packages/tts-cli/package.json | 6 +++++- packages/tts-lib/package.json | 6 +++++- 4 files changed, 21 insertions(+), 31 deletions(-) diff --git a/package-lock.json b/package-lock.json index f719116..2a12579 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,13 +13,7 @@ "tts-cli": "file:packages/tts-cli", "tts-lib": "file:packages/tts-lib" }, - "devDependencies": { - "cross-env": "^5.2.0", - "jasmine": "^3.5.0", - "nyc": "^15.0.1", - "proxyquire": "^1.8.0", - "standard": "^17.1.0" - } + "devDependencies": {} }, "node_modules/@aashutoshrathi/word-wrap": { "version": "1.2.6", @@ -6561,21 +6555,6 @@ "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", "license": "MIT" }, - "node_modules/proxyquire": { - "version": "1.8.0", - "dev": true, - "license": "MIT", - "dependencies": { - "fill-keys": "^1.0.2", - "module-not-found-error": "^1.0.0", - "resolve": "~1.1.7" - } - }, - "node_modules/proxyquire/node_modules/resolve": { - "version": "1.1.7", - "dev": true, - "license": "MIT" - }, "node_modules/pump": { "version": "3.0.0", "license": "MIT", @@ -8110,7 +8089,11 @@ }, "devDependencies": { "codecov": "^3.6.5", - "proxyquire": "^2.1.3" + "cross-env": "^5.2.0", + "jasmine": "^3.5.0", + "nyc": "^15.0.1", + "proxyquire": "^2.1.3", + "standard": "^17.1.0" } }, "packages/tts-cli/node_modules/debug": { @@ -8148,7 +8131,11 @@ }, "devDependencies": { "codecov": "^3.6.5", - "proxyquire": "^2.1.3" + "cross-env": "^5.2.0", + "jasmine": "^3.5.0", + "nyc": "^15.0.1", + "proxyquire": "^2.1.3", + "standard": "^17.1.0" } }, "packages/tts-lib/node_modules/debug": { diff --git a/package.json b/package.json index 7cd2801..37cbfd6 100644 --- a/package.json +++ b/package.json @@ -29,11 +29,6 @@ "test": "npm run test --workspaces --if-present" }, "devDependencies": { - "cross-env": "^5.2.0", - "jasmine": "^3.5.0", - "nyc": "^15.0.1", - "proxyquire": "^1.8.0", - "standard": "^17.1.0" }, "dependencies": { "tts-cli": "file:packages/tts-cli", diff --git a/packages/tts-cli/package.json b/packages/tts-cli/package.json index fb50773..37af963 100644 --- a/packages/tts-cli/package.json +++ b/packages/tts-cli/package.json @@ -42,7 +42,11 @@ }, "devDependencies": { "codecov": "^3.6.5", - "proxyquire": "^2.1.3" + "cross-env": "^5.2.0", + "jasmine": "^3.5.0", + "nyc": "^15.0.1", + "proxyquire": "^2.1.3", + "standard": "^17.1.0" }, "standard": { "env": { diff --git a/packages/tts-lib/package.json b/packages/tts-lib/package.json index 15ac696..037f37c 100644 --- a/packages/tts-lib/package.json +++ b/packages/tts-lib/package.json @@ -40,7 +40,11 @@ }, "devDependencies": { "codecov": "^3.6.5", - "proxyquire": "^2.1.3" + "cross-env": "^5.2.0", + "jasmine": "^3.5.0", + "nyc": "^15.0.1", + "proxyquire": "^2.1.3", + "standard": "^17.1.0" }, "standard": { "env": { From 4953356876200a2e319a0ea2f08ba4c96b1d1c70 Mon Sep 17 00:00:00 2001 From: Eric Heikes Date: Mon, 12 May 2025 23:31:58 -0700 Subject: [PATCH 03/48] feat: Convert providers to classes --- packages/tts-lib/lib/create-provider.js | 14 ++ packages/tts-lib/lib/provider.js | 71 ++++++- packages/tts-lib/lib/providers/aws.js | 167 ++++++++-------- packages/tts-lib/lib/providers/gcp.js | 187 ++++++++---------- packages/tts-lib/test/create-provider.spec.js | 21 ++ packages/tts-lib/test/provider.spec.js | 145 ++++++++++++-- packages/tts-lib/test/providers/aws.spec.js | 42 ---- packages/tts-lib/test/providers/gcp.spec.js | 62 +----- 8 files changed, 387 insertions(+), 322 deletions(-) create mode 100644 packages/tts-lib/lib/create-provider.js create mode 100644 packages/tts-lib/test/create-provider.spec.js diff --git a/packages/tts-lib/lib/create-provider.js b/packages/tts-lib/lib/create-provider.js new file mode 100644 index 0000000..c8e8339 --- /dev/null +++ b/packages/tts-lib/lib/create-provider.js @@ -0,0 +1,14 @@ +const { AwsProvider } = require('./providers/aws') +const { GcpProvider } = require('./providers/gcp') + +const providers = { + aws: AwsProvider, + gcp: GcpProvider +} + +exports.createProvider = (service, opts) => { + if (!providers[service]) { + throw new Error(`Unsupported service: ${service}`) + } + return new providers[service](opts) +} diff --git a/packages/tts-lib/lib/provider.js b/packages/tts-lib/lib/provider.js index c8e8339..ed2bafa 100644 --- a/packages/tts-lib/lib/provider.js +++ b/packages/tts-lib/lib/provider.js @@ -1,14 +1,65 @@ -const { AwsProvider } = require('./providers/aws') -const { GcpProvider } = require('./providers/gcp') +const tempfile = require('tempfile') +const { combine } = require('./combine-parts') +const { createManifest, generateAll } = require('./generate-speech') +const { splitText } = require('./split-text') -const providers = { - aws: AwsProvider, - gcp: GcpProvider -} +/** + * Abstract base class for providers. + * @abstract + */ +class Provider { + constructor (opts = {}) { + this.name = '[Base Provider]' + this.maxCharacterCount = 1500 + this.opts = opts + if (this.constructor === Provider) { + throw new Error('Can\'t instantiate abstract class') + } + } + + /** + * This should be overridden by subclasses to return the data object. + */ + buildInfo (text, task) { + return { + task, + tempfile: tempfile(`.${this.extensionFor(this.opts.format)}`), + text + } + } -exports.createProvider = (service, opts) => { - if (!providers[service]) { - throw new Error(`Unsupported service: ${service}`) + combineAudio (manifestFile) { + const newFile = tempfile(`.${this.extensionFor(this.opts.format)}`) + return combine(manifestFile, newFile, 'encoded', this.opts.ffmpeg) + } + + /** + * This should be overridden by subclasses to return the file extension for the given format. + */ + extensionFor (_format) { + return 'mp3' + } + + /** + * This should be overridden by subclasses. + */ + generate (_info, _i, _callback) { + throw new Error('generate() not implemented') + } + + /** + * Returns a Promise with the temporary audio file. + */ + async generateSpeech (strParts, task) { + // Compile the text parts and options together in a packet. + const parts = strParts.map(part => this.buildInfo(part, task)) + const updatedParts = await generateAll(parts, this.opts.limit, this.generate.bind(this), task) + return createManifest(updatedParts) + } + + splitText (text) { + return splitText(text, this.maxCharacterCount, this.opts.type) } - return new providers[service](opts) } + +exports.Provider = Provider diff --git a/packages/tts-lib/lib/providers/aws.js b/packages/tts-lib/lib/providers/aws.js index 4eb5d50..0378915 100644 --- a/packages/tts-lib/lib/providers/aws.js +++ b/packages/tts-lib/lib/providers/aws.js @@ -3,109 +3,96 @@ const debug = require('debug') const fs = require('fs-extra') const tempfile = require('tempfile') const { combine } = require('../combine-parts') -const { createManifest, generateAll } = require('../generate-speech') -const { splitText } = require('../split-text') +const { Provider } = require('../provider') -const AwsProvider = function (opts = {}) { - this.name = 'AWS' - this.maxCharacterCount = 1500 +class AwsProvider extends Provider { + constructor (opts = {}) { + super(opts) + this.name = 'AWS' + this.maxCharacterCount = 1500 - this.opts = opts - if (this.opts.format === 'ogg_vorbis') { - debug('aws')('Warning: Format "ogg_vorbis" is deprecated; use "ogg" instead') - this.opts.format = 'ogg' - } - if (typeof this.opts.voice === 'undefined') { - debug('aws')('Setting default voice to "Joanna"') - this.opts.voice = 'Joanna' - } - - debug('create')(`Creating AWS Polly instance in ${this.opts.region}`) - this.instance = new Polly({ - credentials: { - accessKeyId: this.opts.accessKey, - secretAccessKey: this.opts.secretKey - }, - region: this.opts.region - }) -} + if (this.opts.format === 'ogg_vorbis') { + debug('aws')('Warning: Format "ogg_vorbis" is deprecated; use "ogg" instead') + this.opts.format = 'ogg' + } + if (typeof this.opts.voice === 'undefined') { + debug('aws')('Setting default voice to "Joanna"') + this.opts.voice = 'Joanna' + } -AwsProvider.prototype.extensionFor = (format) => { - if (format === 'mp3') { - return 'mp3' - } else if (format === 'ogg' || format === 'ogg_vorbis') { - return 'ogg' - } else if (format === 'pcm') { - return 'pcm' + debug('create')(`Creating AWS Polly instance in ${this.opts.region}`) + this.instance = new Polly({ + credentials: { + accessKeyId: this.opts.accessKey, + secretAccessKey: this.opts.secretKey + }, + region: this.opts.region + }) } - throw new Error(`No known file extension for "${format}" format`) -} - -AwsProvider.prototype.splitText = function (text) { // TODO put on parent - return splitText(text, this.maxCharacterCount, this.opts.type) -} -AwsProvider.prototype.combineAudio = function (manifestFile) { // TODO put on parent - const newFile = tempfile(`.${this.extensionFor(this.opts.format)}`) - return combine(manifestFile, newFile, this.opts.format === 'pcm' ? 'raw' : 'encoded', this.opts.ffmpeg) -} + /** + * Creates an object containing all the data. + */ + buildInfo (text, task) { + return { + task, + tempfile: tempfile(`.${this.extensionFor(this.opts.format)}`), + text, + send: this.instance.send.bind(this.instance) + } + } -/** - * Creates an object containing all the data. - */ -AwsProvider.prototype.buildInfo = function (text, task) { - return { - task, - tempfile: tempfile(`.${this.extensionFor(this.opts.format)}`), - text, - send: this.instance.send.bind(this.instance) + combineAudio (manifestFile) { + const newFile = tempfile(`.${this.extensionFor(this.opts.format)}`) + return combine(manifestFile, newFile, this.opts.format === 'pcm' ? 'raw' : 'encoded', this.opts.ffmpeg) } -} -/** - * Calls the Polly API with the given info. - */ -AwsProvider.prototype.generate = function (info, i, callback) { - info.task.title = info.task.title.replace(/\d+\//, `${i}/`) + extensionFor (format) { + if (format === 'mp3') { + return 'mp3' + } else if (format === 'ogg' || format === 'ogg_vorbis') { + return 'ogg' + } else if (format === 'pcm') { + return 'pcm' + } + throw new Error(`No known file extension for "${format}" format`) + } - const command = new SynthesizeSpeechCommand({ - Engine: info.opts.engine, - LanguageCode: info.opts.language, - LexiconNames: info.opts.lexicon, - OutputFormat: info.opts.format === 'ogg' ? 'ogg_vorbis' : info.opts.format, - SampleRate: info.opts.sampleRate ? String(info.opts.sampleRate) : undefined, - Text: info.text, - TextType: info.opts.type, - VoiceId: info.opts.voice - }) + /** + * Calls the Polly API with the given info. + */ + generate (info, i, callback) { + info.task.title = info.task.title.replace(/\d+\//, `${i}/`) - debug('generate')('Making request to Amazon Web Services') - info.send(command).then(response => { - debug('generate')(`Writing audio content to ${info.tempfile}`) - const fileStream = fs.createWriteStream(info.tempfile) - response.AudioStream.pipe(fileStream) - fileStream.on('finish', () => { - fileStream.close() - callback() + const command = new SynthesizeSpeechCommand({ + Engine: info.opts.engine, + LanguageCode: info.opts.language, + LexiconNames: info.opts.lexicon, + OutputFormat: info.opts.format === 'ogg' ? 'ogg_vorbis' : info.opts.format, + SampleRate: info.opts.sampleRate ? String(info.opts.sampleRate) : undefined, + Text: info.text, + TextType: info.opts.type, + VoiceId: info.opts.voice }) - fileStream.on('error', err => { - debug('generate')(`Error writing: ${err.message}`) + + debug('generate')('Making request to Amazon Web Services') + info.send(command).then(response => { + debug('generate')(`Writing audio content to ${info.tempfile}`) + const fileStream = fs.createWriteStream(info.tempfile) + response.AudioStream.pipe(fileStream) + fileStream.on('finish', () => { + fileStream.close() + callback() + }) + fileStream.on('error', err => { + debug('generate')(`Error writing: ${err.message}`) + return callback(err) + }) + }, err => { + debug('generate')(`Error during request: ${err.message}`) return callback(err) }) - }, err => { - debug('generate')(`Error during request: ${err.message}`) - return callback(err) - }) -} - -/** - * Returns a Promise with the temporary audio file. - */ -AwsProvider.prototype.generateSpeech = async function (strParts, task) { // TODO put on parent - // Compile the text parts and options together in a packet. - const parts = strParts.map(part => this.buildInfo(part, task)) - const updatedParts = await generateAll(parts, this.opts.limit, this.generate.bind(this), task) - return createManifest(updatedParts) + } } exports.AwsProvider = AwsProvider diff --git a/packages/tts-lib/lib/providers/gcp.js b/packages/tts-lib/lib/providers/gcp.js index 7a5b9d5..aa25543 100644 --- a/packages/tts-lib/lib/providers/gcp.js +++ b/packages/tts-lib/lib/providers/gcp.js @@ -3,126 +3,107 @@ const fs = require('fs-extra') const path = require('path') const tempfile = require('tempfile') const GoogleClient = require('@google-cloud/text-to-speech').TextToSpeechClient -const { combine } = require('../combine-parts') -const { createManifest, generateAll } = require('../generate-speech') -const { splitText } = require('../split-text') +const { Provider } = require('../provider') -const GcpProvider = function (opts = {}) { - this.name = 'GCP' - this.maxCharacterCount = 5000 +class GcpProvider extends Provider { + constructor (opts = {}) { + super(opts) + this.name = 'GCP' + this.maxCharacterCount = 5000 - this.opts = opts - if (typeof this.opts.language === 'undefined') { - debug('gcp')('Setting default language to "en-US"') - this.opts.language = 'en-US' - } + if (typeof this.opts.language === 'undefined') { + debug('gcp')('Setting default language to "en-US"') + this.opts.language = 'en-US' + } - debug('create')('Creating Google Cloud TTS instance') - try { - this.instance = new GoogleClient({ - credentials: this.opts.email || this.opts.privateKey - ? { - client_email: this.opts.email, - private_key: this.opts.privateKey - } - : undefined, - keyFilename: this.opts.projectFile ? path.resolve(this.opts.projectFile) : undefined, - projectId: this.opts.projectId - }) - } catch (err) { - /* istanbul ignore next */ - this.instance = null + debug('create')('Creating Google Cloud TTS instance') + try { + this.instance = new GoogleClient({ + credentials: this.opts.email || this.opts.privateKey + ? { + client_email: this.opts.email, + private_key: this.opts.privateKey + } + : undefined, + keyFilename: this.opts.projectFile ? path.resolve(this.opts.projectFile) : undefined, + projectId: this.opts.projectId + }) + } catch (err) { + /* istanbul ignore next */ + this.instance = null + } } -} -GcpProvider.prototype.extensionFor = (format) => { - if (format === 'mp3') { - return 'mp3' - } else if (format === 'ogg' || format === 'ogg_vorbis') { - return 'ogg' - } else if (format === 'pcm') { - return 'wav' + /** + * Creates an object containing all the data. + */ + buildInfo = function (text, task) { + return { + task, + tempfile: tempfile(`.${this.extensionFor(this.opts.format)}`), + text, + synthesizer: this.instance.synthesizeSpeech.bind(this.instance) + } } - throw new Error(`No known file extension for "${format}" format`) -} - -GcpProvider.prototype.splitText = function (text) { // TODO put on parent - return splitText(text, this.maxCharacterCount, this.opts.type) -} - -GcpProvider.prototype.combineAudio = function (manifestFile) { // TODO put on parent - const newFile = tempfile(`.${this.extensionFor(this.opts.format)}`) - return combine(manifestFile, newFile, 'encoded', this.opts.ffmpeg) -} -/** - * Creates an object containing all the data. - */ -GcpProvider.prototype.buildInfo = function (text, task) { - return { - task, - tempfile: tempfile(`.${this.extensionFor(this.opts.format)}`), - text, - synthesizer: this.instance.synthesizeSpeech.bind(this.instance) + extensionFor (format) { + if (format === 'mp3') { + return 'mp3' + } else if (format === 'ogg' || format === 'ogg_vorbis') { + return 'ogg' + } else if (format === 'pcm') { + return 'wav' + } + throw new Error(`No known file extension for "${format}" format`) } -} -/** - * Calls the Google Cloud API with the given info. - */ -GcpProvider.prototype.generate = function (info, i, callback) { - info.task.title = info.task.title.replace(/\d+\//, `${i}/`) + /** + * Calls the Google Cloud API with the given info. + */ + generate = function (info, i, callback) { + info.task.title = info.task.title.replace(/\d+\//, `${i}/`) - const request = { - input: info.opts.type === 'ssml' - ? { ssml: info.text } - : { text: info.text }, - voice: { - ssmlGender: info.opts.gender ? String(info.opts.gender).toUpperCase() : undefined, - languageCode: info.opts.language, - name: info.opts.voice - }, - audioConfig: { - audioEncoding: info.opts.format === 'pcm' - ? 'LINEAR16' - : info.opts.format === 'ogg' ? 'OGG_OPUS' : 'MP3', - effectsProfileId: info.opts.effect, - pitch: info.opts.pitch, - sampleRateHertz: info.opts.sampleRate, - speakingRate: info.opts.speed, - volumeGainDb: info.opts.gain + const request = { + input: info.opts.type === 'ssml' + ? { ssml: info.text } + : { text: info.text }, + voice: { + ssmlGender: info.opts.gender ? String(info.opts.gender).toUpperCase() : undefined, + languageCode: info.opts.language, + name: info.opts.voice + }, + audioConfig: { + audioEncoding: info.opts.format === 'pcm' + ? 'LINEAR16' + : info.opts.format === 'ogg' ? 'OGG_OPUS' : 'MP3', + effectsProfileId: info.opts.effect, + pitch: info.opts.pitch, + sampleRateHertz: info.opts.sampleRate, + speakingRate: info.opts.speed, + volumeGainDb: info.opts.gain + } } - } - const opts = { - retry: null - } - - debug('generate')('Making request to Google Cloud Platform') - info.synthesizer(request, opts, (err, response) => { - if (err) { - debug('generate')(`Error during request: ${err.message}`) - return callback(err) + const opts = { + retry: null } - debug('generate')(`Writing audio content to ${info.tempfile}`) - fs.writeFile(info.tempfile, response.audioContent, 'binary', err => { + debug('generate')('Making request to Google Cloud Platform') + info.synthesizer(request, opts, (err, response) => { if (err) { - debug('generate')(`Error writing: ${err.message}`) + debug('generate')(`Error during request: ${err.message}`) return callback(err) } - callback() - }) - }) -} -/** - * Returns a Promise with the temporary audio file. - */ -GcpProvider.prototype.generateSpeech = async function (strParts, task) { - // Compile the text parts and options together in a packet. - const parts = strParts.map(part => this.buildInfo(part, task)) - const updatedParts = await generateAll(parts, this.opts.limit, this.generate.bind(this), task) - return createManifest(updatedParts) + debug('generate')(`Writing audio content to ${info.tempfile}`) + fs.writeFile(info.tempfile, response.audioContent, 'binary', err => { + if (err) { + debug('generate')(`Error writing: ${err.message}`) + return callback(err) + } + callback() + }) + }) + } } exports.GcpProvider = GcpProvider diff --git a/packages/tts-lib/test/create-provider.spec.js b/packages/tts-lib/test/create-provider.spec.js new file mode 100644 index 0000000..9c9e02f --- /dev/null +++ b/packages/tts-lib/test/create-provider.spec.js @@ -0,0 +1,21 @@ +const { createProvider } = require('../lib/create-provider') +const { AwsProvider } = require('../lib/providers/aws') +const { GcpProvider } = require('../lib/providers/gcp') + +describe('createProvider()', () => { + it('should create an AWS provider', () => { + const provider = createProvider('aws') + expect(provider).toBeInstanceOf(AwsProvider) + }) + + it('should create a GCP provider', () => { + const provider = createProvider('gcp') + expect(provider).toBeInstanceOf(GcpProvider) + }) + + it('should throw an error for an unknown provider', () => { + expect(() => { + createProvider('foobar', {}) + }).toThrowError(/Unsupported service: foobar/) + }) +}) diff --git a/packages/tts-lib/test/provider.spec.js b/packages/tts-lib/test/provider.spec.js index 521322a..faf8efe 100644 --- a/packages/tts-lib/test/provider.spec.js +++ b/packages/tts-lib/test/provider.spec.js @@ -1,26 +1,139 @@ -const { createProvider } = require('../lib/provider') -const { AwsProvider } = require('../lib/providers/aws') -const { GcpProvider } = require('../lib/providers/gcp') +const proxyquire = require('proxyquire') describe('provider', () => { - beforeEach(() => { - }) + describe('base class', () => { + const chunks = ['hello', 'world'] + const manifestFile = 'manifest.txt' + const parts = ['foo.txt', 'bar.txt'] + const text = 'hello world' + + let Provider + let childProvider + let combineStub + let createManifestStub + let generateAllStub + let splitTextStub + + beforeEach(() => { + combineStub = jasmine.createSpy('combine').and.returnValue(manifestFile) + createManifestStub = jasmine.createSpy('createManifest').and.returnValue(manifestFile) + generateAllStub = jasmine.createSpy('generateAll').and.returnValue(Promise.resolve(parts)) + splitTextStub = jasmine.createSpy('generateAll').and.returnValue(chunks) + ;({ Provider } = proxyquire('../lib/provider', { + './combine-parts': { + combine: combineStub + }, + './generate-speech': { + createManifest: createManifestStub, + generateAll: generateAllStub + }, + './split-text': { + splitText: splitTextStub + } + })) + class ChildProvider extends Provider { + constructor (opts = {}) { + super(opts) + } + } + childProvider = new ChildProvider({ + ffmpeg: 'ffmpeg-test', + limit: 10 + }) + }) + + describe('constructor()', () => { + it('should throw an error if instantiated directly', () => { + expect(() => { + new Provider() // eslint-disable-line no-new + }).toThrowError(/Can't instantiate abstract class/) + }) - describe('createProvider()', () => { - it('should create an AWS provider', () => { - const provider = createProvider('aws') - expect(provider).toBeInstanceOf(AwsProvider) + it('should set a default name and maxCharacterCount', () => { + expect(childProvider.name).toBe('[Base Provider]') + expect(childProvider.maxCharacterCount).toBe(1500) + }) }) - it('should create a GCP provider', () => { - const provider = createProvider('gcp') - expect(provider).toBeInstanceOf(GcpProvider) + describe('buildInfo()', () => { + it('should return an object with the text and a tempfile', () => { + const task = {} + const result = childProvider.buildInfo(text, task) + expect(result).toEqual({ + task, + tempfile: jasmine.any(String), + text + }) + }) }) - it('should throw an error for an unknown provider', () => { - expect(() => { - createProvider('foobar', {}) - }).toThrowError(/Unsupported service: foobar/) + describe('combineAudio()', () => { + it('should call combine() for the audio', async () => { + await childProvider.combineAudio('foobar') + const args = combineStub.calls.mostRecent().args + expect(args[0]).toBe('foobar') + expect(args[1]).toMatch(/\.mp3$/) + expect(args[2]).toBe('encoded') + expect(args[3]).toBe('ffmpeg-test') + }) + + it('should return the combine() result', async () => { + const result = await childProvider.combineAudio('foobar') + expect(result).toBe(manifestFile) + }) + }) + + describe('extensionFor()', () => { + it('should default to mp3', () => { + const result = childProvider.extensionFor('aaa') + expect(result).toBe('mp3') + }) + }) + + describe('generate()', () => { + it('should throw an error', () => { + expect(() => { + childProvider.generate() + }).toThrowError(/generate\(\) not implemented/) + }) + }) + + describe('generateSpeech()', () => { + it('should call generateAll()', async () => { + const task = {} + await childProvider.generateSpeech(chunks, task) + expect(generateAllStub).toHaveBeenCalledWith([{ + task, + tempfile: jasmine.any(String), + text: chunks[0] + }, { + task, + tempfile: jasmine.any(String), + text: chunks[1] + }], childProvider.opts.limit, jasmine.any(Function), task) + }) + + it('should call createManifest()', async () => { + await childProvider.generateSpeech(chunks, {}) + expect(createManifestStub).toHaveBeenCalledWith(parts) + }) + + it('should return the manifest file', async () => { + const result = await childProvider.generateSpeech(chunks, {}) + expect(result).toBe(manifestFile) + }) + }) + + describe('splitText()', () => { + it('should call the splitText routine with the correct parameters', () => { + childProvider.splitText(text) + expect(splitTextStub).toHaveBeenCalledWith(text, childProvider.maxCharacterCount, childProvider.opts.type) + }) + + it('should return the splitText() result', () => { + const result = childProvider.splitText(text) + expect(result).toEqual(chunks) + }) }) }) }) diff --git a/packages/tts-lib/test/providers/aws.spec.js b/packages/tts-lib/test/providers/aws.spec.js index 058ab17..aec1b85 100644 --- a/packages/tts-lib/test/providers/aws.spec.js +++ b/packages/tts-lib/test/providers/aws.spec.js @@ -6,37 +6,26 @@ const tempfile = require('tempfile') describe('AWS provider', () => { const chunks = ['hello world'] - const manifestFile = 'manifest.txt' - const parts = ['foo.txt', 'bar.txt'] - const text = 'hello world' let combineStub - let createManifestStub let fsStub - let generateAllStub let splitTextStub let AwsProvider let provider beforeEach(() => { combineStub = jasmine.createSpy('combine') - createManifestStub = jasmine.createSpy('createManifest').and.returnValue(manifestFile) fsStub = jasmine.createSpyObj('fs', ['createWriteStream']) fsStub.createWriteStream.and.callFake(filename => { const stream = fs.createWriteStream(filename) return stream }) - generateAllStub = jasmine.createSpy('generateAll').and.returnValue(Promise.resolve(parts)) splitTextStub = jasmine.createSpy('splitText').and.returnValue(chunks) ;({ AwsProvider } = proxyquire('../../lib/providers/aws', { 'fs-extra': fsStub, '../combine-parts': { combine: combineStub }, - '../generate-speech': { - createManifest: createManifestStub, - generateAll: generateAllStub - }, '../split-text': { splitText: splitTextStub } @@ -125,14 +114,6 @@ describe('AWS provider', () => { }) }) - describe('splitText()', () => { - it('should call the splitText routine with the correct parameters', () => { - const result = provider.splitText(text) - expect(splitTextStub).toHaveBeenCalledWith(text, provider.maxCharacterCount, provider.opts.type) - expect(result).toEqual(chunks) - }) - }) - describe('combineAudio()', () => { it('should call combine() for raw audio', async () => { provider = new AwsProvider({ @@ -375,27 +356,4 @@ describe('AWS provider', () => { }) }) }) - - describe('generateSpeech()', () => { - it('should call generateAll()', async () => { - const task = {} - await provider.generateSpeech(chunks, task) - expect(generateAllStub).toHaveBeenCalledWith([{ - task, - tempfile: jasmine.any(String), - text: chunks[0], - send: jasmine.any(Function) - }], provider.opts.limit, jasmine.any(Function), task) - }) - - it('should call createManifest()', async () => { - await provider.generateSpeech(chunks, {}) - expect(createManifestStub).toHaveBeenCalledWith(parts) - }) - - it('should return the manifest file', async () => { - const result = await provider.generateSpeech(chunks, {}) - expect(result).toBe(manifestFile) - }) - }) }) diff --git a/packages/tts-lib/test/providers/gcp.spec.js b/packages/tts-lib/test/providers/gcp.spec.js index 0229cf2..7a7f986 100644 --- a/packages/tts-lib/test/providers/gcp.spec.js +++ b/packages/tts-lib/test/providers/gcp.spec.js @@ -4,36 +4,25 @@ const path = require('path') const proxyquire = require('proxyquire') const tempfile = require('tempfile') -describe('Google Cloud provider', () => { +describe('GCP provider', () => { const chunks = ['hello world'] - const manifestFile = 'manifest.txt' - const parts = ['foo.txt', 'bar.txt'] - const text = 'hello world' let combineStub - let createManifestStub let fsStub - let generateAllStub let splitTextStub let GcpProvider let provider beforeEach(() => { combineStub = jasmine.createSpy('combine') - createManifestStub = jasmine.createSpy('createManifest').and.returnValue(manifestFile) fsStub = jasmine.createSpyObj('fs', ['writeFile']) fsStub.writeFile.and.callFake((dest, data, opts, callback) => { callback() }) - generateAllStub = jasmine.createSpy('generateAll').and.returnValue(Promise.resolve(parts)) splitTextStub = jasmine.createSpy('splitText').and.returnValue(chunks) ;({ GcpProvider } = proxyquire('../../lib/providers/gcp', { 'fs-extra': fsStub, '../combine-parts': { combine: combineStub }, - '../generate-speech': { - createManifest: createManifestStub, - generateAll: generateAllStub - }, '../split-text': { splitText: splitTextStub } @@ -157,32 +146,6 @@ describe('Google Cloud provider', () => { }) }) - describe('splitText()', () => { - it('should call the splitText routine with the correct parameters', () => { - const result = provider.splitText(text) - expect(splitTextStub).toHaveBeenCalledWith(text, provider.maxCharacterCount, provider.opts.type) - expect(result).toEqual(chunks) - }) - }) - - describe('combineAudio()', () => { - it('should call combine() for the audio', async () => { - provider = new GcpProvider({ - email: 'foo@example.com', - privateKey: 'fake key', - projectFile: path.resolve('project-file.json'), - format: 'mp3', - ffmpeg: 'ffmpeg-test' - }) - await provider.combineAudio('foobar') - const args = combineStub.calls.mostRecent().args - expect(args[0]).toBe('foobar') - expect(args[1]).toMatch(/\.mp3$/) - expect(args[2]).toBe('encoded') - expect(args[3]).toBe('ffmpeg-test') - }) - }) - describe('buildInfo()', () => { it('should return an object with the expected properties', () => { const task = { foo: 1, bar: 2 } @@ -443,27 +406,4 @@ describe('Google Cloud provider', () => { }) }) }) - - describe('generateSpeech()', () => { - it('should call generateAll()', async () => { - const task = {} - await provider.generateSpeech(chunks, task) - expect(generateAllStub).toHaveBeenCalledWith([{ - task, - tempfile: jasmine.any(String), - text: chunks[0], - synthesizer: jasmine.any(Function) - }], provider.opts.limit, jasmine.any(Function), task) - }) - - it('should call createManifest()', async () => { - await provider.generateSpeech(chunks, {}) - expect(createManifestStub).toHaveBeenCalledWith(parts) - }) - - it('should return the manifest file', async () => { - const result = await provider.generateSpeech(chunks, {}) - expect(result).toBe(manifestFile) - }) - }) }) From 75bc8b0281ca90e93ad0697e35b779ab368b2a8a Mon Sep 17 00:00:00 2001 From: Eric Heikes Date: Tue, 13 May 2025 21:27:26 -0700 Subject: [PATCH 04/48] test: Remove helpers file --- packages/tts-cli/test/helpers.js | 67 -------------------- packages/tts-cli/test/move-temp-file.spec.js | 15 +++-- packages/tts-cli/test/read-text.spec.js | 16 +++-- 3 files changed, 22 insertions(+), 76 deletions(-) delete mode 100644 packages/tts-cli/test/helpers.js diff --git a/packages/tts-cli/test/helpers.js b/packages/tts-cli/test/helpers.js deleted file mode 100644 index 2037310..0000000 --- a/packages/tts-cli/test/helpers.js +++ /dev/null @@ -1,67 +0,0 @@ -// TODO clean this up -const async = require('async') -const originalFs = require('fs') -const proxyquire = require('proxyquire') - -exports.loadLib = (file) => { - // Spy on the async module. - spyOn(async, 'eachOfLimit').and.callThrough() - - // Stub out the fs(-extra) module with spies. - const fs = jasmine.createSpyObj('fs', [ - 'appendFileSync', - 'createFileSync', - 'createWriteStream', - 'move', - 'readFile', - 'readFileSync', - 'removeSync', - 'truncateSync', - 'writeFile', - 'writeFileSync' - ]) - fs.createWriteStream.and.callFake(filename => { - const stream = originalFs.createWriteStream(filename) - return stream - }) - fs.move.and.callFake((src, dest, opts, callback) => { callback() }) - fs.writeFile.and.callFake((dest, data, opts, callback) => { callback() }) - - // Stub out a provider. - const providerStub = { - create: () => ({ - buildPart: () => ({}), - generate: (item, key, callback) => callback(null, null) - }) - } - - const spawnOnSpy = jasmine.createSpy('spawn.on').and.callFake((type, callback) => { - if (type === 'close') { callback() } - }) - const spawnStderrOn = jasmine.createSpy('spawn.stderr.on') - const spawn = jasmine.createSpy('spawn').and.callFake(() => { - return { - on: spawnOnSpy, - stderr: { - on: spawnStderrOn - } - } - }) - - // Load the library module. - const lib = proxyquire(`../lib/${file}`, { - async, - child_process: { spawn }, // eslint-disable-line camelcase - 'fs-extra': fs - }) - - // Add the spies for inspection. - lib.async = async - lib.fs = fs - lib.provider = providerStub - lib.spawn = spawn - lib.spawn.on = spawnOnSpy - lib.spawn.stderr = { on: spawnStderrOn } - - return lib -} diff --git a/packages/tts-cli/test/move-temp-file.spec.js b/packages/tts-cli/test/move-temp-file.spec.js index 2fbf741..83a8d6f 100644 --- a/packages/tts-cli/test/move-temp-file.spec.js +++ b/packages/tts-cli/test/move-temp-file.spec.js @@ -1,12 +1,19 @@ +const proxyquire = require('proxyquire') + describe('moveTempFile()', () => { const sourceFile = 'source file' const destFile = 'destination file' - let moveTempFile, fs + let moveTempFile + let fsSpy let ctx, task beforeEach(() => { - ({ fs, moveTempFile } = require('./helpers').loadLib('move-temp-file')) + fsSpy = jasmine.createSpyObj('fs', ['move']) + fsSpy.move.and.callFake((src, dest, opts, callback) => { callback() }) + ;({ moveTempFile } = proxyquire('../lib/move-temp-file', { + 'fs-extra': fsSpy + })) ctx = { tempFile: sourceFile, outputFilename: destFile @@ -16,7 +23,7 @@ describe('moveTempFile()', () => { }) it('should overwrite the destination filename with the specified temp file', () => { - expect(fs.move).toHaveBeenCalledWith( + expect(fsSpy.move).toHaveBeenCalledWith( sourceFile, destFile, { overwrite: true }, @@ -29,7 +36,7 @@ describe('moveTempFile()', () => { }) it('should return the error if the filesystem call fails', () => { - fs.move.and.callFake((src, dest, opts, callback) => callback(new Error('test error'))) + fsSpy.move.and.callFake((src, dest, opts, callback) => callback(new Error('test error'))) return moveTempFile(ctx, task).then(() => { throw new Error('should have thrown!') }).catch(err => { diff --git a/packages/tts-cli/test/read-text.spec.js b/packages/tts-cli/test/read-text.spec.js index efa7747..0bfaf27 100644 --- a/packages/tts-cli/test/read-text.spec.js +++ b/packages/tts-cli/test/read-text.spec.js @@ -1,11 +1,17 @@ +const proxyquire = require('proxyquire') + describe('readText()', () => { const testData = 'hello world' - let readText, fs + let readText + let fsSpy let ctx, stdin beforeEach(() => { - ({ readText, fs } = require('./helpers').loadLib('read-text')) + fsSpy = jasmine.createSpyObj('fs', ['readFile', 'readFileSync']) + ;({ readText } = proxyquire('../lib/read-text', { + 'fs-extra': fsSpy + })) }) beforeEach(() => { @@ -53,14 +59,14 @@ describe('readText()', () => { beforeEach(() => { ctx.input = testFilename - fs.readFile.and.callFake((filename, opts, callback) => { + fsSpy.readFile.and.callFake((filename, opts, callback) => { callback(null, testData) }) }) it('should read data from the file', done => { readText(ctx).then(() => { - expect(fs.readFile).toHaveBeenCalledWith( + expect(fsSpy.readFile).toHaveBeenCalledWith( testFilename, 'utf8', jasmine.any(Function) @@ -79,7 +85,7 @@ describe('readText()', () => { describe('and cannot read the file', () => { it('should reject with the error', done => { const testError = 'error object' - fs.readFile.and.callFake((filename, opts, callback) => { + fsSpy.readFile.and.callFake((filename, opts, callback) => { callback(testError) }) readText(ctx).catch(err => { From 8e9d82f32d7ab9df73b9f2f314c26ea0b4cec029 Mon Sep 17 00:00:00 2001 From: Eric Heikes Date: Tue, 13 May 2025 22:33:57 -0700 Subject: [PATCH 05/48] refactor(read-text): Change to functional style --- packages/tts-cli/lib/read-text.js | 6 +----- packages/tts-cli/test/cli.spec.js | 21 ++++++++++++++------- packages/tts-cli/test/read-text.spec.js | 25 +++++++++---------------- packages/tts-cli/tts.js | 7 ++++--- 4 files changed, 28 insertions(+), 31 deletions(-) diff --git a/packages/tts-cli/lib/read-text.js b/packages/tts-cli/lib/read-text.js index bb4115f..8bf58e1 100644 --- a/packages/tts-cli/lib/read-text.js +++ b/packages/tts-cli/lib/read-text.js @@ -5,9 +5,7 @@ const fs = require('fs-extra') * Read in the text from a file. * If no file is specified, read from stdin. */ -exports.readText = (ctx) => { - const inputFilename = ctx.input - const proc = ctx.process +exports.readText = (inputFilename, proc) => { return new Promise((resolve, reject) => { if (inputFilename) { // Read from a file. @@ -32,7 +30,5 @@ exports.readText = (ctx) => { resolve(data) }) } - }).then(text => { - ctx.text = text }) } diff --git a/packages/tts-cli/test/cli.spec.js b/packages/tts-cli/test/cli.spec.js index d5793e7..23712da 100644 --- a/packages/tts-cli/test/cli.spec.js +++ b/packages/tts-cli/test/cli.spec.js @@ -1,6 +1,7 @@ const proxyquire = require('proxyquire') describe('CLI', () => { + const inputFile = 'input-file' const manifestFile = 'manifest file' const outputFile = 'output-file' const parts = ['part1', 'part2'] @@ -11,12 +12,14 @@ describe('CLI', () => { let mocks let minimist let cleanup + let readText let createProvider beforeEach(() => { - args = { _: [outputFile] } + args = { _: [inputFile, outputFile] } minimist = jasmine.createSpy('minimist').and.callFake(() => args) cleanup = jasmine.createSpy('cleanup') + readText = jasmine.createSpy('readText').and.returnValue('test text') createProvider = jasmine.createSpy('createProvider').and.returnValue({ combineAudio: () => { return Promise.resolve(tempFile) @@ -36,7 +39,8 @@ describe('CLI', () => { '../tts-lib/lib/cleanup': { cleanup }, '../tts-lib/lib/provider': { createProvider - } + }, + './lib/read-text': { readText } } cli = proxyquire('../tts', mocks) }) @@ -52,11 +56,6 @@ describe('CLI', () => { expect(cli.context.args).toBe(args) }) - it('should pass the process object to Listr', () => { - expect(cli.context.process).toEqual(jasmine.any(Object)) - expect(cli.context.process.argv).toEqual(jasmine.any(Array)) - }) - describe('when 2 arguments are given', () => { const inputFile = 'input-file' @@ -197,6 +196,14 @@ describe('CLI', () => { expect(opts.privateKey).toBe('file contents') }) + it('should call readText() in the text-reading task', async () => { + args = { _: [outputFile] } + cli = proxyquire('../tts', mocks) + const context = {} + await cli.tasks[0].task(context) + expect(context.text).toBe('test text') + }) + it('should call splitText() in the text-splitting task', async () => { args = { _: [outputFile] } cli = proxyquire('../tts', mocks) diff --git a/packages/tts-cli/test/read-text.spec.js b/packages/tts-cli/test/read-text.spec.js index 0bfaf27..645ddb7 100644 --- a/packages/tts-cli/test/read-text.spec.js +++ b/packages/tts-cli/test/read-text.spec.js @@ -5,7 +5,7 @@ describe('readText()', () => { let readText let fsSpy - let ctx, stdin + let stdin beforeEach(() => { fsSpy = jasmine.createSpyObj('fs', ['readFile', 'readFileSync']) @@ -23,32 +23,26 @@ describe('readText()', () => { sentData = true return response }) - ctx = { - input: null, - process: { - stdin - } - } }) describe('when it succeeds', () => { it('should set the read text', done => { - readText(ctx).then(() => { - expect(ctx.text).toBe(testData) + readText(null, { stdin }).then((text) => { + expect(text).toBe(testData) }).then(done) }) }) describe('when no filename is specified', () => { it('should read data from stdin', done => { - readText(ctx).then(() => { + readText(null, { stdin }).then(() => { expect(stdin.on).toHaveBeenCalled() expect(stdin.read).toHaveBeenCalled() }).then(done) }) it('should use UTF-8 encoding', done => { - readText(ctx).then(() => { + readText(null, { stdin }).then(() => { expect(stdin.setEncoding).toHaveBeenCalledWith('utf8') }).then(done) }) @@ -58,14 +52,13 @@ describe('readText()', () => { const testFilename = 'test.txt' beforeEach(() => { - ctx.input = testFilename fsSpy.readFile.and.callFake((filename, opts, callback) => { callback(null, testData) }) }) it('should read data from the file', done => { - readText(ctx).then(() => { + readText(testFilename, { stdin }).then(() => { expect(fsSpy.readFile).toHaveBeenCalledWith( testFilename, 'utf8', @@ -76,8 +69,8 @@ describe('readText()', () => { describe('and can read the file', () => { it('should set the file\'s data', done => { - readText(ctx).then(() => { - expect(ctx.text).toBe(testData) + readText(testFilename, { stdin }).then((text) => { + expect(text).toBe(testData) }).then(done) }) }) @@ -88,7 +81,7 @@ describe('readText()', () => { fsSpy.readFile.and.callFake((filename, opts, callback) => { callback(testError) }) - readText(ctx).catch(err => { + readText(testFilename, { stdin }).catch(err => { expect(err).toBe(testError) }).then(done) }) diff --git a/packages/tts-cli/tts.js b/packages/tts-cli/tts.js index 8eda4aa..762424a 100755 --- a/packages/tts-cli/tts.js +++ b/packages/tts-cli/tts.js @@ -72,7 +72,9 @@ const provider = createProvider(service, opts) // Define the tasks and options. const tasks = [{ title: 'Reading text', - task: readText // TODO make more functional, no ctx + task: async (ctx) => { + ctx.text = await readText(input, process) + } }, { title: 'Splitting text', task: async (ctx) => { @@ -99,9 +101,8 @@ const tasks = [{ }] const context = { args, - input, + input, // only used for testing outputFilename, - process, service } From 40b48ee043912bfd94c84300299c056e75cb7a19 Mon Sep 17 00:00:00 2001 From: Eric Heikes Date: Tue, 13 May 2025 23:18:16 -0700 Subject: [PATCH 06/48] feat(index): Expose API functions --- packages/tts-lib/index.js | 22 ++++++++++++++++++++-- packages/tts-lib/test/index.spec.js | 15 +++++++++++++++ 2 files changed, 35 insertions(+), 2 deletions(-) create mode 100644 packages/tts-lib/test/index.spec.js diff --git a/packages/tts-lib/index.js b/packages/tts-lib/index.js index 9848637..fc3d704 100644 --- a/packages/tts-lib/index.js +++ b/packages/tts-lib/index.js @@ -1,2 +1,20 @@ -// Nothing yet, sorry! -// TODO +const { AwsProvider } = require('./lib/providers/aws') +const { GcpProvider } = require('./lib/providers/gcp') +const { cleanup } = require('./lib/cleanup') +const { combine } = require('./lib/combine-parts') +const { createProvider } = require('./lib/create-provider') +const { createManifest, generateAll } = require('./lib/generate-speech') +const { Provider } = require('./lib/provider') +const { splitText } = require('./lib/split-text') + +module.exports = { + AwsProvider, + cleanup, + combine, + createManifest, + createProvider, + GcpProvider, + generateAll, + Provider, + splitText +} diff --git a/packages/tts-lib/test/index.spec.js b/packages/tts-lib/test/index.spec.js new file mode 100644 index 0000000..cbc1c1d --- /dev/null +++ b/packages/tts-lib/test/index.spec.js @@ -0,0 +1,15 @@ +const api = require('../index') + +describe('index', () => { + it('should expose the API', () => { + expect(api.AwsProvider).toBeDefined() + expect(api.cleanup).toBeDefined() + expect(api.combine).toBeDefined() + expect(api.createManifest).toBeDefined() + expect(api.createProvider).toBeDefined() + expect(api.GcpProvider).toBeDefined() + expect(api.generateAll).toBeDefined() + expect(api.Provider).toBeDefined() + expect(api.splitText).toBeDefined() + }) +}) From 5cc9dc33ea74ef20d65d9e5a9946ccf947b05e35 Mon Sep 17 00:00:00 2001 From: Eric Heikes Date: Wed, 14 May 2025 18:17:32 -0700 Subject: [PATCH 07/48] fix: Prevent regexp DoS --- packages/tts-lib/lib/providers/aws.js | 5 ++++- packages/tts-lib/lib/providers/gcp.js | 5 ++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/tts-lib/lib/providers/aws.js b/packages/tts-lib/lib/providers/aws.js index 0378915..73a2583 100644 --- a/packages/tts-lib/lib/providers/aws.js +++ b/packages/tts-lib/lib/providers/aws.js @@ -62,7 +62,10 @@ class AwsProvider extends Provider { * Calls the Polly API with the given info. */ generate (info, i, callback) { - info.task.title = info.task.title.replace(/\d+\//, `${i}/`) + /* istanbul ignore else: not a real-life scenario */ + if (info.task.title.length < 1000) { // prevent regexp DoS + info.task.title = info.task.title.replace(/\d+\//, `${i}/`) + } const command = new SynthesizeSpeechCommand({ Engine: info.opts.engine, diff --git a/packages/tts-lib/lib/providers/gcp.js b/packages/tts-lib/lib/providers/gcp.js index aa25543..ba83898 100644 --- a/packages/tts-lib/lib/providers/gcp.js +++ b/packages/tts-lib/lib/providers/gcp.js @@ -61,7 +61,10 @@ class GcpProvider extends Provider { * Calls the Google Cloud API with the given info. */ generate = function (info, i, callback) { - info.task.title = info.task.title.replace(/\d+\//, `${i}/`) + /* istanbul ignore else: not a real-life scenario */ + if (info.task.title.length < 1000) { // prevent regexp DoS + info.task.title = info.task.title.replace(/\d+\//, `${i}/`) + } const request = { input: info.opts.type === 'ssml' From 3a4650014a5ef8cf677e116c1afb3ca141537aa1 Mon Sep 17 00:00:00 2001 From: Eric Heikes Date: Wed, 14 May 2025 21:18:59 -0700 Subject: [PATCH 08/48] test: Remove helpers file --- packages/tts-lib/test/cleanup.spec.js | 16 +++-- .../test/combine-encoded-audio.spec.js | 35 ++++++++-- .../tts-lib/test/combine-raw-audio.spec.js | 25 +++++-- packages/tts-lib/test/combine.spec.js | 49 ++++++++++--- packages/tts-lib/test/create-manifest.spec.js | 12 +++- packages/tts-lib/test/generate-all.spec.js | 20 ++++-- packages/tts-lib/test/helpers.js | 69 ------------------- packages/tts-lib/test/providers/aws.spec.js | 10 +-- packages/tts-lib/test/split-text.spec.js | 8 +-- 9 files changed, 125 insertions(+), 119 deletions(-) delete mode 100644 packages/tts-lib/test/helpers.js diff --git a/packages/tts-lib/test/cleanup.spec.js b/packages/tts-lib/test/cleanup.spec.js index e6681ed..1a63088 100644 --- a/packages/tts-lib/test/cleanup.spec.js +++ b/packages/tts-lib/test/cleanup.spec.js @@ -1,26 +1,32 @@ +const proxyquire = require('proxyquire') + describe('cleanup()', () => { const manifestFilename = 'manifest.txt' const tempFilenames = ['foo.mp3', 'bar.mp3'] - let cleanup, fs + let cleanup + let fsSpy beforeEach(() => { - ({ cleanup, fs } = require('./helpers').loadLib('cleanup')) + fsSpy = jasmine.createSpyObj('fs', ['readFileSync', 'removeSync']) + ;({ cleanup } = proxyquire('../lib/cleanup', { + 'fs-extra': fsSpy + })) }) beforeEach(() => { const manifestContents = tempFilenames.map(filename => `file '${filename}'`).join('\n') - fs.readFileSync.and.callFake(() => manifestContents) + fsSpy.readFileSync.and.callFake(() => manifestContents) return cleanup(manifestFilename) }) it('should delete the manifest file', () => { - expect(fs.removeSync).toHaveBeenCalledWith(manifestFilename) + expect(fsSpy.removeSync).toHaveBeenCalledWith(manifestFilename) }) it('should delete the temporary audio files', () => { tempFilenames.forEach(filename => { - expect(fs.removeSync).toHaveBeenCalledWith(filename) + expect(fsSpy.removeSync).toHaveBeenCalledWith(filename) }) }) }) diff --git a/packages/tts-lib/test/combine-encoded-audio.spec.js b/packages/tts-lib/test/combine-encoded-audio.spec.js index 6a205a1..58a6202 100644 --- a/packages/tts-lib/test/combine-encoded-audio.spec.js +++ b/packages/tts-lib/test/combine-encoded-audio.spec.js @@ -1,12 +1,35 @@ +const proxyquire = require('proxyquire') + describe('combineEncodedAudio()', () => { const binary = 'ffmpeg' const manifestFilename = 'manifest.txt' const tempFilename = 'foobar.mp3' - let combineEncodedAudio, spawn + let combineEncodedAudio + let spawnSpy beforeEach(() => { - ({ combineEncodedAudio, spawn } = require('./helpers').loadLib('combine-parts')) + const spawnOnSpy = jasmine.createSpy('spawn.on').and.callFake((type, callback) => { + if (type === 'close') { callback() } + }) + const spawnStderrOn = jasmine.createSpy('spawn.stderr.on') + spawnSpy = jasmine.createSpy('spawn').and.callFake(() => { + return { + on: spawnOnSpy, + stderr: { + on: spawnStderrOn + } + } + }) + spawnSpy.on = spawnOnSpy + spawnSpy.stderr = { + on: spawnStderrOn + } + ;({ combineEncodedAudio } = proxyquire('../lib/combine-parts', { + child_process: { + spawn: spawnSpy + } + })) }) describe('process', () => { @@ -14,7 +37,7 @@ describe('combineEncodedAudio()', () => { beforeEach(() => { combineEncodedAudio(binary, manifestFilename, tempFilename); - [cmd, args] = spawn.calls.mostRecent().args + [cmd, args] = spawnSpy.calls.mostRecent().args args = args.join(' ') }) @@ -45,7 +68,7 @@ describe('combineEncodedAudio()', () => { describe('when the process cannot be spawned', () => { beforeEach(() => { - spawn.on.and.callFake((type, callback) => { + spawnSpy.on.and.callFake((type, callback) => { if (type === 'error') { callback() } }) }) @@ -62,10 +85,10 @@ describe('combineEncodedAudio()', () => { const errorOutput = 'foobar' beforeEach(() => { - spawn.on.and.callFake((type, callback) => { + spawnSpy.on.and.callFake((type, callback) => { if (type === 'close') { callback(errorCode) } }) - spawn.stderr.on.and.callFake((type, callback) => { + spawnSpy.stderr.on.and.callFake((type, callback) => { if (type === 'data') { callback(errorOutput) } }) }) diff --git a/packages/tts-lib/test/combine-raw-audio.spec.js b/packages/tts-lib/test/combine-raw-audio.spec.js index d070bef..053cb10 100644 --- a/packages/tts-lib/test/combine-raw-audio.spec.js +++ b/packages/tts-lib/test/combine-raw-audio.spec.js @@ -1,29 +1,40 @@ +const proxyquire = require('proxyquire') + describe('combineRawAudio()', () => { const manifestFilename = 'manifest.txt' const outputFilename = 'foobar.mp3' const tempFilenames = ['foo.mp3', 'bar.mp3'] - let combineRawAudio, fs + let combineRawAudio + let fsSpy beforeEach(() => { - ({ combineRawAudio, fs } = require('./helpers').loadLib('combine-parts')) + fsSpy = jasmine.createSpyObj('fs', [ + 'appendFileSync', + 'createFileSync', + 'readFileSync', + 'truncateSync' + ]) + ;({ combineRawAudio } = proxyquire('../lib/combine-parts', { + 'fs-extra': fsSpy + })) }) beforeEach(done => { const manifestContents = tempFilenames.map(filename => `file '${filename}'`).join('\n') - fs.readFileSync.and.callFake(() => manifestContents) + fsSpy.readFileSync.and.callFake(() => manifestContents) combineRawAudio(manifestFilename, outputFilename).then(done) }) it('should create the output file and truncate it', () => { - expect(fs.createFileSync).toHaveBeenCalledWith(outputFilename) - expect(fs.truncateSync).toHaveBeenCalledWith(outputFilename) + expect(fsSpy.createFileSync).toHaveBeenCalledWith(outputFilename) + expect(fsSpy.truncateSync).toHaveBeenCalledWith(outputFilename) }) it('should read and append each file from the manifest', () => { tempFilenames.forEach(filename => { - expect(fs.readFileSync).toHaveBeenCalledWith(filename) + expect(fsSpy.readFileSync).toHaveBeenCalledWith(filename) }) - expect(fs.appendFileSync.calls.count()).toBe(tempFilenames.length) + expect(fsSpy.appendFileSync.calls.count()).toBe(tempFilenames.length) }) }) diff --git a/packages/tts-lib/test/combine.spec.js b/packages/tts-lib/test/combine.spec.js index e67194e..76cee12 100644 --- a/packages/tts-lib/test/combine.spec.js +++ b/packages/tts-lib/test/combine.spec.js @@ -1,23 +1,50 @@ +const proxyquire = require('proxyquire') const tempfile = require('tempfile') describe('combine()', () => { const testManifest = 'manifest.txt' - let combine, fs, spawn + let combine + let fsSpy + let spawnSpy beforeEach(() => { - const lib = require('./helpers').loadLib('combine-parts') - combine = lib.combine - fs = lib.fs - spawn = lib.spawn + fsSpy = jasmine.createSpyObj('fs', [ + 'appendFileSync', + 'createFileSync', + 'readFileSync', + 'truncateSync' + ]) + const spawnOnSpy = jasmine.createSpy('spawn.on').and.callFake((type, callback) => { + if (type === 'close') { callback() } + }) + const spawnStderrOn = jasmine.createSpy('spawn.stderr.on') + spawnSpy = jasmine.createSpy('spawn').and.callFake(() => { + return { + on: spawnOnSpy, + stderr: { + on: spawnStderrOn + } + } + }) + spawnSpy.on = spawnOnSpy + spawnSpy.stderr = { + on: spawnStderrOn + } + ;({ combine } = proxyquire('../lib/combine-parts', { + child_process: { + spawn: spawnSpy + }, + 'fs-extra': fsSpy + })) }) describe('when the format is encoded audio', () => { it('should call combineEncodedAudio()', () => { return combine(testManifest, tempfile(), 'encoded', 'ffmpeg-test').then(() => { // We can't spy on combineEncodedAudio() directly, so look at its internals. // TODO is this true? - expect(spawn).toHaveBeenCalled() - expect(spawn.calls.mostRecent().args[0]).toBe('ffmpeg-test') + expect(spawnSpy).toHaveBeenCalled() + expect(spawnSpy.calls.mostRecent().args[0]).toBe('ffmpeg-test') }) }) }) @@ -26,7 +53,7 @@ describe('combine()', () => { it('should call combineRawAudio()', () => { return combine(testManifest, tempfile(), 'raw').then(() => { // We can't spy on combineRawAudio() directly, so look at its internals. // TODO is this true? - expect(fs.createFileSync).toHaveBeenCalled() + expect(fsSpy.createFileSync).toHaveBeenCalled() }) }) }) @@ -35,8 +62,8 @@ describe('combine()', () => { it('should call combineEncodedAudio()', () => { return combine(testManifest, tempfile()).then(() => { // We can't spy on combineEncodedAudio() directly, so look at its internals. // TODO is this true? - expect(spawn).toHaveBeenCalled() - expect(spawn.calls.mostRecent().args[0]).toBe('ffmpeg') + expect(spawnSpy).toHaveBeenCalled() + expect(spawnSpy.calls.mostRecent().args[0]).toBe('ffmpeg') }) }) }) @@ -58,7 +85,7 @@ describe('combine()', () => { let result beforeEach(() => { - spawn.on.and.callFake((type, callback) => { + spawnSpy.on.and.callFake((type, callback) => { if (type === 'error') { callback() } }) return combine(testManifest, tempfile(), 'encoded').catch(response => { diff --git a/packages/tts-lib/test/create-manifest.spec.js b/packages/tts-lib/test/create-manifest.spec.js index 206456b..20b2bca 100644 --- a/packages/tts-lib/test/create-manifest.spec.js +++ b/packages/tts-lib/test/create-manifest.spec.js @@ -1,19 +1,25 @@ +const proxyquire = require('proxyquire') + describe('createManifest()', () => { const testParts = [ { tempfile: 'foo.mp3' }, { tempfile: 'bar.mp3' } ] - let createManifest, fs + let createManifest + let fsSpy let outputFilename, fileContents, options, lines, response beforeEach(() => { - ({ createManifest, fs } = require('./helpers').loadLib('generate-speech')) + fsSpy = jasmine.createSpyObj('fs', ['writeFileSync']) + ;({ createManifest } = proxyquire('../lib/generate-speech', { + 'fs-extra': fsSpy + })) }) beforeEach(() => { response = createManifest(testParts); - [outputFilename, fileContents, options] = fs.writeFileSync.calls.mostRecent().args + [outputFilename, fileContents, options] = fsSpy.writeFileSync.calls.mostRecent().args lines = fileContents.split('\n') }) diff --git a/packages/tts-lib/test/generate-all.spec.js b/packages/tts-lib/test/generate-all.spec.js index e787939..c568429 100644 --- a/packages/tts-lib/test/generate-all.spec.js +++ b/packages/tts-lib/test/generate-all.spec.js @@ -1,5 +1,9 @@ +const async = require('async') +const proxyquire = require('proxyquire') + describe('generateAll()', () => { - let async, generateAll, task + let generateAll, task + let asyncSpy let iteratorFunction const testLimit = 2 @@ -11,14 +15,16 @@ describe('generateAll()', () => { iteratorFunction = jasmine.createSpy('async iterator') iteratorFunction.and.callFake((data, i, callback) => { callback() }) task = { title: '' } - const helpers = require('./helpers').loadLib('generate-speech') - async = helpers.async - generateAll = helpers.generateAll + asyncSpy = jasmine.createSpyObj('async', ['eachOfLimit']) + asyncSpy.eachOfLimit.and.callFake(async.eachOfLimit) + ;({ generateAll } = proxyquire('../lib/generate-speech', { + async: asyncSpy + })) }) it('should asynchronously call the function for each of the parts', done => { generateAll(textParts, testLimit, iteratorFunction, task).then(() => { - const [parts] = async.eachOfLimit.calls.mostRecent().args + const [parts] = asyncSpy.eachOfLimit.calls.mostRecent().args expect(parts).toEqual(textParts) expect(parts.length).toBe(textParts.length) expect(iteratorFunction.calls.count()).toBe(textParts.length) @@ -27,14 +33,14 @@ describe('generateAll()', () => { it('should limit the async calls according to the option', done => { generateAll(textParts, testLimit, iteratorFunction, task).then(() => { - const [, limit] = async.eachOfLimit.calls.mostRecent().args + const [, limit] = asyncSpy.eachOfLimit.calls.mostRecent().args expect(limit).toBe(testLimit) }).then(done) }) describe('initial spinner', () => { beforeEach(done => { - async.eachOfLimit.and.callFake((parts, opts, func, callback) => { + asyncSpy.eachOfLimit.and.callFake((parts, opts, func, callback) => { callback(new Error('reject async')) }) generateAll(textParts, 1, iteratorFunction, task).catch(() => { diff --git a/packages/tts-lib/test/helpers.js b/packages/tts-lib/test/helpers.js deleted file mode 100644 index 426887f..0000000 --- a/packages/tts-lib/test/helpers.js +++ /dev/null @@ -1,69 +0,0 @@ -// TODO clean up this file -const async = require('async') -const originalFs = require('fs') -const proxyquire = require('proxyquire') - -exports.loadLib = (file) => { - // Spy on the async module. - spyOn(async, 'eachOfLimit').and.callThrough() - - // Stub out the fs(-extra) module with spies. - const fs = jasmine.createSpyObj('fs', [ - 'appendFileSync', - 'createFileSync', - 'createWriteStream', - 'move', - 'readFile', - 'readFileSync', - 'removeSync', - 'truncateSync', - 'writeFile', - 'writeFileSync' - ]) - fs.createWriteStream.and.callFake(filename => { - const stream = originalFs.createWriteStream(filename) - return stream - }) - fs.move.and.callFake((src, dest, opts, callback) => { callback() }) - fs.writeFile.and.callFake((dest, data, opts, callback) => { callback() }) - - // Stub out a provider. - const providerStub = { - create: () => ({ - buildPart: () => ({}), - generate: (item, key, callback) => callback(null, null) - }) - } - - const spawnOnSpy = jasmine.createSpy('spawn.on').and.callFake((type, callback) => { - if (type === 'close') { callback() } - }) - const spawnStderrOn = jasmine.createSpy('spawn.stderr.on') - const spawn = jasmine.createSpy('spawn').and.callFake(() => { - return { - on: spawnOnSpy, - stderr: { - on: spawnStderrOn - } - } - }) - - // Load the library module. - const lib = proxyquire(`../lib/${file}`, { - './providers/aws': providerStub, - './providers/gcp': providerStub, - async, - child_process: { spawn }, // eslint-disable-line camelcase - 'fs-extra': fs - }) - - // Add the spies for inspection. - lib.async = async - lib.fs = fs - lib.provider = providerStub - lib.spawn = spawn - lib.spawn.on = spawnOnSpy - lib.spawn.stderr = { on: spawnStderrOn } - - return lib -} diff --git a/packages/tts-lib/test/providers/aws.spec.js b/packages/tts-lib/test/providers/aws.spec.js index aec1b85..c81fa32 100644 --- a/packages/tts-lib/test/providers/aws.spec.js +++ b/packages/tts-lib/test/providers/aws.spec.js @@ -8,21 +8,21 @@ describe('AWS provider', () => { const chunks = ['hello world'] let combineStub - let fsStub + let fsSpy let splitTextStub let AwsProvider let provider beforeEach(() => { combineStub = jasmine.createSpy('combine') - fsStub = jasmine.createSpyObj('fs', ['createWriteStream']) - fsStub.createWriteStream.and.callFake(filename => { + fsSpy = jasmine.createSpyObj('fs', ['createWriteStream']) + fsSpy.createWriteStream.and.callFake(filename => { const stream = fs.createWriteStream(filename) return stream }) splitTextStub = jasmine.createSpy('splitText').and.returnValue(chunks) ;({ AwsProvider } = proxyquire('../../lib/providers/aws', { - 'fs-extra': fsStub, + 'fs-extra': fsSpy, '../combine-parts': { combine: combineStub }, @@ -343,7 +343,7 @@ describe('AWS provider', () => { }) it('should callback with an error if file saving fails', done => { - fsStub.createWriteStream.and.callFake(filename => { + fsSpy.createWriteStream.and.callFake(filename => { const stream = fs.createWriteStream(filename) stream.on('pipe', () => { stream.emit('error', new Error('write stream error')) diff --git a/packages/tts-lib/test/split-text.spec.js b/packages/tts-lib/test/split-text.spec.js index 0d4ca7b..2040379 100644 --- a/packages/tts-lib/test/split-text.spec.js +++ b/packages/tts-lib/test/split-text.spec.js @@ -1,13 +1,9 @@ +const { splitText } = require('../lib/split-text') + describe('splitText()', () => { const maxChars = 1000 const testData = 'hello world' - let splitText - - beforeEach(() => { - ({ splitText } = require('./helpers').loadLib('split-text')) - }) - it('should split the text into an array of parts', done => { splitText(testData, maxChars).then((parts) => { expect(parts).toEqual([testData]) From 2829da5adf3a00e58676b1a94390d70cf7cacc6b Mon Sep 17 00:00:00 2001 From: Eric Heikes Date: Wed, 14 May 2025 21:31:31 -0700 Subject: [PATCH 09/48] test(combine): Spy directly on functions --- packages/tts-lib/test/combine.spec.js | 63 +++------------------------ 1 file changed, 7 insertions(+), 56 deletions(-) diff --git a/packages/tts-lib/test/combine.spec.js b/packages/tts-lib/test/combine.spec.js index 76cee12..5e43299 100644 --- a/packages/tts-lib/test/combine.spec.js +++ b/packages/tts-lib/test/combine.spec.js @@ -1,50 +1,21 @@ -const proxyquire = require('proxyquire') const tempfile = require('tempfile') +const lib = require('../lib/combine-parts') describe('combine()', () => { const testManifest = 'manifest.txt' let combine - let fsSpy - let spawnSpy beforeEach(() => { - fsSpy = jasmine.createSpyObj('fs', [ - 'appendFileSync', - 'createFileSync', - 'readFileSync', - 'truncateSync' - ]) - const spawnOnSpy = jasmine.createSpy('spawn.on').and.callFake((type, callback) => { - if (type === 'close') { callback() } - }) - const spawnStderrOn = jasmine.createSpy('spawn.stderr.on') - spawnSpy = jasmine.createSpy('spawn').and.callFake(() => { - return { - on: spawnOnSpy, - stderr: { - on: spawnStderrOn - } - } - }) - spawnSpy.on = spawnOnSpy - spawnSpy.stderr = { - on: spawnStderrOn - } - ;({ combine } = proxyquire('../lib/combine-parts', { - child_process: { - spawn: spawnSpy - }, - 'fs-extra': fsSpy - })) + combine = lib.combine + spyOn(lib, 'combineEncodedAudio') + spyOn(lib, 'combineRawAudio') }) describe('when the format is encoded audio', () => { it('should call combineEncodedAudio()', () => { return combine(testManifest, tempfile(), 'encoded', 'ffmpeg-test').then(() => { - // We can't spy on combineEncodedAudio() directly, so look at its internals. // TODO is this true? - expect(spawnSpy).toHaveBeenCalled() - expect(spawnSpy.calls.mostRecent().args[0]).toBe('ffmpeg-test') + expect(lib.combineEncodedAudio).toHaveBeenCalledWith('ffmpeg-test', 'manifest.txt', jasmine.any(String)) }) }) }) @@ -52,8 +23,7 @@ describe('combine()', () => { describe('when the format is raw', () => { it('should call combineRawAudio()', () => { return combine(testManifest, tempfile(), 'raw').then(() => { - // We can't spy on combineRawAudio() directly, so look at its internals. // TODO is this true? - expect(fsSpy.createFileSync).toHaveBeenCalled() + expect(lib.combineRawAudio).toHaveBeenCalledWith('manifest.txt', jasmine.any(String)) }) }) }) @@ -61,9 +31,7 @@ describe('combine()', () => { describe('default format', () => { it('should call combineEncodedAudio()', () => { return combine(testManifest, tempfile()).then(() => { - // We can't spy on combineEncodedAudio() directly, so look at its internals. // TODO is this true? - expect(spawnSpy).toHaveBeenCalled() - expect(spawnSpy.calls.mostRecent().args[0]).toBe('ffmpeg') + expect(lib.combineEncodedAudio).toHaveBeenCalledWith('ffmpeg', 'manifest.txt', jasmine.any(String)) }) }) }) @@ -80,21 +48,4 @@ describe('combine()', () => { expect(result).toBe(destFile) }) }) - - describe('when it fails', () => { - let result - - beforeEach(() => { - spawnSpy.on.and.callFake((type, callback) => { - if (type === 'error') { callback() } - }) - return combine(testManifest, tempfile(), 'encoded').catch(response => { - result = response - }) - }) - - it('should return a rejected promise with the error', () => { - expect(result.message).toMatch('Could not start ffmpeg process') - }) - }) }) From c0d431f0b1d0220ec2c869b29945cd8cc2ca5d5d Mon Sep 17 00:00:00 2001 From: Eric Heikes Date: Thu, 15 May 2025 21:13:33 -0700 Subject: [PATCH 10/48] test: Remove old unneeded tests --- packages/tts-lib/test/generate-speech.spec.js | 137 ------------------ 1 file changed, 137 deletions(-) delete mode 100644 packages/tts-lib/test/generate-speech.spec.js diff --git a/packages/tts-lib/test/generate-speech.spec.js b/packages/tts-lib/test/generate-speech.spec.js deleted file mode 100644 index 4e9fc20..0000000 --- a/packages/tts-lib/test/generate-speech.spec.js +++ /dev/null @@ -1,137 +0,0 @@ -// TODO reconcile this -xdescribe('generateSpeech()', () => { - let fsStub - let generateSpeech - let ctx, task - - beforeEach(() => { - ({ fs: fsStub, generateSpeech } = require('./helpers').loadLib('generate-speech')) - ctx = { - args: { - 'access-key': 'access key', - email: 'foo@example.com', - effect: 'effect', - engine: 'neural', - ffmpeg: 'ffmpeg', - format: 'mp3', - gain: '1.2', - gender: 'neutral', - language: 'ab-CD', - lexicon: 'lexicon', - pitch: '9.8', - 'private-key': 'private key', - 'project-file': 'project file', - 'project-id': 'project ID', - region: 'region', - 'sample-rate': '22000', - 'secret-key': 'secret key', - speed: '2.3', - throttle: '10', - type: 'type', - voice: 'voice' - }, - service: 'aws', - parts: ['a', 'b', 'c'] - } - task = { title: 'test task' } - }) - - it('should set context "opts" from the args', () => { - return generateSpeech(ctx, task).then(() => { - expect(ctx.opts.accessKey).toBe(ctx.args['access-key']) - expect(ctx.opts.effect).toEqual([ctx.args.effect]) - expect(ctx.opts.email).toBe(ctx.args.email) - expect(ctx.opts.engine).toBe(ctx.args.engine) - expect(ctx.opts.ffmpeg).toBe(ctx.args.ffmpeg) - expect(ctx.opts.format).toBe(ctx.args.format) - expect(ctx.opts.gain).toBe(Number(ctx.args.gain)) - expect(ctx.opts.gender).toBe(ctx.args.gender) - expect(ctx.opts.language).toBe(ctx.args.language) - expect(ctx.opts.lexicon).toEqual([ctx.args.lexicon]) - expect(ctx.opts.limit).toBe(Number(ctx.args.throttle)) - expect(ctx.opts.pitch).toBe(Number(ctx.args.pitch)) - expect(ctx.opts.privateKey).toBe(ctx.args['private-key']) - expect(ctx.opts.projectFile).toBe(ctx.args['project-file']) - expect(ctx.opts.projectId).toBe(ctx.args['project-id']) - expect(ctx.opts.region).toBe(ctx.args.region) - expect(ctx.opts.sampleRate).toBe(Number(ctx.args['sample-rate'])) - expect(ctx.opts.secretKey).toBe(ctx.args['secret-key']) - expect(ctx.opts.speed).toBe(parseFloat(ctx.args.speed)) - expect(ctx.opts.type).toBe(ctx.args.type) - expect(ctx.opts.voice).toBe(ctx.args.voice) - }) - }) - - it('should have context "opts" fall back to the (AWS) defaults', () => { - ctx.args = {} - ctx.service = 'aws' - return generateSpeech(ctx, task).then(() => { - expect(ctx.opts.accessKey).toBeUndefined() - expect(ctx.opts.effect).toBeUndefined() - expect(ctx.opts.engine).toBeUndefined() - expect(ctx.opts.ffmpeg).toBe('ffmpeg') - expect(ctx.opts.format).toBe('mp3') - expect(ctx.opts.gain).toBeUndefined() - expect(ctx.opts.gender).toBeUndefined() - expect(ctx.opts.language).toBeUndefined() - expect(ctx.opts.lexicon).toBeUndefined() - expect(ctx.opts.limit).toBe(5) - expect(ctx.opts.pitch).toBeUndefined() - expect(ctx.opts.region).toBe('us-east-1') - expect(ctx.opts.sampleRate).toBeUndefined() - expect(ctx.opts.secretKey).toBeUndefined() - expect(ctx.opts.speed).toBeUndefined() - expect(ctx.opts.type).toBe('text') - expect(ctx.opts.voice).toBe('Joanna') - }) - }) - - it('should have context "opts" fall back to the (GCP) defaults', () => { - ctx.args = {} - ctx.service = 'gcp' - return generateSpeech(ctx, task).then(() => { - expect(ctx.opts.effect).toBeUndefined() - expect(ctx.opts.email).toBeUndefined() - expect(ctx.opts.engine).toBeUndefined() - expect(ctx.opts.ffmpeg).toBe('ffmpeg') - expect(ctx.opts.format).toBe('mp3') - expect(ctx.opts.gain).toBeUndefined() - expect(ctx.opts.gender).toBeUndefined() - expect(ctx.opts.language).toBe('en-US') - expect(ctx.opts.limit).toBe(5) - expect(ctx.opts.pitch).toBeUndefined() - expect(ctx.opts.privateKey).toBeUndefined() - expect(ctx.opts.projectFile).toBeUndefined() - expect(ctx.opts.projectId).toBeUndefined() - expect(ctx.opts.sampleRate).toBeUndefined() - expect(ctx.opts.speed).toBeUndefined() - expect(ctx.opts.type).toBe('text') - expect(ctx.opts.voice).toBeUndefined() - }) - }) - - it('should set private-key when private-key-file is specified', () => { - fsStub.readFileSync.and.returnValue('private key data') - ctx.args = { - 'private-key-file': 'foobar.pem' - } - return generateSpeech(ctx, task).then(() => { - expect(fsStub.readFileSync).toHaveBeenCalledWith('foobar.pem', 'utf8') - expect(ctx.opts.privateKey).toBe('private key data') - }) - }) - - it('should set format to "ogg" when argument is "ogg_vorbis" (AWS)', () => { - ctx.service = 'aws' - ctx.args.format = 'ogg_vorbis' - return generateSpeech(ctx, task).then(() => { - expect(ctx.opts.format).toBe('ogg') - }) - }) - - it('should save the manifest file to the context', () => { - return generateSpeech(ctx, task).then(() => { - expect(ctx.manifestFile).toEqual(jasmine.any(String)) - }) - }) -}) From a34fe1cb3f72a2573911f733634c3818f872f234 Mon Sep 17 00:00:00 2001 From: Eric Heikes Date: Thu, 15 May 2025 22:12:20 -0700 Subject: [PATCH 11/48] refactor: Use native fs --- package-lock.json | 1 - packages/tts-cli/lib/move-temp-file.js | 13 +++------ packages/tts-cli/lib/read-text.js | 9 ++++--- packages/tts-cli/package.json | 1 - packages/tts-cli/test/cli.spec.js | 2 +- packages/tts-cli/test/move-temp-file.spec.js | 28 +++++++++----------- packages/tts-cli/test/read-text.spec.js | 18 ++++--------- packages/tts-cli/tts.js | 4 +-- 8 files changed, 29 insertions(+), 47 deletions(-) diff --git a/package-lock.json b/package-lock.json index 2a12579..10a7ca0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8080,7 +8080,6 @@ "license": "Apache-2.0", "dependencies": { "debug": "^3.1.0", - "fs-extra": "^2.0.0", "listr2": "^8.0.2", "minimist": "^1.2.0" }, diff --git a/packages/tts-cli/lib/move-temp-file.js b/packages/tts-cli/lib/move-temp-file.js index f81fff3..7c3e085 100644 --- a/packages/tts-cli/lib/move-temp-file.js +++ b/packages/tts-cli/lib/move-temp-file.js @@ -1,18 +1,13 @@ const debug = require('debug')('moveTempFile') -const fs = require('fs-extra') +const { rename } = require('fs/promises') /** * Moves the temporary file to the final destination. */ -exports.moveTempFile = (ctx, task) => { +exports.moveTempFile = async (ctx, task) => { const tempFile = ctx.tempFile const outputFilename = ctx.outputFilename debug(`copying ${tempFile} to ${outputFilename}`) - return new Promise((resolve, reject) => { - fs.move(tempFile, outputFilename, { overwrite: true }, (err) => { - if (err) { return reject(err) } - task.title = `Done. Saved to ${outputFilename}` - resolve() - }) - }) + await rename(tempFile, outputFilename) + task.title = `Done. Saved to ${outputFilename}` } diff --git a/packages/tts-cli/lib/read-text.js b/packages/tts-cli/lib/read-text.js index 8bf58e1..6b6aad9 100644 --- a/packages/tts-cli/lib/read-text.js +++ b/packages/tts-cli/lib/read-text.js @@ -1,19 +1,20 @@ const debug = require('debug')('readText') -const fs = require('fs-extra') +const { readFile } = require('fs/promises') /** * Read in the text from a file. * If no file is specified, read from stdin. */ -exports.readText = (inputFilename, proc) => { +exports.readText = async (inputFilename, proc) => { return new Promise((resolve, reject) => { if (inputFilename) { // Read from a file. debug(`Reading from ${inputFilename}`) - fs.readFile(inputFilename, 'utf8', (err, data) => { - if (err) { return reject(err) } + readFile(inputFilename, 'utf8').then((data) => { debug(`Finished reading (${data.length} bytes)`) resolve(data) + }).catch((err) => { + reject(err) }) } else { // Read from stdin. diff --git a/packages/tts-cli/package.json b/packages/tts-cli/package.json index 37af963..14b7d3d 100644 --- a/packages/tts-cli/package.json +++ b/packages/tts-cli/package.json @@ -36,7 +36,6 @@ "homepage": "https://github.com/eheikes/tts/tree/master/packages/tts-cli#readme", "dependencies": { "debug": "^3.1.0", - "fs-extra": "^2.0.0", "listr2": "^8.0.2", "minimist": "^1.2.0" }, diff --git a/packages/tts-cli/test/cli.spec.js b/packages/tts-cli/test/cli.spec.js index 23712da..7298db0 100644 --- a/packages/tts-cli/test/cli.spec.js +++ b/packages/tts-cli/test/cli.spec.js @@ -32,7 +32,7 @@ describe('CLI', () => { } }) mocks = { - 'fs-extra': { + 'fs': { readFileSync: () => 'file contents' }, minimist, diff --git a/packages/tts-cli/test/move-temp-file.spec.js b/packages/tts-cli/test/move-temp-file.spec.js index 83a8d6f..873af99 100644 --- a/packages/tts-cli/test/move-temp-file.spec.js +++ b/packages/tts-cli/test/move-temp-file.spec.js @@ -8,39 +8,35 @@ describe('moveTempFile()', () => { let fsSpy let ctx, task - beforeEach(() => { - fsSpy = jasmine.createSpyObj('fs', ['move']) - fsSpy.move.and.callFake((src, dest, opts, callback) => { callback() }) + beforeEach(async () => { + fsSpy = jasmine.createSpyObj('fs', ['rename']) + fsSpy.rename.and.callFake((src, dest) => Promise.resolve()) ;({ moveTempFile } = proxyquire('../lib/move-temp-file', { - 'fs-extra': fsSpy + 'fs/promises': fsSpy })) ctx = { tempFile: sourceFile, outputFilename: destFile } task = { title: 'test task' } - return moveTempFile(ctx, task) + await moveTempFile(ctx, task) }) it('should overwrite the destination filename with the specified temp file', () => { - expect(fsSpy.move).toHaveBeenCalledWith( - sourceFile, - destFile, - { overwrite: true }, - jasmine.any(Function) - ) + expect(fsSpy.rename).toHaveBeenCalledWith(sourceFile, destFile) }) it('should update the task title', () => { expect(task.title).toContain('Done. Saved to') }) - it('should return the error if the filesystem call fails', () => { - fsSpy.move.and.callFake((src, dest, opts, callback) => callback(new Error('test error'))) - return moveTempFile(ctx, task).then(() => { + it('should return the error if the filesystem call fails', async () => { + fsSpy.rename.and.callFake((src, dest) => Promise.reject(new Error('test error'))) + try { + await moveTempFile(ctx, task) throw new Error('should have thrown!') - }).catch(err => { + } catch(err) { expect(err.message).toBe('test error') - }) + } }) }) diff --git a/packages/tts-cli/test/read-text.spec.js b/packages/tts-cli/test/read-text.spec.js index 645ddb7..76217c3 100644 --- a/packages/tts-cli/test/read-text.spec.js +++ b/packages/tts-cli/test/read-text.spec.js @@ -8,9 +8,9 @@ describe('readText()', () => { let stdin beforeEach(() => { - fsSpy = jasmine.createSpyObj('fs', ['readFile', 'readFileSync']) + fsSpy = jasmine.createSpyObj('fs', ['readFile']) ;({ readText } = proxyquire('../lib/read-text', { - 'fs-extra': fsSpy + 'fs/promises': fsSpy })) }) @@ -52,18 +52,12 @@ describe('readText()', () => { const testFilename = 'test.txt' beforeEach(() => { - fsSpy.readFile.and.callFake((filename, opts, callback) => { - callback(null, testData) - }) + fsSpy.readFile.and.callFake((filename, opts) => Promise.resolve(testData)) }) it('should read data from the file', done => { readText(testFilename, { stdin }).then(() => { - expect(fsSpy.readFile).toHaveBeenCalledWith( - testFilename, - 'utf8', - jasmine.any(Function) - ) + expect(fsSpy.readFile).toHaveBeenCalledWith(testFilename, 'utf8') }).then(done) }) @@ -78,9 +72,7 @@ describe('readText()', () => { describe('and cannot read the file', () => { it('should reject with the error', done => { const testError = 'error object' - fsSpy.readFile.and.callFake((filename, opts, callback) => { - callback(testError) - }) + fsSpy.readFile.and.callFake((filename, opts) => Promise.reject(testError)) readText(testFilename, { stdin }).catch(err => { expect(err).toBe(testError) }).then(done) diff --git a/packages/tts-cli/tts.js b/packages/tts-cli/tts.js index 762424a..f788ae8 100755 --- a/packages/tts-cli/tts.js +++ b/packages/tts-cli/tts.js @@ -4,7 +4,7 @@ * to convert it to an audio file. */ const debug = require('debug')('tts-cli') -const fs = require('fs-extra') +const { readFileSync} = require('fs') const { createProvider } = require('../tts-lib/lib/provider') const { cleanup } = require('../tts-lib/lib/cleanup') @@ -61,7 +61,7 @@ if (typeof opts.lexicon !== 'undefined' && !Array.isArray(opts.lexicon)) { } if (args['private-key-file']) { debug(`Reading private key from ${args['private-key-file']}`) - opts.privateKey = fs.readFileSync(args['private-key-file'], 'utf8') + opts.privateKey = readFileSync(args['private-key-file'], 'utf8') } debug(`Options: ${JSON.stringify(sanitizeOpts(opts))}`) From 19f4e6a03e3321cee94c6207bac8e9a0cece1a58 Mon Sep 17 00:00:00 2001 From: Eric Heikes Date: Fri, 23 May 2025 23:24:08 -0700 Subject: [PATCH 12/48] refactor: Use native fs --- package-lock.json | 21 +------------------ packages/tts-lib/lib/cleanup.js | 8 +++---- packages/tts-lib/lib/combine-parts.js | 14 ++++++------- packages/tts-lib/lib/generate-speech.js | 6 +++--- packages/tts-lib/lib/providers/aws.js | 4 ++-- packages/tts-lib/lib/providers/gcp.js | 4 ++-- packages/tts-lib/package.json | 1 - packages/tts-lib/test/cleanup.spec.js | 10 ++++----- .../tts-lib/test/combine-raw-audio.spec.js | 18 +++++++--------- packages/tts-lib/test/create-manifest.spec.js | 10 ++++----- packages/tts-lib/test/provider.spec.js | 2 +- packages/tts-lib/test/providers/aws.spec.js | 2 +- packages/tts-lib/test/providers/gcp.spec.js | 2 +- 13 files changed, 39 insertions(+), 63 deletions(-) diff --git a/package-lock.json b/package-lock.json index 10a7ca0..b5a4af4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4134,16 +4134,6 @@ ], "license": "MIT" }, - "node_modules/fs-extra": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-2.1.2.tgz", - "integrity": "sha512-9ztMtDZtSKC78V8mev+k31qaTabbmuH5jatdvPBMikrFHvw5BqlYnQIn/WGK3WHeRooSTkRvLa2IPlaHjPq5Sg==", - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.1.2", - "jsonfile": "^2.1.0" - } - }, "node_modules/fs.realpath": { "version": "1.0.0", "dev": true, @@ -4419,6 +4409,7 @@ }, "node_modules/graceful-fs": { "version": "4.2.0", + "dev": true, "license": "ISC" }, "node_modules/graphemer": { @@ -5211,15 +5202,6 @@ "json5": "lib/cli.js" } }, - "node_modules/jsonfile": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-2.4.0.tgz", - "integrity": "sha512-PKllAqbgLgxHaj8TElYymKCAgrASebJrWpTnEkOaTowt23VKXXN0sUeriJ+eh7y6ufb/CC5ap11pz71/cM0hUw==", - "license": "MIT", - "optionalDependencies": { - "graceful-fs": "^4.1.6" - } - }, "node_modules/jsx-ast-utils": { "version": "2.4.1", "dev": true, @@ -8122,7 +8104,6 @@ "@google-cloud/text-to-speech": "^5.0.1", "async": "^3.0.0", "debug": "^3.1.0", - "fs-extra": "^2.0.0", "sax": "^1.2.4", "sentence-splitter": "^5.0.0", "tempfile": "^1.1.1", diff --git a/packages/tts-lib/lib/cleanup.js b/packages/tts-lib/lib/cleanup.js index 5f61ec1..706de55 100644 --- a/packages/tts-lib/lib/cleanup.js +++ b/packages/tts-lib/lib/cleanup.js @@ -1,18 +1,18 @@ const debug = require('debug')('cleanup') -const fs = require('fs-extra') // TODO not needed anymore? +const { readFile, rm } = require('fs/promises') /** * Deletes the manifest and its files. */ exports.cleanup = async (manifestFile) => { - const manifest = fs.readFileSync(manifestFile, 'utf8') // TODO use async + const manifest = await readFile(manifestFile, 'utf8') debug(`Manifest is ${manifest}`) const regexpState = /^file\s+'(.*)'$/gm let match while ((match = regexpState.exec(manifest)) !== null) { debug(`Deleting temporary file ${match[1]}`) - fs.removeSync(match[1]) // TODO use async + rm(match[1], { force: true, recursive: true }) } debug(`Deleting manifest file ${manifestFile}`) - fs.removeSync(manifestFile) // TODO use async + rm(manifestFile, { force: true, recursive: true }) } diff --git a/packages/tts-lib/lib/combine-parts.js b/packages/tts-lib/lib/combine-parts.js index e366777..7fff4d5 100644 --- a/packages/tts-lib/lib/combine-parts.js +++ b/packages/tts-lib/lib/combine-parts.js @@ -1,6 +1,6 @@ const spawn = require('child_process').spawn const debug = require('debug') -const fs = require('fs-extra') +const { appendFile, readFile, writeFile } = require('fs/promises') /** * Combines MP3 or OGG files into one file. @@ -37,20 +37,18 @@ exports.combineEncodedAudio = (binary, manifestFile, outputFile) => { /** * Concatenates raw PCM audio into one file. */ -exports.combineRawAudio = (manifestFile, outputFile) => { - const manifest = fs.readFileSync(manifestFile, 'utf8') +exports.combineRawAudio = async (manifestFile, outputFile) => { + const manifest = await readFile(manifestFile, 'utf8') debug('combineRawAudio')(`Manifest contains: ${manifest}`) const regexpState = /^file\s+'(.*)'$/gm debug('combineRawAudio')(`Creating file ${outputFile}`) - fs.createFileSync(outputFile) - debug('combineRawAudio')(`Truncating file ${outputFile}`) - fs.truncateSync(outputFile) + await writeFile(outputFile, '') let match while ((match = regexpState.exec(manifest)) !== null) { debug('combineRawAudio')(`Reading data from ${match[1]}`) - const dataBuffer = fs.readFileSync(match[1]) + const dataBuffer = await readFile(match[1]) debug('combineRawAudio')(`Appending data to ${outputFile}`) - fs.appendFileSync(outputFile, dataBuffer) + await appendFile(outputFile, dataBuffer) } return Promise.resolve() } diff --git a/packages/tts-lib/lib/generate-speech.js b/packages/tts-lib/lib/generate-speech.js index bc030b9..c49bdda 100644 --- a/packages/tts-lib/lib/generate-speech.js +++ b/packages/tts-lib/lib/generate-speech.js @@ -1,20 +1,20 @@ const async = require('async') const debug = require('debug') -const fs = require('fs-extra') +const { writeFile } = require('fs/promises') const tempfile = require('tempfile') /** * Writes down all the temp files for ffmpeg to read in. * Returns the text filename. */ -exports.createManifest = parts => { +exports.createManifest = async (parts) => { const txtFile = tempfile('.txt') debug('createManifest')(`Creating ${txtFile} for manifest`) const contents = parts.map(info => { return `file '${info.tempfile}'` }).join('\n') debug('createManifest')(`Writing manifest contents:\n${contents}`) - fs.writeFileSync(txtFile, contents, 'utf8') + await writeFile(txtFile, contents, 'utf8') return txtFile } diff --git a/packages/tts-lib/lib/providers/aws.js b/packages/tts-lib/lib/providers/aws.js index 73a2583..acfbac3 100644 --- a/packages/tts-lib/lib/providers/aws.js +++ b/packages/tts-lib/lib/providers/aws.js @@ -1,6 +1,6 @@ const { Polly, SynthesizeSpeechCommand } = require('@aws-sdk/client-polly') const debug = require('debug') -const fs = require('fs-extra') +const { createWriteStream } = require('fs') const tempfile = require('tempfile') const { combine } = require('../combine-parts') const { Provider } = require('../provider') @@ -81,7 +81,7 @@ class AwsProvider extends Provider { debug('generate')('Making request to Amazon Web Services') info.send(command).then(response => { debug('generate')(`Writing audio content to ${info.tempfile}`) - const fileStream = fs.createWriteStream(info.tempfile) + const fileStream = createWriteStream(info.tempfile) response.AudioStream.pipe(fileStream) fileStream.on('finish', () => { fileStream.close() diff --git a/packages/tts-lib/lib/providers/gcp.js b/packages/tts-lib/lib/providers/gcp.js index ba83898..4089b67 100644 --- a/packages/tts-lib/lib/providers/gcp.js +++ b/packages/tts-lib/lib/providers/gcp.js @@ -1,5 +1,5 @@ const debug = require('debug') -const fs = require('fs-extra') +const { writeFile } = require('fs') const path = require('path') const tempfile = require('tempfile') const GoogleClient = require('@google-cloud/text-to-speech').TextToSpeechClient @@ -98,7 +98,7 @@ class GcpProvider extends Provider { } debug('generate')(`Writing audio content to ${info.tempfile}`) - fs.writeFile(info.tempfile, response.audioContent, 'binary', err => { + writeFile(info.tempfile, response.audioContent, 'binary', err => { if (err) { debug('generate')(`Error writing: ${err.message}`) return callback(err) diff --git a/packages/tts-lib/package.json b/packages/tts-lib/package.json index 037f37c..732bfc1 100644 --- a/packages/tts-lib/package.json +++ b/packages/tts-lib/package.json @@ -32,7 +32,6 @@ "@google-cloud/text-to-speech": "^5.0.1", "async": "^3.0.0", "debug": "^3.1.0", - "fs-extra": "^2.0.0", "sax": "^1.2.4", "sentence-splitter": "^5.0.0", "tempfile": "^1.1.1", diff --git a/packages/tts-lib/test/cleanup.spec.js b/packages/tts-lib/test/cleanup.spec.js index 1a63088..1943282 100644 --- a/packages/tts-lib/test/cleanup.spec.js +++ b/packages/tts-lib/test/cleanup.spec.js @@ -8,25 +8,25 @@ describe('cleanup()', () => { let fsSpy beforeEach(() => { - fsSpy = jasmine.createSpyObj('fs', ['readFileSync', 'removeSync']) + fsSpy = jasmine.createSpyObj('fs', ['readFile', 'rm']) ;({ cleanup } = proxyquire('../lib/cleanup', { - 'fs-extra': fsSpy + 'fs/promises': fsSpy })) }) beforeEach(() => { const manifestContents = tempFilenames.map(filename => `file '${filename}'`).join('\n') - fsSpy.readFileSync.and.callFake(() => manifestContents) + fsSpy.readFile.and.callFake(() => Promise.resolve(manifestContents)) return cleanup(manifestFilename) }) it('should delete the manifest file', () => { - expect(fsSpy.removeSync).toHaveBeenCalledWith(manifestFilename) + expect(fsSpy.rm).toHaveBeenCalledWith(manifestFilename, { force: true, recursive: true }) }) it('should delete the temporary audio files', () => { tempFilenames.forEach(filename => { - expect(fsSpy.removeSync).toHaveBeenCalledWith(filename) + expect(fsSpy.rm).toHaveBeenCalledWith(filename, { force: true, recursive: true }) }) }) }) diff --git a/packages/tts-lib/test/combine-raw-audio.spec.js b/packages/tts-lib/test/combine-raw-audio.spec.js index 053cb10..672d27d 100644 --- a/packages/tts-lib/test/combine-raw-audio.spec.js +++ b/packages/tts-lib/test/combine-raw-audio.spec.js @@ -10,31 +10,29 @@ describe('combineRawAudio()', () => { beforeEach(() => { fsSpy = jasmine.createSpyObj('fs', [ - 'appendFileSync', - 'createFileSync', - 'readFileSync', - 'truncateSync' + 'appendFile', + 'readFile', + 'writeFile' ]) ;({ combineRawAudio } = proxyquire('../lib/combine-parts', { - 'fs-extra': fsSpy + 'fs/promises': fsSpy })) }) beforeEach(done => { const manifestContents = tempFilenames.map(filename => `file '${filename}'`).join('\n') - fsSpy.readFileSync.and.callFake(() => manifestContents) + fsSpy.readFile.and.callFake(() => Promise.resolve(manifestContents)) combineRawAudio(manifestFilename, outputFilename).then(done) }) it('should create the output file and truncate it', () => { - expect(fsSpy.createFileSync).toHaveBeenCalledWith(outputFilename) - expect(fsSpy.truncateSync).toHaveBeenCalledWith(outputFilename) + expect(fsSpy.writeFile).toHaveBeenCalledWith(outputFilename, '') }) it('should read and append each file from the manifest', () => { tempFilenames.forEach(filename => { - expect(fsSpy.readFileSync).toHaveBeenCalledWith(filename) + expect(fsSpy.readFile).toHaveBeenCalledWith(filename) }) - expect(fsSpy.appendFileSync.calls.count()).toBe(tempFilenames.length) + expect(fsSpy.appendFile.calls.count()).toBe(tempFilenames.length) }) }) diff --git a/packages/tts-lib/test/create-manifest.spec.js b/packages/tts-lib/test/create-manifest.spec.js index 20b2bca..e31b838 100644 --- a/packages/tts-lib/test/create-manifest.spec.js +++ b/packages/tts-lib/test/create-manifest.spec.js @@ -11,15 +11,15 @@ describe('createManifest()', () => { let outputFilename, fileContents, options, lines, response beforeEach(() => { - fsSpy = jasmine.createSpyObj('fs', ['writeFileSync']) + fsSpy = jasmine.createSpyObj('fs', ['writeFile']) ;({ createManifest } = proxyquire('../lib/generate-speech', { - 'fs-extra': fsSpy + 'fs/promises': fsSpy })) }) - beforeEach(() => { - response = createManifest(testParts); - [outputFilename, fileContents, options] = fsSpy.writeFileSync.calls.mostRecent().args + beforeEach(async () => { + response = await createManifest(testParts); + [outputFilename, fileContents, options] = fsSpy.writeFile.calls.mostRecent().args lines = fileContents.split('\n') }) diff --git a/packages/tts-lib/test/provider.spec.js b/packages/tts-lib/test/provider.spec.js index faf8efe..e2928d9 100644 --- a/packages/tts-lib/test/provider.spec.js +++ b/packages/tts-lib/test/provider.spec.js @@ -16,7 +16,7 @@ describe('provider', () => { beforeEach(() => { combineStub = jasmine.createSpy('combine').and.returnValue(manifestFile) - createManifestStub = jasmine.createSpy('createManifest').and.returnValue(manifestFile) + createManifestStub = jasmine.createSpy('createManifest').and.returnValue(Promise.resolve(manifestFile)) generateAllStub = jasmine.createSpy('generateAll').and.returnValue(Promise.resolve(parts)) splitTextStub = jasmine.createSpy('generateAll').and.returnValue(chunks) ;({ Provider } = proxyquire('../lib/provider', { diff --git a/packages/tts-lib/test/providers/aws.spec.js b/packages/tts-lib/test/providers/aws.spec.js index c81fa32..c893c9c 100644 --- a/packages/tts-lib/test/providers/aws.spec.js +++ b/packages/tts-lib/test/providers/aws.spec.js @@ -22,7 +22,7 @@ describe('AWS provider', () => { }) splitTextStub = jasmine.createSpy('splitText').and.returnValue(chunks) ;({ AwsProvider } = proxyquire('../../lib/providers/aws', { - 'fs-extra': fsSpy, + fs: fsSpy, '../combine-parts': { combine: combineStub }, diff --git a/packages/tts-lib/test/providers/gcp.spec.js b/packages/tts-lib/test/providers/gcp.spec.js index 7a7f986..5729d66 100644 --- a/packages/tts-lib/test/providers/gcp.spec.js +++ b/packages/tts-lib/test/providers/gcp.spec.js @@ -19,7 +19,7 @@ describe('GCP provider', () => { fsStub.writeFile.and.callFake((dest, data, opts, callback) => { callback() }) splitTextStub = jasmine.createSpy('splitText').and.returnValue(chunks) ;({ GcpProvider } = proxyquire('../../lib/providers/gcp', { - 'fs-extra': fsStub, + fs: fsStub, '../combine-parts': { combine: combineStub }, From 2e239d514c9bb14cd3a57722d4a3d75a74b2844a Mon Sep 17 00:00:00 2001 From: Eric Heikes Date: Sat, 24 May 2025 21:32:25 -0700 Subject: [PATCH 13/48] refactor(read-text): Replace callbacks/promises/events with async+await --- packages/tts-cli/lib/read-text.js | 41 ++++++-------- packages/tts-cli/test/read-text.spec.js | 71 ++++++++++++------------- 2 files changed, 51 insertions(+), 61 deletions(-) diff --git a/packages/tts-cli/lib/read-text.js b/packages/tts-cli/lib/read-text.js index 6b6aad9..b5b85c4 100644 --- a/packages/tts-cli/lib/read-text.js +++ b/packages/tts-cli/lib/read-text.js @@ -6,30 +6,21 @@ const { readFile } = require('fs/promises') * If no file is specified, read from stdin. */ exports.readText = async (inputFilename, proc) => { - return new Promise((resolve, reject) => { - if (inputFilename) { - // Read from a file. - debug(`Reading from ${inputFilename}`) - readFile(inputFilename, 'utf8').then((data) => { - debug(`Finished reading (${data.length} bytes)`) - resolve(data) - }).catch((err) => { - reject(err) - }) - } else { - // Read from stdin. - debug('Reading from stdin') - let data = '' - proc.stdin.setEncoding('utf8') - proc.stdin.on('readable', () => { - const chunk = proc.stdin.read() - /* istanbul ignore else: need to add test for this */ - if (chunk !== null) { data += chunk } - }) - proc.stdin.on('end', () => { - debug(`Finished reading (${data.length} bytes)`) - resolve(data) - }) + if (inputFilename) { + // Read from a file. + debug(`Reading from ${inputFilename}`) + const data = await readFile(inputFilename, 'utf8') + debug(`Finished reading (${data.length} bytes)`) + return data + } else { + // Read from stdin. + debug('Reading from stdin') + proc.stdin.setEncoding('utf8') + let data = '' + for await (const chunk of proc.stdin) { + data += chunk } - }) + debug(`Finished reading (${data.length} bytes)`) + return data + } } diff --git a/packages/tts-cli/test/read-text.spec.js b/packages/tts-cli/test/read-text.spec.js index 76217c3..a4e45ce 100644 --- a/packages/tts-cli/test/read-text.spec.js +++ b/packages/tts-cli/test/read-text.spec.js @@ -15,36 +15,34 @@ describe('readText()', () => { }) beforeEach(() => { - let sentData = false - stdin = jasmine.createSpyObj('stdin', ['on', 'read', 'setEncoding']) - stdin.on.and.callFake((type, callback) => { callback() }) - stdin.read.and.callFake(() => { - const response = sentData ? null : testData - sentData = true - return response - }) - }) - - describe('when it succeeds', () => { - it('should set the read text', done => { - readText(null, { stdin }).then((text) => { - expect(text).toBe(testData) - }).then(done) - }) + stdin = { + [Symbol.asyncIterator]() { + let allDone = false + return { + next() { + const done = allDone + const value = done ? undefined : testData + allDone = true + return Promise.resolve({ value, done }) + }, + return() { + return { done: true } + } + } + }, + setEncoding: jasmine.createSpy('setEncoding') + } }) describe('when no filename is specified', () => { - it('should read data from stdin', done => { - readText(null, { stdin }).then(() => { - expect(stdin.on).toHaveBeenCalled() - expect(stdin.read).toHaveBeenCalled() - }).then(done) + it('should read data from stdin', async () => { + const text = await readText(null, { stdin }) + expect(text).toBe(testData) }) - it('should use UTF-8 encoding', done => { - readText(null, { stdin }).then(() => { - expect(stdin.setEncoding).toHaveBeenCalledWith('utf8') - }).then(done) + it('should use UTF-8 encoding', async () => { + await readText(null, { stdin }) + expect(stdin.setEncoding).toHaveBeenCalledWith('utf8') }) }) @@ -55,27 +53,28 @@ describe('readText()', () => { fsSpy.readFile.and.callFake((filename, opts) => Promise.resolve(testData)) }) - it('should read data from the file', done => { - readText(testFilename, { stdin }).then(() => { - expect(fsSpy.readFile).toHaveBeenCalledWith(testFilename, 'utf8') - }).then(done) + it('should read data from the file', async () => { + await readText(testFilename, { stdin }) + expect(fsSpy.readFile).toHaveBeenCalledWith(testFilename, 'utf8') }) describe('and can read the file', () => { - it('should set the file\'s data', done => { - readText(testFilename, { stdin }).then((text) => { - expect(text).toBe(testData) - }).then(done) + it('should set the file\'s data', async () => { + const text = await readText(testFilename, { stdin }) + expect(text).toBe(testData) }) }) describe('and cannot read the file', () => { - it('should reject with the error', done => { + it('should reject with the error', async () => { const testError = 'error object' fsSpy.readFile.and.callFake((filename, opts) => Promise.reject(testError)) - readText(testFilename, { stdin }).catch(err => { + try { + await readText(testFilename, { stdin }) + throw new Error('Expected readText to throw an error') + } catch (err) { expect(err).toBe(testError) - }).then(done) + } }) }) }) From 87b38c2461f8c6cd4fa15393f9ca6ffb5120b744 Mon Sep 17 00:00:00 2001 From: Eric Heikes Date: Sat, 24 May 2025 22:13:02 -0700 Subject: [PATCH 14/48] refactor(split-text): Convert promises to async+await --- packages/tts-lib/lib/split-text.js | 19 +++--- packages/tts-lib/test/split-text.spec.js | 84 +++++++++++------------- 2 files changed, 47 insertions(+), 56 deletions(-) diff --git a/packages/tts-lib/lib/split-text.js b/packages/tts-lib/lib/split-text.js index eb40186..8ed4dab 100644 --- a/packages/tts-lib/lib/split-text.js +++ b/packages/tts-lib/lib/split-text.js @@ -97,16 +97,15 @@ const chunkXml = (xml, maxCharacterCount) => { /** * Splits a string of text into chunks. */ -exports.splitText = (text, maxCharacterCount, type = 'text') => { +exports.splitText = async (text, maxCharacterCount, type = 'text') => { const chunker = type === 'ssml' ? chunkXml : chunkText - return chunker(text, maxCharacterCount).then(parts => { - debug('splitText')('Stripping whitespace') - return parts.map(str => { - // Compress whitespace. - return str.replace(/\s+/g, ' ') - }).map(str => { - // Trim whitespace from the ends. - return str.trim() - }) + const parts = await chunker(text, maxCharacterCount) + debug('splitText')('Stripping whitespace') + return parts.map(str => { + // Compress whitespace. + return str.replace(/\s+/g, ' ') + }).map(str => { + // Trim whitespace from the ends. + return str.trim() }) } diff --git a/packages/tts-lib/test/split-text.spec.js b/packages/tts-lib/test/split-text.spec.js index 2040379..6bedf75 100644 --- a/packages/tts-lib/test/split-text.spec.js +++ b/packages/tts-lib/test/split-text.spec.js @@ -4,72 +4,64 @@ describe('splitText()', () => { const maxChars = 1000 const testData = 'hello world' - it('should split the text into an array of parts', done => { - splitText(testData, maxChars).then((parts) => { - expect(parts).toEqual([testData]) - }).then(done) + it('should split the text into an array of parts', async () => { + const parts = await splitText(testData, maxChars) + expect(parts).toEqual([testData]) }) - it('should split the text by the given number of characters', done => { - splitText(testData, 2).then((parts) => { - expect(parts).toEqual(['he', 'll', 'o', 'wo', 'rl', 'd']) - }).then(done) + it('should split the text by the given number of characters', async () => { + const parts = await splitText(testData, 2) + expect(parts).toEqual(['he', 'll', 'o', 'wo', 'rl', 'd']) }) - it('should propagate SSML tags through the chunks', done => { + it('should propagate SSML tags through the chunks', async () => { const text = 'Hello there world' - splitText(text, 6, 'ssml').then((parts) => { - expect(parts).toEqual([ - 'Hello', - 'there', - 'world' - ]) - }).then(done) + const parts = await splitText(text, 6, 'ssml') + expect(parts).toEqual([ + 'Hello', + 'there', + 'world' + ]) }) - it('should work when SSML tags are duplicated in sequence', done => { + it('should work when SSML tags are duplicated in sequence', async () => { const text = '

Section 1

Introduction

' - splitText(text, 1500, 'ssml').then((parts) => { - expect(parts).toEqual([ - '

Section 1

', - '

Introduction

' - ]) - }).then(done) + const parts = await splitText(text, 1500, 'ssml') + expect(parts).toEqual([ + '

Section 1

', + '

Introduction

' + ]) }) - it('should NOT propagate SSML tags for non-SSML text', done => { + it('should NOT propagate SSML tags for non-SSML text', async () => { const text = 'Hello there world' - splitText(text, 6).then((parts) => { - expect(parts).toEqual([ - 'Hello', - 'there', - 'world<', - '/speak', - '>' - ]) - }).then(done) + const parts = await splitText(text, 6) + expect(parts).toEqual([ + 'Hello', + 'there', + 'world<', + '/speak', + '>' + ]) }) - it('should condense whitespace', done => { + it('should condense whitespace', async () => { const text = 'hello world' - splitText(text, maxChars).then((parts) => { - expect(parts).toEqual(['hello world']) - }).then(done) + const parts = await splitText(text, maxChars) + expect(parts).toEqual(['hello world']) }) - it('should trim whitespace from the ends', done => { + it('should trim whitespace from the ends', async () => { const text = ' hello world ' - splitText(text, maxChars).then((parts) => { - expect(parts).toEqual(['hello world']) - }).then(done) + const parts = await splitText(text, maxChars) + expect(parts).toEqual(['hello world']) }) describe('when type is undefined', () => { - it('should still work', () => { - return splitText(testData, maxChars, undefined).then((parts) => { - expect(parts).toEqual([testData]) - }) + it('should still work', async () => { + const parts = await splitText(testData, maxChars, undefined) + expect(parts).toEqual([testData]) }) }) }) From cd6d8f2a6bb324abe8f7b5672bdf7bbf13052f98 Mon Sep 17 00:00:00 2001 From: Eric Heikes Date: Sat, 24 May 2025 22:20:57 -0700 Subject: [PATCH 15/48] test(combine-encoded-audio): Convert promises to async+await --- .../tts-lib/test/combine-encoded-audio.spec.js | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/packages/tts-lib/test/combine-encoded-audio.spec.js b/packages/tts-lib/test/combine-encoded-audio.spec.js index 58a6202..b9f84ab 100644 --- a/packages/tts-lib/test/combine-encoded-audio.spec.js +++ b/packages/tts-lib/test/combine-encoded-audio.spec.js @@ -73,10 +73,13 @@ describe('combineEncodedAudio()', () => { }) }) - it('should return a rejected promise with an error', done => { - combineEncodedAudio(binary, manifestFilename, tempFilename).catch(err => { + it('should return a rejected promise with an error', async () => { + try { + await combineEncodedAudio(binary, manifestFilename, tempFilename) + throw new Error('Expected combineEncodedAudio to throw an error') + } catch (err) { expect(err.message).toMatch('Could not start ffmpeg process') - }).then(done) + } }) }) @@ -93,11 +96,14 @@ describe('combineEncodedAudio()', () => { }) }) - it('should return a rejected promise with the stderr output', done => { - combineEncodedAudio(binary, manifestFilename, tempFilename).catch(err => { + it('should return a rejected promise with the stderr output', async () => { + try { + await combineEncodedAudio(binary, manifestFilename, tempFilename) + throw new Error('Expected combineEncodedAudio to throw an error') + } catch (err) { expect(err.message).toMatch(`(${errorCode})`) expect(err.message).toMatch(`(${errorOutput})`) - }).then(done) + } }) }) }) From 5edbe4b1fab91c3ac17940cd34676036aa7e7424 Mon Sep 17 00:00:00 2001 From: Eric Heikes Date: Sat, 24 May 2025 22:22:03 -0700 Subject: [PATCH 16/48] test(combine-raw-audio): Convert promises to async+await --- packages/tts-lib/test/combine-raw-audio.spec.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/tts-lib/test/combine-raw-audio.spec.js b/packages/tts-lib/test/combine-raw-audio.spec.js index 672d27d..ed98dc8 100644 --- a/packages/tts-lib/test/combine-raw-audio.spec.js +++ b/packages/tts-lib/test/combine-raw-audio.spec.js @@ -19,10 +19,10 @@ describe('combineRawAudio()', () => { })) }) - beforeEach(done => { + beforeEach(async () => { const manifestContents = tempFilenames.map(filename => `file '${filename}'`).join('\n') fsSpy.readFile.and.callFake(() => Promise.resolve(manifestContents)) - combineRawAudio(manifestFilename, outputFilename).then(done) + await combineRawAudio(manifestFilename, outputFilename) }) it('should create the output file and truncate it', () => { From 20c6c869fa055eac37c7d3176170a82a5be99820 Mon Sep 17 00:00:00 2001 From: Eric Heikes Date: Sat, 24 May 2025 22:27:16 -0700 Subject: [PATCH 17/48] test(combine): Convert promises to async+await --- packages/tts-lib/test/combine.spec.js | 21 +++++++++------------ 1 file changed, 9 insertions(+), 12 deletions(-) diff --git a/packages/tts-lib/test/combine.spec.js b/packages/tts-lib/test/combine.spec.js index 5e43299..f8a7c48 100644 --- a/packages/tts-lib/test/combine.spec.js +++ b/packages/tts-lib/test/combine.spec.js @@ -13,26 +13,23 @@ describe('combine()', () => { }) describe('when the format is encoded audio', () => { - it('should call combineEncodedAudio()', () => { - return combine(testManifest, tempfile(), 'encoded', 'ffmpeg-test').then(() => { - expect(lib.combineEncodedAudio).toHaveBeenCalledWith('ffmpeg-test', 'manifest.txt', jasmine.any(String)) - }) + it('should call combineEncodedAudio()', async () => { + await combine(testManifest, tempfile(), 'encoded', 'ffmpeg-test') + expect(lib.combineEncodedAudio).toHaveBeenCalledWith('ffmpeg-test', 'manifest.txt', jasmine.any(String)) }) }) describe('when the format is raw', () => { - it('should call combineRawAudio()', () => { - return combine(testManifest, tempfile(), 'raw').then(() => { - expect(lib.combineRawAudio).toHaveBeenCalledWith('manifest.txt', jasmine.any(String)) - }) + it('should call combineRawAudio()', async () => { + await combine(testManifest, tempfile(), 'raw') + expect(lib.combineRawAudio).toHaveBeenCalledWith('manifest.txt', jasmine.any(String)) }) }) describe('default format', () => { - it('should call combineEncodedAudio()', () => { - return combine(testManifest, tempfile()).then(() => { - expect(lib.combineEncodedAudio).toHaveBeenCalledWith('ffmpeg', 'manifest.txt', jasmine.any(String)) - }) + it('should call combineEncodedAudio()', async () => { + await combine(testManifest, tempfile()) + expect(lib.combineEncodedAudio).toHaveBeenCalledWith('ffmpeg', 'manifest.txt', jasmine.any(String)) }) }) From 66debe81688352dcbb42fbb6317d069883eadce5 Mon Sep 17 00:00:00 2001 From: Eric Heikes Date: Sat, 24 May 2025 22:32:19 -0700 Subject: [PATCH 18/48] test(generate-all): Convert promises to async+await --- packages/tts-lib/test/generate-all.spec.js | 55 +++++++++++----------- 1 file changed, 28 insertions(+), 27 deletions(-) diff --git a/packages/tts-lib/test/generate-all.spec.js b/packages/tts-lib/test/generate-all.spec.js index c568429..9de5010 100644 --- a/packages/tts-lib/test/generate-all.spec.js +++ b/packages/tts-lib/test/generate-all.spec.js @@ -22,30 +22,30 @@ describe('generateAll()', () => { })) }) - it('should asynchronously call the function for each of the parts', done => { - generateAll(textParts, testLimit, iteratorFunction, task).then(() => { - const [parts] = asyncSpy.eachOfLimit.calls.mostRecent().args - expect(parts).toEqual(textParts) - expect(parts.length).toBe(textParts.length) - expect(iteratorFunction.calls.count()).toBe(textParts.length) - }).then(done) + it('should asynchronously call the function for each of the parts', async () => { + await generateAll(textParts, testLimit, iteratorFunction, task) + const [parts] = asyncSpy.eachOfLimit.calls.mostRecent().args + expect(parts).toEqual(textParts) + expect(parts.length).toBe(textParts.length) + expect(iteratorFunction.calls.count()).toBe(textParts.length) }) - it('should limit the async calls according to the option', done => { - generateAll(textParts, testLimit, iteratorFunction, task).then(() => { - const [, limit] = asyncSpy.eachOfLimit.calls.mostRecent().args - expect(limit).toBe(testLimit) - }).then(done) + it('should limit the async calls according to the option', async () => { + await generateAll(textParts, testLimit, iteratorFunction, task) + const [, limit] = asyncSpy.eachOfLimit.calls.mostRecent().args + expect(limit).toBe(testLimit) }) describe('initial spinner', () => { - beforeEach(done => { + beforeEach(async () => { asyncSpy.eachOfLimit.and.callFake((parts, opts, func, callback) => { callback(new Error('reject async')) }) - generateAll(textParts, 1, iteratorFunction, task).catch(() => { - done() - }) + try { + await generateAll(textParts, 1, iteratorFunction, task) + } catch (_err) { + // Ignore the error. + } }) it('should be updated', () => { @@ -62,16 +62,14 @@ describe('generateAll()', () => { }) describe('when all requests succeed', () => { - it('should respond with the original parts', done => { - generateAll(textParts, testLimit, iteratorFunction, task).then(response => { - expect(response).toEqual(textParts) - }).then(done) + it('should respond with the original parts', async () => { + const response = await generateAll(textParts, testLimit, iteratorFunction, task) + expect(response).toEqual(textParts) }) - it('should show the final count', done => { - generateAll(textParts, testLimit, iteratorFunction, task).then(() => { - expect(task.title).toMatch(`\\(${textParts.length}/`) - }).then(done) + it('should show the final count', async () => { + await generateAll(textParts, testLimit, iteratorFunction, task) + expect(task.title).toMatch(`\\(${textParts.length}/`) }) }) @@ -84,10 +82,13 @@ describe('generateAll()', () => { }) }) - it('should return a rejected promise with the error', done => { - generateAll(textParts, testLimit, iteratorFunction, task).catch(err => { + it('should return a rejected promise with the error', async () => { + try { + await generateAll(textParts, testLimit, iteratorFunction, task) + throw new Error('Expected generateAll to throw an error') + } catch (err) { expect(err.message).toBe(testError) - }).then(done) + } }) }) }) From d72b410864f0c88b720ae8aa72ecda083a6e9c63 Mon Sep 17 00:00:00 2001 From: Eric Heikes Date: Sat, 24 May 2025 22:39:17 -0700 Subject: [PATCH 19/48] test(gcp): Convert promise to async+await --- packages/tts-lib/test/providers/gcp.spec.js | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/packages/tts-lib/test/providers/gcp.spec.js b/packages/tts-lib/test/providers/gcp.spec.js index 5729d66..fcedf60 100644 --- a/packages/tts-lib/test/providers/gcp.spec.js +++ b/packages/tts-lib/test/providers/gcp.spec.js @@ -78,10 +78,9 @@ describe('GCP provider', () => { expect(provider.instance.auth.jsonContent.private_key).toBe('private key') }) - it('should use the project ID from the options', () => { - provider.instance.getProjectId().then(id => { - expect(id).toBe('project ID') - }) + it('should use the project ID from the options', async () => { + const id = await provider.instance.getProjectId() + expect(id).toBe('project ID') }) it('should leave out the project file if not specified', () => { From b9493ef714516c9e6683e6a90152e15a4047db02 Mon Sep 17 00:00:00 2001 From: Eric Heikes Date: Mon, 26 May 2025 11:17:49 -0700 Subject: [PATCH 20/48] feat(generate-speech): Replace promises & callbacks with async+await --- packages/tts-lib/lib/generate-speech.js | 27 ++++++++-------------- packages/tts-lib/test/generate-all.spec.js | 8 +++---- 2 files changed, 14 insertions(+), 21 deletions(-) diff --git a/packages/tts-lib/lib/generate-speech.js b/packages/tts-lib/lib/generate-speech.js index c49bdda..fb98f69 100644 --- a/packages/tts-lib/lib/generate-speech.js +++ b/packages/tts-lib/lib/generate-speech.js @@ -21,23 +21,16 @@ exports.createManifest = async (parts) => { /** * Calls the API for each text part (throttled). Returns a Promise. */ -exports.generateAll = (parts, limit, func, task) => { +exports.generateAll = async (parts, limit, func, task) => { const count = parts.length task.title = `Convert to audio (0/${count})` - return (new Promise((resolve, reject) => { - debug('generateAll')(`Requesting ${count} audio segments, ${limit} at a time`) - async.eachOfLimit( - parts, - limit, - func, - err => { - debug('generateAll')(`Requested all parts, with error ${err}`) - if (err) { - return reject(err) - } - task.title = task.title.replace(/\d+\//, `${count}/`) - resolve(parts) - } - ) - })) + debug('generateAll')(`Requesting ${count} audio segments, ${limit} at a time`) + try { + await async.eachOfLimit(parts, limit, func) + } catch (err) { + debug('generateAll')(`Requested all parts, with error ${err}`) + throw err + } + task.title = task.title.replace(/\d+\//, `${count}/`) + return parts } diff --git a/packages/tts-lib/test/generate-all.spec.js b/packages/tts-lib/test/generate-all.spec.js index 9de5010..bd85b43 100644 --- a/packages/tts-lib/test/generate-all.spec.js +++ b/packages/tts-lib/test/generate-all.spec.js @@ -38,8 +38,8 @@ describe('generateAll()', () => { describe('initial spinner', () => { beforeEach(async () => { - asyncSpy.eachOfLimit.and.callFake((parts, opts, func, callback) => { - callback(new Error('reject async')) + asyncSpy.eachOfLimit.and.callFake((parts, opts, func) => { + throw new Error('reject async') }) try { await generateAll(textParts, 1, iteratorFunction, task) @@ -77,8 +77,8 @@ describe('generateAll()', () => { const testError = 'test error' beforeEach(() => { - iteratorFunction.and.callFake((data, i, callback) => { - callback(new Error(testError)) + iteratorFunction.and.callFake((data, i) => { + throw new Error(testError) }) }) From de9076cb24e1e4fff61cfd2afa9f3bbdc96f7361 Mon Sep 17 00:00:00 2001 From: Eric Heikes Date: Mon, 26 May 2025 11:23:26 -0700 Subject: [PATCH 21/48] fix(cli): Fix createProvider import --- packages/tts-cli/test/cli.spec.js | 2 +- packages/tts-cli/tts.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/tts-cli/test/cli.spec.js b/packages/tts-cli/test/cli.spec.js index 7298db0..8caf3ce 100644 --- a/packages/tts-cli/test/cli.spec.js +++ b/packages/tts-cli/test/cli.spec.js @@ -37,7 +37,7 @@ describe('CLI', () => { }, minimist, '../tts-lib/lib/cleanup': { cleanup }, - '../tts-lib/lib/provider': { + '../tts-lib/lib/create-provider': { createProvider }, './lib/read-text': { readText } diff --git a/packages/tts-cli/tts.js b/packages/tts-cli/tts.js index f788ae8..d8b655e 100755 --- a/packages/tts-cli/tts.js +++ b/packages/tts-cli/tts.js @@ -5,7 +5,7 @@ */ const debug = require('debug')('tts-cli') const { readFileSync} = require('fs') -const { createProvider } = require('../tts-lib/lib/provider') +const { createProvider } = require('../tts-lib/lib/create-provider') const { cleanup } = require('../tts-lib/lib/cleanup') const { checkUsage } = require('./lib/check-usage') From 30fc089976775c7d8ebee30e7ef7160863d25205 Mon Sep 17 00:00:00 2001 From: Eric Heikes Date: Mon, 26 May 2025 11:31:11 -0700 Subject: [PATCH 22/48] fix(provider): Include opts in the info object --- packages/tts-lib/lib/provider.js | 1 + packages/tts-lib/lib/providers/aws.js | 1 + packages/tts-lib/lib/providers/gcp.js | 1 + packages/tts-lib/test/provider.spec.js | 3 +++ packages/tts-lib/test/providers/aws.spec.js | 1 + packages/tts-lib/test/providers/gcp.spec.js | 1 + 6 files changed, 8 insertions(+) diff --git a/packages/tts-lib/lib/provider.js b/packages/tts-lib/lib/provider.js index ed2bafa..9fae808 100644 --- a/packages/tts-lib/lib/provider.js +++ b/packages/tts-lib/lib/provider.js @@ -22,6 +22,7 @@ class Provider { */ buildInfo (text, task) { return { + opts: this.opts, task, tempfile: tempfile(`.${this.extensionFor(this.opts.format)}`), text diff --git a/packages/tts-lib/lib/providers/aws.js b/packages/tts-lib/lib/providers/aws.js index acfbac3..f74fe89 100644 --- a/packages/tts-lib/lib/providers/aws.js +++ b/packages/tts-lib/lib/providers/aws.js @@ -35,6 +35,7 @@ class AwsProvider extends Provider { */ buildInfo (text, task) { return { + opts: this.opts, task, tempfile: tempfile(`.${this.extensionFor(this.opts.format)}`), text, diff --git a/packages/tts-lib/lib/providers/gcp.js b/packages/tts-lib/lib/providers/gcp.js index 4089b67..3423a0d 100644 --- a/packages/tts-lib/lib/providers/gcp.js +++ b/packages/tts-lib/lib/providers/gcp.js @@ -39,6 +39,7 @@ class GcpProvider extends Provider { */ buildInfo = function (text, task) { return { + opts: this.opts, task, tempfile: tempfile(`.${this.extensionFor(this.opts.format)}`), text, diff --git a/packages/tts-lib/test/provider.spec.js b/packages/tts-lib/test/provider.spec.js index e2928d9..f5f206c 100644 --- a/packages/tts-lib/test/provider.spec.js +++ b/packages/tts-lib/test/provider.spec.js @@ -60,6 +60,7 @@ describe('provider', () => { const task = {} const result = childProvider.buildInfo(text, task) expect(result).toEqual({ + opts: { ffmpeg: 'ffmpeg-test', limit: 10 }, task, tempfile: jasmine.any(String), text @@ -103,10 +104,12 @@ describe('provider', () => { const task = {} await childProvider.generateSpeech(chunks, task) expect(generateAllStub).toHaveBeenCalledWith([{ + opts: jasmine.any(Object), task, tempfile: jasmine.any(String), text: chunks[0] }, { + opts: jasmine.any(Object), task, tempfile: jasmine.any(String), text: chunks[1] diff --git a/packages/tts-lib/test/providers/aws.spec.js b/packages/tts-lib/test/providers/aws.spec.js index c893c9c..ccb3629 100644 --- a/packages/tts-lib/test/providers/aws.spec.js +++ b/packages/tts-lib/test/providers/aws.spec.js @@ -153,6 +153,7 @@ describe('AWS provider', () => { const task = { foo: 1, bar: 2 } const info = provider.buildInfo('hello world', task) expect(info).toEqual({ + opts: provider.opts, task, tempfile: jasmine.any(String), text: 'hello world', diff --git a/packages/tts-lib/test/providers/gcp.spec.js b/packages/tts-lib/test/providers/gcp.spec.js index fcedf60..30c8d9a 100644 --- a/packages/tts-lib/test/providers/gcp.spec.js +++ b/packages/tts-lib/test/providers/gcp.spec.js @@ -150,6 +150,7 @@ describe('GCP provider', () => { const task = { foo: 1, bar: 2 } const info = provider.buildInfo('hello world', task) expect(info).toEqual({ + opts: provider.opts, task, tempfile: jasmine.any(String), text: 'hello world', From cbfc6d47e236a1b07948f7cf57a252ea9ce559bb Mon Sep 17 00:00:00 2001 From: Eric Heikes Date: Mon, 26 May 2025 11:36:53 -0700 Subject: [PATCH 23/48] fix(generate-speech): Prevent regexp DoS --- packages/tts-lib/lib/generate-speech.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/tts-lib/lib/generate-speech.js b/packages/tts-lib/lib/generate-speech.js index fb98f69..1acd2e0 100644 --- a/packages/tts-lib/lib/generate-speech.js +++ b/packages/tts-lib/lib/generate-speech.js @@ -31,6 +31,9 @@ exports.generateAll = async (parts, limit, func, task) => { debug('generateAll')(`Requested all parts, with error ${err}`) throw err } - task.title = task.title.replace(/\d+\//, `${count}/`) + /* istanbul ignore else: not a real-life scenario */ + if (task.title.length < 1000) { // prevent regexp DoS + task.title = task.title.replace(/\d+\//, `${count}/`) + } return parts } From 43b45087dc3aecae726f0b0722c589053c836c77 Mon Sep 17 00:00:00 2001 From: Eric Heikes Date: Mon, 26 May 2025 21:56:12 -0700 Subject: [PATCH 24/48] feat(provider): Convert generate() to async --- packages/tts-lib/lib/provider.js | 2 +- packages/tts-lib/lib/providers/aws.js | 34 +-- packages/tts-lib/lib/providers/gcp.js | 33 +-- packages/tts-lib/test/provider.spec.js | 11 +- packages/tts-lib/test/providers/aws.spec.js | 186 +++++++-------- packages/tts-lib/test/providers/gcp.spec.js | 240 +++++++++----------- 6 files changed, 228 insertions(+), 278 deletions(-) diff --git a/packages/tts-lib/lib/provider.js b/packages/tts-lib/lib/provider.js index 9fae808..2e91258 100644 --- a/packages/tts-lib/lib/provider.js +++ b/packages/tts-lib/lib/provider.js @@ -44,7 +44,7 @@ class Provider { /** * This should be overridden by subclasses. */ - generate (_info, _i, _callback) { + async generate (_info, _i) { throw new Error('generate() not implemented') } diff --git a/packages/tts-lib/lib/providers/aws.js b/packages/tts-lib/lib/providers/aws.js index f74fe89..caa01c8 100644 --- a/packages/tts-lib/lib/providers/aws.js +++ b/packages/tts-lib/lib/providers/aws.js @@ -1,6 +1,7 @@ const { Polly, SynthesizeSpeechCommand } = require('@aws-sdk/client-polly') const debug = require('debug') const { createWriteStream } = require('fs') +const { pipeline } = require('stream/promises') const tempfile = require('tempfile') const { combine } = require('../combine-parts') const { Provider } = require('../provider') @@ -62,7 +63,7 @@ class AwsProvider extends Provider { /** * Calls the Polly API with the given info. */ - generate (info, i, callback) { + async generate (info, i, callback) { /* istanbul ignore else: not a real-life scenario */ if (info.task.title.length < 1000) { // prevent regexp DoS info.task.title = info.task.title.replace(/\d+\//, `${i}/`) @@ -80,22 +81,23 @@ class AwsProvider extends Provider { }) debug('generate')('Making request to Amazon Web Services') - info.send(command).then(response => { - debug('generate')(`Writing audio content to ${info.tempfile}`) - const fileStream = createWriteStream(info.tempfile) - response.AudioStream.pipe(fileStream) - fileStream.on('finish', () => { - fileStream.close() - callback() - }) - fileStream.on('error', err => { - debug('generate')(`Error writing: ${err.message}`) - return callback(err) - }) - }, err => { + let response + try { + response = await info.send(command) + } catch (err) { debug('generate')(`Error during request: ${err.message}`) - return callback(err) - }) + throw err + } + + debug('generate')(`Writing audio content to ${info.tempfile}`) + try { + const fileStream = createWriteStream(info.tempfile) + await pipeline(response.AudioStream, fileStream) + fileStream.close() + } catch (err) { + debug('generate')(`Error writing: ${err.message}`) + throw err + } } } diff --git a/packages/tts-lib/lib/providers/gcp.js b/packages/tts-lib/lib/providers/gcp.js index 3423a0d..c12fe92 100644 --- a/packages/tts-lib/lib/providers/gcp.js +++ b/packages/tts-lib/lib/providers/gcp.js @@ -1,5 +1,6 @@ const debug = require('debug') -const { writeFile } = require('fs') +const { writeFile } = require('fs/promises') +const { promisify } = require('util') const path = require('path') const tempfile = require('tempfile') const GoogleClient = require('@google-cloud/text-to-speech').TextToSpeechClient @@ -61,7 +62,7 @@ class GcpProvider extends Provider { /** * Calls the Google Cloud API with the given info. */ - generate = function (info, i, callback) { + async generate (info, i) { /* istanbul ignore else: not a real-life scenario */ if (info.task.title.length < 1000) { // prevent regexp DoS info.task.title = info.task.title.replace(/\d+\//, `${i}/`) @@ -91,22 +92,22 @@ class GcpProvider extends Provider { retry: null } + let response debug('generate')('Making request to Google Cloud Platform') - info.synthesizer(request, opts, (err, response) => { - if (err) { - debug('generate')(`Error during request: ${err.message}`) - return callback(err) - } + try { + response = await promisify(info.synthesizer)(request, opts) + } catch (err) { + debug('generate')(`Error during request: ${err.message}`) + throw err + } - debug('generate')(`Writing audio content to ${info.tempfile}`) - writeFile(info.tempfile, response.audioContent, 'binary', err => { - if (err) { - debug('generate')(`Error writing: ${err.message}`) - return callback(err) - } - callback() - }) - }) + debug('generate')(`Writing audio content to ${info.tempfile}`) + try { + await writeFile(info.tempfile, response.audioContent, 'binary') + } catch (err) { + debug('generate')(`Error writing: ${err.message}`) + throw err + } } } diff --git a/packages/tts-lib/test/provider.spec.js b/packages/tts-lib/test/provider.spec.js index f5f206c..a5d33ee 100644 --- a/packages/tts-lib/test/provider.spec.js +++ b/packages/tts-lib/test/provider.spec.js @@ -92,10 +92,13 @@ describe('provider', () => { }) describe('generate()', () => { - it('should throw an error', () => { - expect(() => { - childProvider.generate() - }).toThrowError(/generate\(\) not implemented/) + it('should throw an error', async () => { + try { + await childProvider.generate() + throw new Error('generate() should ahve thrown error') + } catch (err) { + expect(err.message).toBe('generate() not implemented') + } }) }) diff --git a/packages/tts-lib/test/providers/aws.spec.js b/packages/tts-lib/test/providers/aws.spec.js index ccb3629..8dacb32 100644 --- a/packages/tts-lib/test/providers/aws.spec.js +++ b/packages/tts-lib/test/providers/aws.spec.js @@ -1,5 +1,6 @@ const { Polly } = require('@aws-sdk/client-polly') -const fs = require('fs') +const { constants: FILE, createWriteStream } = require('fs') +const { readFile, unlink } = require('fs/promises') const proxyquire = require('proxyquire') const { Readable } = require('stream') const tempfile = require('tempfile') @@ -17,7 +18,7 @@ describe('AWS provider', () => { combineStub = jasmine.createSpy('combine') fsSpy = jasmine.createSpyObj('fs', ['createWriteStream']) fsSpy.createWriteStream.and.callFake(filename => { - const stream = fs.createWriteStream(filename) + const stream = createWriteStream(filename) return stream }) splitTextStub = jasmine.createSpy('splitText').and.returnValue(chunks) @@ -203,158 +204,133 @@ describe('AWS provider', () => { } }) - afterEach(done => { - fs.access(testData.filename, fs.constants.F_OK, err => { - if (err) { return done() } - fs.unlink(testData.filename, done) - }) + afterEach(async () => { + try { + await unlink(testData.filename) + } catch (_err) { + // Ignore errors. + } }) - it('should update the task title', done => { - provider.generate(info, testData.index, () => { - expect(task.title).toMatch(`\\(${testData.index}/`) - done() - }) + it('should update the task title', async () => { + await provider.generate(info, testData.index) + expect(task.title).toMatch(`\\(${testData.index}/`) }) - it('should work with the MP3 format', done => { + it('should work with the MP3 format', async () => { testData.opts.format = 'mp3' - provider.generate(info, 0, () => { - const command = send.calls.mostRecent().args[0] - expect(command.input.OutputFormat).toBe('mp3') - done() - }) + await provider.generate(info, 0) + const command = send.calls.mostRecent().args[0] + expect(command.input.OutputFormat).toBe('mp3') }) - it('should work with the OGG format', done => { + it('should work with the OGG format', async () => { testData.opts.format = 'ogg' - provider.generate(info, 0, () => { - const command = send.calls.mostRecent().args[0] - expect(command.input.OutputFormat).toBe('ogg_vorbis') - done() - }) + await provider.generate(info, 0) + const command = send.calls.mostRecent().args[0] + expect(command.input.OutputFormat).toBe('ogg_vorbis') }) - it('should work with the PCM format', done => { + it('should work with the PCM format', async () => { testData.opts.format = 'pcm' - provider.generate(info, 0, () => { - const command = send.calls.mostRecent().args[0] - expect(command.input.OutputFormat).toBe('pcm') - done() - }) + await provider.generate(info, 0) + const command = send.calls.mostRecent().args[0] + expect(command.input.OutputFormat).toBe('pcm') }) - it('should use the given voice engine', done => { - provider.generate(info, 0, () => { - const command = send.calls.mostRecent().args[0] - expect(command.input.Engine).toBe('neural') - done() - }) + it('should use the given voice engine', async () => { + await provider.generate(info, 0) + const command = send.calls.mostRecent().args[0] + expect(command.input.Engine).toBe('neural') }) - it('should not use sample rate if not specified', done => { + it('should not use sample rate if not specified', async () => { delete info.opts.sampleRate - provider.generate(info, 0, () => { - const command = send.calls.mostRecent().args[0] - expect(command.input.SampleRate).toBeUndefined() - done() - }) + await provider.generate(info, 0) + const command = send.calls.mostRecent().args[0] + expect(command.input.SampleRate).toBeUndefined() }) - it('should use the (stringified) sample rate, when specified', done => { - provider.generate(info, 0, () => { - const command = send.calls.mostRecent().args[0] - expect(command.input.SampleRate).toBe(String(testData.opts.sampleRate)) - done() - }) + it('should use the (stringified) sample rate, when specified', async () => { + await provider.generate(info, 0) + const command = send.calls.mostRecent().args[0] + expect(command.input.SampleRate).toBe(String(testData.opts.sampleRate)) }) - it('should not use lexicon names if not specified', done => { + it('should not use lexicon names if not specified', async () => { delete info.opts.lexicon - provider.generate(info, 0, () => { - const command = send.calls.mostRecent().args[0] - expect(command.input.LexiconNames).toBeUndefined() - done() - }) + await provider.generate(info, 0) + const command = send.calls.mostRecent().args[0] + expect(command.input.LexiconNames).toBeUndefined() }) - it('should use the lexicon names, when specified', done => { - provider.generate(info, 0, () => { - const command = send.calls.mostRecent().args[0] - expect(command.input.LexiconNames).toEqual(testData.opts.lexicon) - done() - }) + it('should use the lexicon names, when specified', async () => { + await provider.generate(info, 0) + const command = send.calls.mostRecent().args[0] + expect(command.input.LexiconNames).toEqual(testData.opts.lexicon) }) - it('should use the given text type', done => { - provider.generate(info, 0, () => { - const command = send.calls.mostRecent().args[0] - expect(command.input.TextType).toBe(testData.opts.type) - done() - }) + it('should use the given text type', async () => { + await provider.generate(info, 0) + const command = send.calls.mostRecent().args[0] + expect(command.input.TextType).toBe(testData.opts.type) }) - it('should use the given text part', done => { - provider.generate(info, 0, () => { - const command = send.calls.mostRecent().args[0] - expect(command.input.Text).toBe(testData.text) - done() - }) + it('should use the given text part', async () => { + await provider.generate(info, 0) + const command = send.calls.mostRecent().args[0] + expect(command.input.Text).toBe(testData.text) }) - it('should not use a language if not specified', done => { + it('should not use a language if not specified', async () => { delete info.opts.language - provider.generate(info, 0, () => { - const command = send.calls.mostRecent().args[0] - expect(command.input.LanguageCode).toBeUndefined() - done() - }) + await provider.generate(info, 0) + const command = send.calls.mostRecent().args[0] + expect(command.input.LanguageCode).toBeUndefined() }) - it('should use the language, when specified', done => { - provider.generate(info, 0, () => { - const command = send.calls.mostRecent().args[0] - expect(command.input.LanguageCode).toBe(testData.opts.language) - done() - }) + it('should use the language, when specified', async () => { + await provider.generate(info, 0) + const command = send.calls.mostRecent().args[0] + expect(command.input.LanguageCode).toBe(testData.opts.language) }) - it('should use the given voice', done => { - provider.generate(info, 0, () => { - const command = send.calls.mostRecent().args[0] - expect(command.input.VoiceId).toBe(String(testData.opts.voice)) - done() - }) + it('should use the given voice', async () => { + await provider.generate(info, 0) + const command = send.calls.mostRecent().args[0] + expect(command.input.VoiceId).toBe(String(testData.opts.voice)) }) - it('should pipe the resulting stream into the file', done => { - provider.generate(info, 0, () => { - const contents = fs.readFileSync(testData.filename, 'utf-8') - expect(contents).toBe('testing') - done() - }) + it('should pipe the resulting stream into the file', async () => { + await provider.generate(info, 0) + const contents = await readFile(testData.filename, 'utf-8') + expect(contents).toBe('testing') }) - it('should callback with an error if send() fails', done => { + it('should callback with an error if send() fails', async () => { info.send = jasmine.createSpy('send').and.rejectWith(new Error('test error')) - provider.generate(info, 0, err => { + try { + await provider.generate(info, 0) + throw new Error('generate() should have thrown an error') + } catch (err) { expect(err).toEqual(new Error('test error')) - done() - }) + } }) - it('should callback with an error if file saving fails', done => { + it('should callback with an error if file saving fails', async () => { fsSpy.createWriteStream.and.callFake(filename => { - const stream = fs.createWriteStream(filename) + const stream = createWriteStream(filename) stream.on('pipe', () => { stream.emit('error', new Error('write stream error')) }) return stream }) - provider.generate(info, 0, err => { + try { + await provider.generate(info, 0) + throw new Error('generate() should have thrown error') + } catch (err) { expect(err).toEqual(new Error('write stream error')) - done() - }) + } }) }) }) diff --git a/packages/tts-lib/test/providers/gcp.spec.js b/packages/tts-lib/test/providers/gcp.spec.js index 30c8d9a..9baa237 100644 --- a/packages/tts-lib/test/providers/gcp.spec.js +++ b/packages/tts-lib/test/providers/gcp.spec.js @@ -1,5 +1,5 @@ const TextToSpeechClient = require('@google-cloud/text-to-speech').TextToSpeechClient -const fs = require('fs') +const { unlink, writeFile } = require('fs/promises') const path = require('path') const proxyquire = require('proxyquire') const tempfile = require('tempfile') @@ -16,10 +16,10 @@ describe('GCP provider', () => { beforeEach(() => { combineStub = jasmine.createSpy('combine') fsStub = jasmine.createSpyObj('fs', ['writeFile']) - fsStub.writeFile.and.callFake((dest, data, opts, callback) => { callback() }) + fsStub.writeFile.and.callFake((dest, data, opts) => { Promise.resolve() }) splitTextStub = jasmine.createSpy('splitText').and.returnValue(chunks) ;({ GcpProvider } = proxyquire('../../lib/providers/gcp', { - fs: fsStub, + 'fs/promises': fsStub, '../combine-parts': { combine: combineStub }, @@ -108,13 +108,13 @@ describe('GCP provider', () => { ) }) - it('should work if email and private key are not specified', () => { + it('should work if email and private key are not specified', async () => { const filename = tempfile() const fakeProject = { client_email: 'foo@example.com', private_key: 'fake key' } - fs.writeFileSync(filename, JSON.stringify(fakeProject), 'utf8') + await writeFile(filename, JSON.stringify(fakeProject), 'utf8') expect(() => { new GcpProvider({ 'project-file': filename }) // eslint-disable-line no-new }).not.toThrow() @@ -195,16 +195,17 @@ describe('GCP provider', () => { } }) - afterEach(done => { - fs.unlink(testData.filename, () => { - // ignore any errors - done() - }) + afterEach(async () => { + try { + await unlink(testData.filename) + } catch (_err) { + // Ignore any errors. + } }) - it('should call the synthesizer function', done => { - synthesizer.and.callFake(() => done()) - provider.generate(info, 0, () => {}) + it('should call the synthesizer function', async () => { + synthesizer.and.callFake((_req, _opts, done) => done(null, {})) + await provider.generate(info, 0, () => {}) expect(synthesizer).toHaveBeenCalled() }) @@ -215,161 +216,124 @@ describe('GCP provider', () => { }) }) - it('should update the task title', done => { - provider.generate(info, testData.index, () => { - expect(task.title).toMatch(`\\(${testData.index}/`) - done() - }) + it('should update the task title', async () => { + await provider.generate(info, testData.index) + expect(task.title).toMatch(`\\(${testData.index}/`) }) - it('should work with the MP3 format', done => { + it('should work with the MP3 format', async () => { testData.opts.format = 'mp3' - provider.generate(info, 0, () => { - const opts = synthesizer.calls.mostRecent().args[0] - expect(opts.audioConfig.audioEncoding).toBe('MP3') - done() - }) + await provider.generate(info, 0) + const opts = synthesizer.calls.mostRecent().args[0] + expect(opts.audioConfig.audioEncoding).toBe('MP3') }) - it('should work with the OGG format', done => { + it('should work with the OGG format', async () => { testData.opts.format = 'ogg' - provider.generate(info, 0, () => { - const opts = synthesizer.calls.mostRecent().args[0] - expect(opts.audioConfig.audioEncoding).toBe('OGG_OPUS') - done() - }) + await provider.generate(info, 0) + const opts = synthesizer.calls.mostRecent().args[0] + expect(opts.audioConfig.audioEncoding).toBe('OGG_OPUS') }) - it('should work with the PCM format', done => { + it('should work with the PCM format', async () => { testData.opts.format = 'pcm' - provider.generate(info, 0, () => { - const opts = synthesizer.calls.mostRecent().args[0] - expect(opts.audioConfig.audioEncoding).toBe('LINEAR16') - done() - }) + await provider.generate(info, 0) + const opts = synthesizer.calls.mostRecent().args[0] + expect(opts.audioConfig.audioEncoding).toBe('LINEAR16') }) - it('should not use sample rate if not specified', done => { + it('should not use sample rate if not specified', async () => { delete info.opts.sampleRate - provider.generate(info, 0, () => { - const opts = synthesizer.calls.mostRecent().args[0] - expect(opts.audioConfig.sampleRateHertz).toBeUndefined() - done() - }) + await provider.generate(info, 0) + const opts = synthesizer.calls.mostRecent().args[0] + expect(opts.audioConfig.sampleRateHertz).toBeUndefined() }) - it('should use the sample rate, when specified', done => { - provider.generate(info, 0, () => { - const opts = synthesizer.calls.mostRecent().args[0] - expect(opts.audioConfig.sampleRateHertz).toBe(testData.opts.sampleRate) - done() - }) + it('should use the sample rate, when specified', async () => { + await provider.generate(info, 0) + const opts = synthesizer.calls.mostRecent().args[0] + expect(opts.audioConfig.sampleRateHertz).toBe(testData.opts.sampleRate) }) - it('should use the given (plain) text', done => { + it('should use the given (plain) text', async () => { testData.opts.type = 'text' - provider.generate(info, 0, () => { - const opts = synthesizer.calls.mostRecent().args[0] - expect(opts.input.text).toBe(testData.text) - expect(opts.input.ssml).toBeUndefined() - done() - }) + await provider.generate(info, 0) + const opts = synthesizer.calls.mostRecent().args[0] + expect(opts.input.text).toBe(testData.text) + expect(opts.input.ssml).toBeUndefined() }) - it('should use the given (SSML) text', done => { + it('should use the given (SSML) text', async () => { testData.opts.type = 'ssml' - provider.generate(info, 0, () => { - const opts = synthesizer.calls.mostRecent().args[0] - expect(opts.input.ssml).toBe(testData.text) - expect(opts.input.text).toBeUndefined() - done() - }) + await provider.generate(info, 0) + const opts = synthesizer.calls.mostRecent().args[0] + expect(opts.input.ssml).toBe(testData.text) + expect(opts.input.text).toBeUndefined() }) - it('should not use effects if not specified', done => { + it('should not use effects if not specified', async () => { delete info.opts.effect - provider.generate(info, 0, () => { - const opts = synthesizer.calls.mostRecent().args[0] - expect(opts.audioConfig.effectsProfileId).toBeUndefined() - done() - }) + await provider.generate(info, 0) + const opts = synthesizer.calls.mostRecent().args[0] + expect(opts.audioConfig.effectsProfileId).toBeUndefined() }) - it('should use the effects, when specified', done => { - provider.generate(info, 0, () => { - const opts = synthesizer.calls.mostRecent().args[0] - expect(opts.audioConfig.effectsProfileId).toEqual(testData.opts.effect) - done() - }) + it('should use the effects, when specified', async () => { + await provider.generate(info, 0) + const opts = synthesizer.calls.mostRecent().args[0] + expect(opts.audioConfig.effectsProfileId).toEqual(testData.opts.effect) }) - it('should use the given volume gain', done => { - provider.generate(info, 0, () => { - const opts = synthesizer.calls.mostRecent().args[0] - expect(opts.audioConfig.volumeGainDb).toBe(testData.opts.gain) - done() - }) + it('should use the given volume gain', async () => { + await provider.generate(info, 0) + const opts = synthesizer.calls.mostRecent().args[0] + expect(opts.audioConfig.volumeGainDb).toBe(testData.opts.gain) }) - it('should use the given gender', done => { - provider.generate(info, 0, () => { - const opts = synthesizer.calls.mostRecent().args[0] - expect(opts.voice.ssmlGender).toBe(testData.opts.gender.toUpperCase()) - done() - }) + it('should use the given gender', async () => { + await provider.generate(info, 0) + const opts = synthesizer.calls.mostRecent().args[0] + expect(opts.voice.ssmlGender).toBe(testData.opts.gender.toUpperCase()) }) - it('should leave out gender if not specified', done => { + it('should leave out gender if not specified', async () => { delete testData.opts.gender - provider.generate(info, 0, () => { - const opts = synthesizer.calls.mostRecent().args[0] - expect(opts.voice.ssmlGender).toBeUndefined() - done() - }) + await provider.generate(info, 0) + const opts = synthesizer.calls.mostRecent().args[0] + expect(opts.voice.ssmlGender).toBeUndefined() }) - it('should use the given language', done => { - provider.generate(info, 0, () => { - const opts = synthesizer.calls.mostRecent().args[0] - expect(opts.voice.languageCode).toBe(testData.opts.language) - done() - }) + it('should use the given language', async () => { + await provider.generate(info, 0) + const opts = synthesizer.calls.mostRecent().args[0] + expect(opts.voice.languageCode).toBe(testData.opts.language) }) - it('should use the given pitch', done => { - provider.generate(info, 0, () => { - const opts = synthesizer.calls.mostRecent().args[0] - expect(opts.audioConfig.pitch).toBe(testData.opts.pitch) - done() - }) + it('should use the given pitch', async () => { + await provider.generate(info, 0) + const opts = synthesizer.calls.mostRecent().args[0] + expect(opts.audioConfig.pitch).toBe(testData.opts.pitch) }) - it('should use the given speed', done => { - provider.generate(info, 0, () => { - const opts = synthesizer.calls.mostRecent().args[0] - expect(opts.audioConfig.speakingRate).toBe(testData.opts.speed) - done() - }) + it('should use the given speed', async () => { + await provider.generate(info, 0) + const opts = synthesizer.calls.mostRecent().args[0] + expect(opts.audioConfig.speakingRate).toBe(testData.opts.speed) }) - it('should use the given voice', done => { - provider.generate(info, 0, () => { - const opts = synthesizer.calls.mostRecent().args[0] - expect(opts.voice.name).toBe(testData.opts.voice) - done() - }) + it('should use the given voice', async () => { + await provider.generate(info, 0) + const opts = synthesizer.calls.mostRecent().args[0] + expect(opts.voice.name).toBe(testData.opts.voice) }) - it('should write the GCP response to the temp file', done => { - provider.generate(info, 0, () => { - expect(fsStub.writeFile).toHaveBeenCalledWith( - testData.filename, - testData.response, - 'binary', - jasmine.any(Function) - ) - done() - }) + it('should write the GCP response to the temp file', async () => { + await provider.generate(info, 0) + expect(fsStub.writeFile).toHaveBeenCalledWith( + testData.filename, + testData.response, + 'binary' + ) }) }) @@ -380,11 +344,13 @@ describe('GCP provider', () => { }) }) - it('should call back with the error', done => { - provider.generate(info, 0, (err) => { + it('should call back with the error', async () => { + try { + await provider.generate(info, 0) + throw new Error('generate() should have thrown an error') + } catch (err) { expect(err.message).toBe('testing GCP error') - done() - }) + } }) }) @@ -393,16 +359,18 @@ describe('GCP provider', () => { synthesizer.and.callFake((req, opts, cb) => { cb(null, { audioContent: testData.response }) }) - fsStub.writeFile.and.callFake((dest, data, opts, cb) => { - cb(new Error('testing write error')) + fsStub.writeFile.and.callFake((dest, data, opts) => { + throw new Error('testing write error') }) }) - it('should call back with the error', done => { - provider.generate(info, 0, (err) => { + it('should call back with the error', async () => { + try { + await provider.generate(info, 0) + throw new Error('generate() should have thrown an error') + } catch (err) { expect(err.message).toBe('testing write error') - done() - }) + } }) }) }) From cae6ef3a64d17fd1ec03d6ef774999b08301e7a9 Mon Sep 17 00:00:00 2001 From: Eric Heikes Date: Mon, 26 May 2025 22:04:23 -0700 Subject: [PATCH 25/48] test(aws): Remove unused variable --- packages/tts-lib/test/providers/aws.spec.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/tts-lib/test/providers/aws.spec.js b/packages/tts-lib/test/providers/aws.spec.js index 8dacb32..0f47eb1 100644 --- a/packages/tts-lib/test/providers/aws.spec.js +++ b/packages/tts-lib/test/providers/aws.spec.js @@ -1,5 +1,5 @@ const { Polly } = require('@aws-sdk/client-polly') -const { constants: FILE, createWriteStream } = require('fs') +const { createWriteStream } = require('fs') const { readFile, unlink } = require('fs/promises') const proxyquire = require('proxyquire') const { Readable } = require('stream') From b13e94f1b7f4799776ae392aa9b42e4b6315eadb Mon Sep 17 00:00:00 2001 From: Eric Heikes Date: Mon, 26 May 2025 22:16:53 -0700 Subject: [PATCH 26/48] test: Fix lint errors --- packages/tts-cli/test/cli.spec.js | 2 +- packages/tts-cli/test/move-temp-file.spec.js | 2 +- packages/tts-cli/test/read-text.spec.js | 6 +++--- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/tts-cli/test/cli.spec.js b/packages/tts-cli/test/cli.spec.js index 8caf3ce..05abd0a 100644 --- a/packages/tts-cli/test/cli.spec.js +++ b/packages/tts-cli/test/cli.spec.js @@ -32,7 +32,7 @@ describe('CLI', () => { } }) mocks = { - 'fs': { + fs: { readFileSync: () => 'file contents' }, minimist, diff --git a/packages/tts-cli/test/move-temp-file.spec.js b/packages/tts-cli/test/move-temp-file.spec.js index 873af99..f03b1d3 100644 --- a/packages/tts-cli/test/move-temp-file.spec.js +++ b/packages/tts-cli/test/move-temp-file.spec.js @@ -35,7 +35,7 @@ describe('moveTempFile()', () => { try { await moveTempFile(ctx, task) throw new Error('should have thrown!') - } catch(err) { + } catch (err) { expect(err.message).toBe('test error') } }) diff --git a/packages/tts-cli/test/read-text.spec.js b/packages/tts-cli/test/read-text.spec.js index a4e45ce..b170a37 100644 --- a/packages/tts-cli/test/read-text.spec.js +++ b/packages/tts-cli/test/read-text.spec.js @@ -16,16 +16,16 @@ describe('readText()', () => { beforeEach(() => { stdin = { - [Symbol.asyncIterator]() { + [Symbol.asyncIterator] () { let allDone = false return { - next() { + next () { const done = allDone const value = done ? undefined : testData allDone = true return Promise.resolve({ value, done }) }, - return() { + return () { return { done: true } } } From ba7d7e8434d060ce51bbb1b0b4eb7287d1f1d836 Mon Sep 17 00:00:00 2001 From: Eric Heikes Date: Mon, 26 May 2025 22:17:11 -0700 Subject: [PATCH 27/48] refactor(tts): Fix lint error --- packages/tts-cli/tts.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/tts-cli/tts.js b/packages/tts-cli/tts.js index d8b655e..79587fa 100755 --- a/packages/tts-cli/tts.js +++ b/packages/tts-cli/tts.js @@ -4,7 +4,7 @@ * to convert it to an audio file. */ const debug = require('debug')('tts-cli') -const { readFileSync} = require('fs') +const { readFileSync } = require('fs') const { createProvider } = require('../tts-lib/lib/create-provider') const { cleanup } = require('../tts-lib/lib/cleanup') From 66c65325efcb443401b4414e34054ce236b05f48 Mon Sep 17 00:00:00 2001 From: Eric Heikes Date: Mon, 26 May 2025 22:27:27 -0700 Subject: [PATCH 28/48] refactor(combine): Use consistent filenames --- packages/tts-lib/index.js | 2 +- packages/tts-lib/lib/combine-encoded-audio.js | 34 ++++++++++ packages/tts-lib/lib/combine-parts.js | 68 ------------------- packages/tts-lib/lib/combine-raw-audio.js | 21 ++++++ packages/tts-lib/lib/combine.js | 17 +++++ packages/tts-lib/lib/provider.js | 2 +- packages/tts-lib/lib/providers/aws.js | 2 +- .../test/combine-encoded-audio.spec.js | 2 +- .../tts-lib/test/combine-raw-audio.spec.js | 2 +- packages/tts-lib/test/combine.spec.js | 19 ++++-- packages/tts-lib/test/provider.spec.js | 2 +- packages/tts-lib/test/providers/aws.spec.js | 2 +- packages/tts-lib/test/providers/gcp.spec.js | 2 +- 13 files changed, 92 insertions(+), 83 deletions(-) create mode 100644 packages/tts-lib/lib/combine-encoded-audio.js delete mode 100644 packages/tts-lib/lib/combine-parts.js create mode 100644 packages/tts-lib/lib/combine-raw-audio.js create mode 100644 packages/tts-lib/lib/combine.js diff --git a/packages/tts-lib/index.js b/packages/tts-lib/index.js index fc3d704..fcab9fd 100644 --- a/packages/tts-lib/index.js +++ b/packages/tts-lib/index.js @@ -1,7 +1,7 @@ const { AwsProvider } = require('./lib/providers/aws') const { GcpProvider } = require('./lib/providers/gcp') const { cleanup } = require('./lib/cleanup') -const { combine } = require('./lib/combine-parts') +const { combine } = require('./lib/combine') const { createProvider } = require('./lib/create-provider') const { createManifest, generateAll } = require('./lib/generate-speech') const { Provider } = require('./lib/provider') diff --git a/packages/tts-lib/lib/combine-encoded-audio.js b/packages/tts-lib/lib/combine-encoded-audio.js new file mode 100644 index 0000000..2d39229 --- /dev/null +++ b/packages/tts-lib/lib/combine-encoded-audio.js @@ -0,0 +1,34 @@ +const spawn = require('child_process').spawn +const debug = require('debug') + +/** + * Combines MP3 or OGG files into one file. + */ +exports.combineEncodedAudio = (binary, manifestFile, outputFile) => { + const args = [ + '-f', 'concat', + '-safe', '0', + '-i', manifestFile, + '-c', 'copy', + outputFile + ] + return new Promise((resolve, reject) => { + debug('combineEncodedAudio')(`Running ${binary} ${args.join(' ')}`) + const ffmpeg = spawn(binary, args) + let stderr = '' + ffmpeg.stderr.on('data', (data) => { + stderr += `\n${data}` + }) + ffmpeg.on('error', () => { + reject(new Error('Could not start ffmpeg process')) + }) + ffmpeg.on('close', code => { + debug('combineEncodedAudio')(stderr) + debug('combineEncodedAudio')(`ffmpeg process completed with code ${code}`) + if (code > 0) { + return reject(new Error(`ffmpeg returned an error (${code}): ${stderr}`)) + } + resolve() + }) + }) +} diff --git a/packages/tts-lib/lib/combine-parts.js b/packages/tts-lib/lib/combine-parts.js deleted file mode 100644 index 7fff4d5..0000000 --- a/packages/tts-lib/lib/combine-parts.js +++ /dev/null @@ -1,68 +0,0 @@ -const spawn = require('child_process').spawn -const debug = require('debug') -const { appendFile, readFile, writeFile } = require('fs/promises') - -/** - * Combines MP3 or OGG files into one file. - */ -exports.combineEncodedAudio = (binary, manifestFile, outputFile) => { - const args = [ - '-f', 'concat', - '-safe', '0', - '-i', manifestFile, - '-c', 'copy', - outputFile - ] - return new Promise((resolve, reject) => { - debug('combineEncodedAudio')(`Running ${binary} ${args.join(' ')}`) - const ffmpeg = spawn(binary, args) - let stderr = '' - ffmpeg.stderr.on('data', (data) => { - stderr += `\n${data}` - }) - ffmpeg.on('error', () => { - reject(new Error('Could not start ffmpeg process')) - }) - ffmpeg.on('close', code => { - debug('combineEncodedAudio')(stderr) - debug('combineEncodedAudio')(`ffmpeg process completed with code ${code}`) - if (code > 0) { - return reject(new Error(`ffmpeg returned an error (${code}): ${stderr}`)) - } - resolve() - }) - }) -} - -/** - * Concatenates raw PCM audio into one file. - */ -exports.combineRawAudio = async (manifestFile, outputFile) => { - const manifest = await readFile(manifestFile, 'utf8') - debug('combineRawAudio')(`Manifest contains: ${manifest}`) - const regexpState = /^file\s+'(.*)'$/gm - debug('combineRawAudio')(`Creating file ${outputFile}`) - await writeFile(outputFile, '') - let match - while ((match = regexpState.exec(manifest)) !== null) { - debug('combineRawAudio')(`Reading data from ${match[1]}`) - const dataBuffer = await readFile(match[1]) - debug('combineRawAudio')(`Appending data to ${outputFile}`) - await appendFile(outputFile, dataBuffer) - } - return Promise.resolve() -} - -/** - * Combines all the parts into one file. - * Resolves with the new filename. - */ -exports.combine = async (manifestFile, newFile, format = 'encoded', ffmpegBinary = 'ffmpeg') => { - debug('combine')(`Combining files into ${newFile}`) - if (format === 'raw') { - await exports.combineRawAudio(manifestFile, newFile) - } else { - await exports.combineEncodedAudio(ffmpegBinary, manifestFile, newFile) - } - return newFile -} diff --git a/packages/tts-lib/lib/combine-raw-audio.js b/packages/tts-lib/lib/combine-raw-audio.js new file mode 100644 index 0000000..11f0b27 --- /dev/null +++ b/packages/tts-lib/lib/combine-raw-audio.js @@ -0,0 +1,21 @@ +const debug = require('debug') +const { appendFile, readFile, writeFile } = require('fs/promises') + +/** + * Concatenates raw PCM audio into one file. + */ +exports.combineRawAudio = async (manifestFile, outputFile) => { + const manifest = await readFile(manifestFile, 'utf8') + debug('combineRawAudio')(`Manifest contains: ${manifest}`) + const regexpState = /^file\s+'(.*)'$/gm + debug('combineRawAudio')(`Creating file ${outputFile}`) + await writeFile(outputFile, '') + let match + while ((match = regexpState.exec(manifest)) !== null) { + debug('combineRawAudio')(`Reading data from ${match[1]}`) + const dataBuffer = await readFile(match[1]) + debug('combineRawAudio')(`Appending data to ${outputFile}`) + await appendFile(outputFile, dataBuffer) + } + return Promise.resolve() +} diff --git a/packages/tts-lib/lib/combine.js b/packages/tts-lib/lib/combine.js new file mode 100644 index 0000000..4d440ed --- /dev/null +++ b/packages/tts-lib/lib/combine.js @@ -0,0 +1,17 @@ +const debug = require('debug') +const { combineEncodedAudio } = require('./combine-encoded-audio') +const { combineRawAudio } = require('./combine-raw-audio') + +/** + * Combines all the parts into one file. + * Resolves with the new filename. + */ +exports.combine = async (manifestFile, newFile, format = 'encoded', ffmpegBinary = 'ffmpeg') => { + debug('combine')(`Combining files into ${newFile}`) + if (format === 'raw') { + await combineRawAudio(manifestFile, newFile) + } else { + await combineEncodedAudio(ffmpegBinary, manifestFile, newFile) + } + return newFile +} diff --git a/packages/tts-lib/lib/provider.js b/packages/tts-lib/lib/provider.js index 2e91258..6293790 100644 --- a/packages/tts-lib/lib/provider.js +++ b/packages/tts-lib/lib/provider.js @@ -1,5 +1,5 @@ const tempfile = require('tempfile') -const { combine } = require('./combine-parts') +const { combine } = require('./combine') const { createManifest, generateAll } = require('./generate-speech') const { splitText } = require('./split-text') diff --git a/packages/tts-lib/lib/providers/aws.js b/packages/tts-lib/lib/providers/aws.js index caa01c8..a67a726 100644 --- a/packages/tts-lib/lib/providers/aws.js +++ b/packages/tts-lib/lib/providers/aws.js @@ -3,7 +3,7 @@ const debug = require('debug') const { createWriteStream } = require('fs') const { pipeline } = require('stream/promises') const tempfile = require('tempfile') -const { combine } = require('../combine-parts') +const { combine } = require('../combine') const { Provider } = require('../provider') class AwsProvider extends Provider { diff --git a/packages/tts-lib/test/combine-encoded-audio.spec.js b/packages/tts-lib/test/combine-encoded-audio.spec.js index b9f84ab..3e30d41 100644 --- a/packages/tts-lib/test/combine-encoded-audio.spec.js +++ b/packages/tts-lib/test/combine-encoded-audio.spec.js @@ -25,7 +25,7 @@ describe('combineEncodedAudio()', () => { spawnSpy.stderr = { on: spawnStderrOn } - ;({ combineEncodedAudio } = proxyquire('../lib/combine-parts', { + ;({ combineEncodedAudio } = proxyquire('../lib/combine-encoded-audio', { child_process: { spawn: spawnSpy } diff --git a/packages/tts-lib/test/combine-raw-audio.spec.js b/packages/tts-lib/test/combine-raw-audio.spec.js index ed98dc8..8efe327 100644 --- a/packages/tts-lib/test/combine-raw-audio.spec.js +++ b/packages/tts-lib/test/combine-raw-audio.spec.js @@ -14,7 +14,7 @@ describe('combineRawAudio()', () => { 'readFile', 'writeFile' ]) - ;({ combineRawAudio } = proxyquire('../lib/combine-parts', { + ;({ combineRawAudio } = proxyquire('../lib/combine-raw-audio', { 'fs/promises': fsSpy })) }) diff --git a/packages/tts-lib/test/combine.spec.js b/packages/tts-lib/test/combine.spec.js index f8a7c48..f085459 100644 --- a/packages/tts-lib/test/combine.spec.js +++ b/packages/tts-lib/test/combine.spec.js @@ -1,35 +1,40 @@ +const proxyquire = require('proxyquire') const tempfile = require('tempfile') -const lib = require('../lib/combine-parts') describe('combine()', () => { const testManifest = 'manifest.txt' let combine + let combineEncodedAudioSpy + let combineRawAudioSpy beforeEach(() => { - combine = lib.combine - spyOn(lib, 'combineEncodedAudio') - spyOn(lib, 'combineRawAudio') + combineEncodedAudioSpy = jasmine.createSpy('combineEncodedAudio') + combineRawAudioSpy = jasmine.createSpy('combineRawAudio') + ;({ combine } = proxyquire('../lib/combine', { + './combine-encoded-audio': { combineEncodedAudio: combineEncodedAudioSpy }, + './combine-raw-audio': { combineRawAudio: combineRawAudioSpy } + })) }) describe('when the format is encoded audio', () => { it('should call combineEncodedAudio()', async () => { await combine(testManifest, tempfile(), 'encoded', 'ffmpeg-test') - expect(lib.combineEncodedAudio).toHaveBeenCalledWith('ffmpeg-test', 'manifest.txt', jasmine.any(String)) + expect(combineEncodedAudioSpy).toHaveBeenCalledWith('ffmpeg-test', 'manifest.txt', jasmine.any(String)) }) }) describe('when the format is raw', () => { it('should call combineRawAudio()', async () => { await combine(testManifest, tempfile(), 'raw') - expect(lib.combineRawAudio).toHaveBeenCalledWith('manifest.txt', jasmine.any(String)) + expect(combineRawAudioSpy).toHaveBeenCalledWith('manifest.txt', jasmine.any(String)) }) }) describe('default format', () => { it('should call combineEncodedAudio()', async () => { await combine(testManifest, tempfile()) - expect(lib.combineEncodedAudio).toHaveBeenCalledWith('ffmpeg', 'manifest.txt', jasmine.any(String)) + expect(combineEncodedAudioSpy).toHaveBeenCalledWith('ffmpeg', 'manifest.txt', jasmine.any(String)) }) }) diff --git a/packages/tts-lib/test/provider.spec.js b/packages/tts-lib/test/provider.spec.js index a5d33ee..9836f80 100644 --- a/packages/tts-lib/test/provider.spec.js +++ b/packages/tts-lib/test/provider.spec.js @@ -20,7 +20,7 @@ describe('provider', () => { generateAllStub = jasmine.createSpy('generateAll').and.returnValue(Promise.resolve(parts)) splitTextStub = jasmine.createSpy('generateAll').and.returnValue(chunks) ;({ Provider } = proxyquire('../lib/provider', { - './combine-parts': { + './combine': { combine: combineStub }, './generate-speech': { diff --git a/packages/tts-lib/test/providers/aws.spec.js b/packages/tts-lib/test/providers/aws.spec.js index 0f47eb1..d8409f8 100644 --- a/packages/tts-lib/test/providers/aws.spec.js +++ b/packages/tts-lib/test/providers/aws.spec.js @@ -24,7 +24,7 @@ describe('AWS provider', () => { splitTextStub = jasmine.createSpy('splitText').and.returnValue(chunks) ;({ AwsProvider } = proxyquire('../../lib/providers/aws', { fs: fsSpy, - '../combine-parts': { + '../combine': { combine: combineStub }, '../split-text': { diff --git a/packages/tts-lib/test/providers/gcp.spec.js b/packages/tts-lib/test/providers/gcp.spec.js index 9baa237..0394478 100644 --- a/packages/tts-lib/test/providers/gcp.spec.js +++ b/packages/tts-lib/test/providers/gcp.spec.js @@ -20,7 +20,7 @@ describe('GCP provider', () => { splitTextStub = jasmine.createSpy('splitText').and.returnValue(chunks) ;({ GcpProvider } = proxyquire('../../lib/providers/gcp', { 'fs/promises': fsStub, - '../combine-parts': { + '../combine': { combine: combineStub }, '../split-text': { From d9332f89347e1b43e3c8398ce82ee475e2cc3948 Mon Sep 17 00:00:00 2001 From: Eric Heikes Date: Mon, 26 May 2025 22:32:53 -0700 Subject: [PATCH 29/48] refactor(create-manifest): Use consistent filenames --- packages/tts-lib/index.js | 3 ++- packages/tts-lib/lib/create-manifest.js | 18 ++++++++++++++++++ packages/tts-lib/lib/generate-speech.js | 17 ----------------- packages/tts-lib/test/create-manifest.spec.js | 2 +- 4 files changed, 21 insertions(+), 19 deletions(-) create mode 100644 packages/tts-lib/lib/create-manifest.js diff --git a/packages/tts-lib/index.js b/packages/tts-lib/index.js index fcab9fd..3787fb4 100644 --- a/packages/tts-lib/index.js +++ b/packages/tts-lib/index.js @@ -2,8 +2,9 @@ const { AwsProvider } = require('./lib/providers/aws') const { GcpProvider } = require('./lib/providers/gcp') const { cleanup } = require('./lib/cleanup') const { combine } = require('./lib/combine') +const { createManifest } = require('./lib/create-manifest') const { createProvider } = require('./lib/create-provider') -const { createManifest, generateAll } = require('./lib/generate-speech') +const { generateAll } = require('./lib/generate-speech') const { Provider } = require('./lib/provider') const { splitText } = require('./lib/split-text') diff --git a/packages/tts-lib/lib/create-manifest.js b/packages/tts-lib/lib/create-manifest.js new file mode 100644 index 0000000..91270c8 --- /dev/null +++ b/packages/tts-lib/lib/create-manifest.js @@ -0,0 +1,18 @@ +const debug = require('debug') +const { writeFile } = require('fs/promises') +const tempfile = require('tempfile') + +/** + * Writes down all the temp files for ffmpeg to read in. + * Returns the text filename. + */ +exports.createManifest = async (parts) => { + const txtFile = tempfile('.txt') + debug('createManifest')(`Creating ${txtFile} for manifest`) + const contents = parts.map(info => { + return `file '${info.tempfile}'` + }).join('\n') + debug('createManifest')(`Writing manifest contents:\n${contents}`) + await writeFile(txtFile, contents, 'utf8') + return txtFile +} diff --git a/packages/tts-lib/lib/generate-speech.js b/packages/tts-lib/lib/generate-speech.js index 1acd2e0..173f1f0 100644 --- a/packages/tts-lib/lib/generate-speech.js +++ b/packages/tts-lib/lib/generate-speech.js @@ -1,22 +1,5 @@ const async = require('async') const debug = require('debug') -const { writeFile } = require('fs/promises') -const tempfile = require('tempfile') - -/** - * Writes down all the temp files for ffmpeg to read in. - * Returns the text filename. - */ -exports.createManifest = async (parts) => { - const txtFile = tempfile('.txt') - debug('createManifest')(`Creating ${txtFile} for manifest`) - const contents = parts.map(info => { - return `file '${info.tempfile}'` - }).join('\n') - debug('createManifest')(`Writing manifest contents:\n${contents}`) - await writeFile(txtFile, contents, 'utf8') - return txtFile -} /** * Calls the API for each text part (throttled). Returns a Promise. diff --git a/packages/tts-lib/test/create-manifest.spec.js b/packages/tts-lib/test/create-manifest.spec.js index e31b838..14513be 100644 --- a/packages/tts-lib/test/create-manifest.spec.js +++ b/packages/tts-lib/test/create-manifest.spec.js @@ -12,7 +12,7 @@ describe('createManifest()', () => { beforeEach(() => { fsSpy = jasmine.createSpyObj('fs', ['writeFile']) - ;({ createManifest } = proxyquire('../lib/generate-speech', { + ;({ createManifest } = proxyquire('../lib/create-manifest', { 'fs/promises': fsSpy })) }) From 2cc345dc93359736b995aea9800326e25e1cc915 Mon Sep 17 00:00:00 2001 From: Eric Heikes Date: Mon, 26 May 2025 22:35:40 -0700 Subject: [PATCH 30/48] refactor(generate-all): Use consistent filenames --- packages/tts-lib/index.js | 2 +- packages/tts-lib/lib/{generate-speech.js => generate-all.js} | 0 packages/tts-lib/lib/provider.js | 2 +- packages/tts-lib/test/generate-all.spec.js | 2 +- packages/tts-lib/test/provider.spec.js | 2 +- 5 files changed, 4 insertions(+), 4 deletions(-) rename packages/tts-lib/lib/{generate-speech.js => generate-all.js} (100%) diff --git a/packages/tts-lib/index.js b/packages/tts-lib/index.js index 3787fb4..991e899 100644 --- a/packages/tts-lib/index.js +++ b/packages/tts-lib/index.js @@ -4,7 +4,7 @@ const { cleanup } = require('./lib/cleanup') const { combine } = require('./lib/combine') const { createManifest } = require('./lib/create-manifest') const { createProvider } = require('./lib/create-provider') -const { generateAll } = require('./lib/generate-speech') +const { generateAll } = require('./lib/generate-all') const { Provider } = require('./lib/provider') const { splitText } = require('./lib/split-text') diff --git a/packages/tts-lib/lib/generate-speech.js b/packages/tts-lib/lib/generate-all.js similarity index 100% rename from packages/tts-lib/lib/generate-speech.js rename to packages/tts-lib/lib/generate-all.js diff --git a/packages/tts-lib/lib/provider.js b/packages/tts-lib/lib/provider.js index 6293790..9d44c40 100644 --- a/packages/tts-lib/lib/provider.js +++ b/packages/tts-lib/lib/provider.js @@ -1,6 +1,6 @@ const tempfile = require('tempfile') const { combine } = require('./combine') -const { createManifest, generateAll } = require('./generate-speech') +const { createManifest, generateAll } = require('./generate-all') const { splitText } = require('./split-text') /** diff --git a/packages/tts-lib/test/generate-all.spec.js b/packages/tts-lib/test/generate-all.spec.js index bd85b43..f43d93c 100644 --- a/packages/tts-lib/test/generate-all.spec.js +++ b/packages/tts-lib/test/generate-all.spec.js @@ -17,7 +17,7 @@ describe('generateAll()', () => { task = { title: '' } asyncSpy = jasmine.createSpyObj('async', ['eachOfLimit']) asyncSpy.eachOfLimit.and.callFake(async.eachOfLimit) - ;({ generateAll } = proxyquire('../lib/generate-speech', { + ;({ generateAll } = proxyquire('../lib/generate-all', { async: asyncSpy })) }) diff --git a/packages/tts-lib/test/provider.spec.js b/packages/tts-lib/test/provider.spec.js index 9836f80..3d56239 100644 --- a/packages/tts-lib/test/provider.spec.js +++ b/packages/tts-lib/test/provider.spec.js @@ -23,7 +23,7 @@ describe('provider', () => { './combine': { combine: combineStub }, - './generate-speech': { + './generate-all': { createManifest: createManifestStub, generateAll: generateAllStub }, From 3f53e4505301e63e414dc94bfc91be17e44bf298 Mon Sep 17 00:00:00 2001 From: Eric Heikes Date: Mon, 26 May 2025 22:39:25 -0700 Subject: [PATCH 31/48] refactor(tts): Simplify imports --- packages/tts-cli/test/cli.spec.js | 5 +---- packages/tts-cli/tts.js | 3 +-- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/packages/tts-cli/test/cli.spec.js b/packages/tts-cli/test/cli.spec.js index 05abd0a..6ff7b83 100644 --- a/packages/tts-cli/test/cli.spec.js +++ b/packages/tts-cli/test/cli.spec.js @@ -36,10 +36,7 @@ describe('CLI', () => { readFileSync: () => 'file contents' }, minimist, - '../tts-lib/lib/cleanup': { cleanup }, - '../tts-lib/lib/create-provider': { - createProvider - }, + '../tts-lib': { cleanup, createProvider }, './lib/read-text': { readText } } cli = proxyquire('../tts', mocks) diff --git a/packages/tts-cli/tts.js b/packages/tts-cli/tts.js index 79587fa..a4512ec 100755 --- a/packages/tts-cli/tts.js +++ b/packages/tts-cli/tts.js @@ -5,8 +5,7 @@ */ const debug = require('debug')('tts-cli') const { readFileSync } = require('fs') -const { createProvider } = require('../tts-lib/lib/create-provider') -const { cleanup } = require('../tts-lib/lib/cleanup') +const { cleanup, createProvider } = require('../tts-lib') const { checkUsage } = require('./lib/check-usage') const { moveTempFile } = require('./lib/move-temp-file') From ef041f0e030a6048b187c291d08b55e5b6dca06d Mon Sep 17 00:00:00 2001 From: Eric Heikes Date: Mon, 26 May 2025 22:42:58 -0700 Subject: [PATCH 32/48] fix(provider): Fix imports following renaming --- packages/tts-lib/lib/provider.js | 3 ++- packages/tts-lib/test/provider.spec.js | 4 +++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/tts-lib/lib/provider.js b/packages/tts-lib/lib/provider.js index 9d44c40..ee3c59a 100644 --- a/packages/tts-lib/lib/provider.js +++ b/packages/tts-lib/lib/provider.js @@ -1,6 +1,7 @@ const tempfile = require('tempfile') const { combine } = require('./combine') -const { createManifest, generateAll } = require('./generate-all') +const { createManifest } = require('./create-manifest') +const { generateAll } = require('./generate-all') const { splitText } = require('./split-text') /** diff --git a/packages/tts-lib/test/provider.spec.js b/packages/tts-lib/test/provider.spec.js index 3d56239..e53a621 100644 --- a/packages/tts-lib/test/provider.spec.js +++ b/packages/tts-lib/test/provider.spec.js @@ -23,8 +23,10 @@ describe('provider', () => { './combine': { combine: combineStub }, + './create-manifest': { + createManifest: createManifestStub + }, './generate-all': { - createManifest: createManifestStub, generateAll: generateAllStub }, './split-text': { From af8a32f771d548111bff61c9edf21d48d1da274a Mon Sep 17 00:00:00 2001 From: Eric Heikes Date: Fri, 13 Jun 2025 23:29:26 -0700 Subject: [PATCH 33/48] feat(tts): Use commander for CLI parser --- package-lock.json | 13 ++- packages/tts-cli/lib/check-usage.js | 53 --------- packages/tts-cli/lib/program.js | 123 +++++++++++++++++++++ packages/tts-cli/package.json | 4 +- packages/tts-cli/test/check-usage.spec.js | 59 ---------- packages/tts-cli/test/cli.spec.js | 127 +++++++++------------- packages/tts-cli/test/program.spec.js | 43 ++++++++ packages/tts-cli/tts.js | 52 +-------- packages/tts-lib/lib/provider.js | 12 +- packages/tts-lib/test/provider.spec.js | 20 +++- 10 files changed, 261 insertions(+), 245 deletions(-) delete mode 100644 packages/tts-cli/lib/check-usage.js create mode 100644 packages/tts-cli/lib/program.js delete mode 100644 packages/tts-cli/test/check-usage.spec.js create mode 100644 packages/tts-cli/test/program.spec.js diff --git a/package-lock.json b/package-lock.json index b5a4af4..412bf4c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2673,6 +2673,15 @@ "node": ">= 0.8" } }, + "node_modules/commander": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.0.tgz", + "integrity": "sha512-2uM9rYjPvyq39NwLRqaiLtWHyDC1FvryJDa2ATTVims5YAS4PupsEQsDvP14FqhFr0P49CYDugi59xaxJlTXRA==", + "license": "MIT", + "engines": { + "node": ">=20" + } + }, "node_modules/commondir": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz", @@ -8061,9 +8070,9 @@ "version": "5.4.0", "license": "Apache-2.0", "dependencies": { + "commander": "^14.0.0", "debug": "^3.1.0", - "listr2": "^8.0.2", - "minimist": "^1.2.0" + "listr2": "^8.0.2" }, "bin": { "tts": "tts.js" diff --git a/packages/tts-cli/lib/check-usage.js b/packages/tts-cli/lib/check-usage.js deleted file mode 100644 index 4716ea4..0000000 --- a/packages/tts-cli/lib/check-usage.js +++ /dev/null @@ -1,53 +0,0 @@ -const path = require('path') - -/** - * Checks if the CLI was called with valid arguments. - */ -exports.checkUsage = (args, proc) => { - const minNumArgs = 1 - const script = path.basename(proc.argv[1]) - const usageStatement = `Converts a text file to speech using AWS Polly or Google Cloud Text-to-Speech. -Usage: - ${script} [INPUTFILE] OUTPUTFILE [OPTIONS] -Standard: - INPUTFILE The text file to convert (reads from stdin if not specified) - OUTPUTFILE The filename to save the audio to -Options: - --help Displays this info and exits - --access-key KEY AWS access key ID - --effect ID Apply an audio effect profile. Can be specified multiple times. - --email EMAIL GCP client email address (required if "private-key" or - "private-key-file" is used) - --engine ENGINE AWS voice engine - --ffmpeg BINARY Path to the ffmpeg binary (defaults to the one in PATH) - --format FORMAT Target audio format ("mp3", "ogg", or "pcm") (default "mp3") - --gain GAIN Volume gain, where 0.0 is normal gain - --gender GENDER Gender of the voice ("male", "female", or "neutral") - --language LANG Code for the desired language (default "en-US" for GCP, - no default for AWS) - --lexicon NAME Apply a stored pronunciation lexicon. Can be specified - multiple times. - --pitch PITCH Change in speaking pich, in semitones - --private-key KEY GCP private key - --private-key-file FILE GCP private key file (".pem" or ".p12" file) - --project-file FILE GCP ".json" file with project info - --project-id ID GCP project ID - --region REGION AWS region to send requests to (default "us-east-1") - --sample-rate RATE Audio frequency, in hertz. - --secret-key KEY AWS secret access key - --service TYPE Cloud service to use ("aws" or "gcp") (default "aws") - --speed RATE Speaking rate, where 1.0 is normal speed - --throttle SIZE Number of simultaneous requests allowed against the API - (default 5) - --type TYPE Type of input text ("text" or "ssml") (default "text") - --voice VOICE Voice to use for the speech (default "Joanna" for AWS) -` - if (args.help) { - proc.stderr.write(usageStatement) - proc.exit(0) - } - if (args._.length < minNumArgs) { - proc.stderr.write(usageStatement) - proc.exit(1) - } -} diff --git a/packages/tts-cli/lib/program.js b/packages/tts-cli/lib/program.js new file mode 100644 index 0000000..162aa3c --- /dev/null +++ b/packages/tts-cli/lib/program.js @@ -0,0 +1,123 @@ +const { Command, Option } = require('commander') + +const packageInfo = require('../package.json') + +const parseInteger = (val) => { + return parseInt(val, 10) +} + +const program = new Command() +program.description('Converts a text file to speech using AWS Polly or Google Cloud Text-to-Speech.') +program.version(packageInfo.version, '-v, --version', 'Output the current version') +program.helpOption('-h, --help', 'Display help for command') +program.argument('FILES...', 'The text file to convert (optional, reads from stdin if not specified), and the filename to save the audio to') +program.action((args, _opts, commander) => { + if (args.length > 2) { + console.error('error: too many arguments, expected 1 or 2') + process.exitCode = 1 + commander.help() // will exit the program + } +}) +program.addOption( + new Option('--access-key ', 'AWS access key ID') + .implies({ service: 'aws' }) +) +program.addOption( + new Option('--effect ', 'Apply an audio effect profile. Can be specified multiple times.') + .implies({ service: 'gcp' }) +) +program.addOption( + new Option('--email ', 'GCP client email address (required if "private-key" or "private-key-file" is used)') + .implies({ service: 'gcp' }) +) +program.addOption( + new Option('--engine ', 'AWS voice engine') + .choices(['standard', 'neural', 'generative', 'long-form']) + .implies({ service: 'aws' }) +) +program.addOption( + new Option('--ffmpeg ', 'Path to the ffmpeg binary (defaults to the one in PATH)') + .default('ffmpeg') +) +program.addOption( + new Option('--format ', 'Target audio format') + .choices(['mp3', 'ogg', 'pcm']) + .default('mp3') +) +program.addOption( + new Option('--gain ', 'Volume gain, where 0.0 is normal gain') + .argParser(parseFloat) + .implies({ service: 'gcp' }) +) +program.addOption( + new Option('--gender ', 'Gender of the voice') + .choices(['male', 'female', 'neutral']) + .implies({ service: 'gcp' }) +) +program.addOption( + new Option('--language ', 'Code for the desired language (default "en-US" for GCP, no default for AWS)') + .implies({ service: 'gcp' }) +) +program.addOption( + new Option('--lexicon ', 'Apply a stored pronunciation lexicon. Can be specified multiple times.') + .implies({ service: 'aws' }) +) +program.addOption( + new Option('--pitch ', 'Change in speaking pich, in semitones') + .argParser(parseFloat) + .implies({ service: 'gcp' }) +) +program.addOption( + new Option('--private-key ', 'GCP private key') + .implies({ service: 'gcp' }) +) +program.addOption( + new Option('--private-key-file ', 'GCP private key file (".pem" or ".p12" file)') + .implies({ service: 'gcp' }) +) +program.addOption( + new Option('--project-file ', 'GCP ".json" file with project info') + .implies({ service: 'gcp' }) +) +program.addOption( + new Option('--project-id ', 'GCP project ID') + .implies({ service: 'gcp' }) +) +program.addOption( + new Option('--region ', 'AWS region to send requests to') + .default('us-east-1') + .implies({ service: 'aws' }) +) +program.addOption( + new Option('--sample-rate ', 'Audio frequency, in hertz.') + .argParser(parseFloat) +) +program.addOption( + new Option('--secret-key ', 'AWS secret access key') + .implies({ service: 'aws' }) +) +program.addOption( + new Option('--service ', 'Cloud service to use ("aws" or "gcp")') + .choices(['aws', 'gcp']) + .default('aws') +) +program.addOption( + new Option('--speed ', 'Speaking rate, where 1.0 is normal speed') + .argParser(parseFloat) + .implies({ service: 'gcp' }) +) +program.addOption( + new Option('--throttle ', 'Number of simultaneous requests allowed against the API') + .argParser(parseInteger) + .default(5) +) +program.addOption( + new Option('--type ', 'Type of input text') + .choices(['text', 'ssml']) + .default('text') +) +program.addOption( + new Option('--voice ', 'Voice to use for the speech (default "Joanna" for AWS)') +) + +module.exports = { program } diff --git a/packages/tts-cli/package.json b/packages/tts-cli/package.json index 14b7d3d..0aa466f 100644 --- a/packages/tts-cli/package.json +++ b/packages/tts-cli/package.json @@ -35,9 +35,9 @@ }, "homepage": "https://github.com/eheikes/tts/tree/master/packages/tts-cli#readme", "dependencies": { + "commander": "^14.0.0", "debug": "^3.1.0", - "listr2": "^8.0.2", - "minimist": "^1.2.0" + "listr2": "^8.0.2" }, "devDependencies": { "codecov": "^3.6.5", diff --git a/packages/tts-cli/test/check-usage.spec.js b/packages/tts-cli/test/check-usage.spec.js deleted file mode 100644 index 9520de3..0000000 --- a/packages/tts-cli/test/check-usage.spec.js +++ /dev/null @@ -1,59 +0,0 @@ -const { checkUsage } = require('../lib/check-usage') - -describe('checkUsage()', () => { - let proc, exit, write - - beforeEach(() => { - exit = jasmine.createSpy('process.exit') - write = jasmine.createSpy('process.stderr.write') - proc = { - argv: ['node', 'tts.js'], - exit, - stderr: { - write - } - } - }) - - describe('when --help is specified', () => { - beforeEach(() => { - checkUsage({ _: [], help: true }, proc) - }) - - it('should output the usage statement', () => { - expect(write).toHaveBeenCalled() - }) - - it('should exit without an error', () => { - expect(exit).toHaveBeenCalledWith(0) - }) - }) - - describe('when 1 argument is passed', () => { - beforeEach(() => { - checkUsage({ _: ['foo'] }, proc) - }) - - it('should NOT output the usage statement', () => { - expect(write).not.toHaveBeenCalled() - }) - - it('should NOT exit', () => { - expect(exit).not.toHaveBeenCalled() - }) - }) - - describe('when no arguments are passed', () => { - beforeEach(() => { - checkUsage({ _: [] }, proc) - }) - - it('should output the usage statement', () => { - expect(write).toHaveBeenCalled() - }) - - it('should exit with an error', () => { - expect(exit).toHaveBeenCalledWith(1) - }) - }) -}) diff --git a/packages/tts-cli/test/cli.spec.js b/packages/tts-cli/test/cli.spec.js index 6ff7b83..34d2764 100644 --- a/packages/tts-cli/test/cli.spec.js +++ b/packages/tts-cli/test/cli.spec.js @@ -8,16 +8,13 @@ describe('CLI', () => { const tempFile = 'temp file' let cli - let args let mocks - let minimist let cleanup let readText let createProvider beforeEach(() => { - args = { _: [inputFile, outputFile] } - minimist = jasmine.createSpy('minimist').and.callFake(() => args) + process.argv = ['node', 'tts.js', inputFile, outputFile] cleanup = jasmine.createSpy('cleanup') readText = jasmine.createSpy('readText').and.returnValue('test text') createProvider = jasmine.createSpy('createProvider').and.returnValue({ @@ -35,7 +32,6 @@ describe('CLI', () => { fs: { readFileSync: () => 'file contents' }, - minimist, '../tts-lib': { cleanup, createProvider }, './lib/read-text': { readText } } @@ -49,15 +45,11 @@ describe('CLI', () => { })).toBe(true) }) - it('should pass the CLI arguments to Listr', () => { - expect(cli.context.args).toBe(args) - }) - describe('when 2 arguments are given', () => { const inputFile = 'input-file' beforeEach(() => { - args = { _: [inputFile, outputFile] } + process.argv = ['node', 'tts.js', inputFile, outputFile] cli = proxyquire('../tts', mocks) }) @@ -72,7 +64,7 @@ describe('CLI', () => { describe('when only 1 argument is given', () => { beforeEach(() => { - args = { _: [outputFile] } + process.argv = ['node', 'tts.js', outputFile] cli = proxyquire('../tts', mocks) }) @@ -86,63 +78,64 @@ describe('CLI', () => { }) it('should create an AWS service', () => { - args = { _: [outputFile], service: 'aws' } + process.argv = ['node', 'tts.js', outputFile, '--service', 'aws'] cli = proxyquire('../tts', mocks) expect(cli.context.service).toBe('aws') }) it('should create a GCP service', () => { - args = { _: [outputFile], service: 'gcp' } + process.argv = ['node', 'tts.js', outputFile, '--service', 'gcp'] cli = proxyquire('../tts', mocks) expect(cli.context.service).toBe('gcp') }) it('should create a default service', () => { - args = { _: [outputFile] } + process.argv = ['node', 'tts.js', outputFile] cli = proxyquire('../tts', mocks) expect(cli.context.service).toBe('aws') }) it('should create the service with options derived from the CLI arguments', () => { - createProvider.calls.reset() - args = { - _: [outputFile], - 'access-key': 'test access key', - effect: 'test effect', - email: 'test email', - engine: 'test engine', - ffmpeg: 'test ffmpeg', - format: 'test format', - gain: '11.13', - gender: 'test gender', - language: 'test language', - lexicon: 'test lexicon', - pitch: '2.3', - 'private-key': 'test private key', - 'project-file': 'test project file', - 'project-id': 'test project id', - region: 'test region', - 'sample-rate': '16000', - 'secret-key': 'test secret key', - service: 'aws', - speed: '2.5', - throttle: '4', - type: 'test type', - voice: 'test voice' - } + process.argv = [ + 'node', + 'tts.js', + inputFile, + outputFile, + '--access-key', 'test access key', + '--effect', 'test effect', + '--email', 'test email', + '--engine', 'neural', + '--ffmpeg', 'test ffmpeg', + '--format', 'ogg', + '--gain', '11.13', + '--gender', 'female', + '--language', 'test language', + '--lexicon', 'test lexicon', + '--pitch', '2.3', + '--private-key', 'test private key', + '--project-file', 'test project file', + '--project-id', 'test project id', + '--region', 'test region', + '--sample-rate', '16000', + '--secret-key', 'test secret key', + '--service', 'aws', + '--speed', '2.5', + '--throttle', '4', + '--type', 'text', + '--voice', 'test voice' + ] cli = proxyquire('../tts', mocks) expect(createProvider).toHaveBeenCalledWith('aws', { accessKey: 'test access key', effect: ['test effect'], email: 'test email', - engine: 'test engine', + engine: 'neural', ffmpeg: 'test ffmpeg', - format: 'test format', + format: 'ogg', gain: 11.13, - gender: 'test gender', + gender: 'female', language: 'test language', lexicon: ['test lexicon'], - limit: 4, pitch: 2.3, privateKey: 'test private key', projectFile: 'test project file', @@ -150,51 +143,29 @@ describe('CLI', () => { region: 'test region', sampleRate: 16000, secretKey: 'test secret key', + service: 'aws', speed: 2.5, - type: 'test type', + throttle: 4, + type: 'text', voice: 'test voice' }) }) it('should create the service with default options', () => { - createProvider.calls.reset() - args = { _: [outputFile] } + process.argv = ['node', 'tts.js', inputFile, outputFile] cli = proxyquire('../tts', mocks) expect(createProvider).toHaveBeenCalledWith('aws', { - accessKey: undefined, - effect: undefined, - email: undefined, - engine: undefined, ffmpeg: 'ffmpeg', format: 'mp3', - gain: undefined, - gender: undefined, - language: undefined, - lexicon: undefined, - limit: 5, - pitch: undefined, - privateKey: undefined, - projectFile: undefined, - projectId: undefined, region: 'us-east-1', - sampleRate: undefined, - secretKey: undefined, - speed: undefined, - type: 'text', - voice: undefined + service: 'aws', + throttle: 5, + type: 'text' }) }) - it('should use private key in private key file', () => { - createProvider.calls.reset() - args = { _: [outputFile], 'private-key-file': 'foobar' } - cli = proxyquire('../tts', mocks) - const opts = createProvider.calls.mostRecent().args[1] - expect(opts.privateKey).toBe('file contents') - }) - it('should call readText() in the text-reading task', async () => { - args = { _: [outputFile] } + process.argv = ['node', 'tts.js', outputFile] cli = proxyquire('../tts', mocks) const context = {} await cli.tasks[0].task(context) @@ -202,7 +173,7 @@ describe('CLI', () => { }) it('should call splitText() in the text-splitting task', async () => { - args = { _: [outputFile] } + process.argv = ['node', 'tts.js', outputFile] cli = proxyquire('../tts', mocks) const context = { text: 'test text' } await cli.tasks[1].task(context) @@ -210,7 +181,7 @@ describe('CLI', () => { }) it('should call generateSpeech() in the speech-generation task', async () => { - args = { _: [outputFile] } + process.argv = ['node', 'tts.js', outputFile] cli = proxyquire('../tts', mocks) const context = { parts } await cli.tasks[2].task(context, {}) @@ -218,7 +189,7 @@ describe('CLI', () => { }) it('should call combineAudio() in the combine-audio task', async () => { - args = { _: [outputFile] } + process.argv = ['node', 'tts.js', outputFile] cli = proxyquire('../tts', mocks) const context = { manifestFile } await cli.tasks[3].task(context) @@ -226,7 +197,7 @@ describe('CLI', () => { }) it('should call cleanup() in the cleanup task', async () => { - args = { _: [outputFile] } + process.argv = ['node', 'tts.js', outputFile] cli = proxyquire('../tts', mocks) const context = { manifestFile } await cli.tasks[4].task(context) diff --git a/packages/tts-cli/test/program.spec.js b/packages/tts-cli/test/program.spec.js new file mode 100644 index 0000000..e9b89c2 --- /dev/null +++ b/packages/tts-cli/test/program.spec.js @@ -0,0 +1,43 @@ +const proxyquire = require('proxyquire') +const { program: originalProgram } = require('../lib/program') + +class FakeOption { + argParser () { return this } + choices () { return this } + default () { return this } + implies () { return this } +} + +describe('program', () => { + it('should be a Commander instance', () => { + expect(originalProgram).toBeDefined() + expect(originalProgram.args).toEqual(jasmine.any(Object)) + expect(originalProgram.opts).toEqual(jasmine.any(Function)) + expect(originalProgram.parse).toEqual(jasmine.any(Function)) + }) + + it('should exit with an error if there are >2 arguments', () => { + spyOn(console, 'error') + const commanderSpy = jasmine.createSpyObj('commander', ['help']) + let actionFunc + const commandSpy = jasmine.createSpy('Command').and.returnValue({ + description: () => {}, + version: () => {}, + helpOption: () => {}, + argument: () => {}, + action: (func) => { actionFunc = func }, + addOption: () => {} + }) + proxyquire('../lib/program', { + commander: { + Command: commandSpy, + Option: FakeOption + } + }) + actionFunc(['file1.txt', 'file2.txt', 'file3.txt'], {}, commanderSpy) + + expect(console.error).toHaveBeenCalledWith('error: too many arguments, expected 1 or 2') + expect(commanderSpy.help).toHaveBeenCalled() + expect(process.exitCode).toBe(1) + }) +}) diff --git a/packages/tts-cli/tts.js b/packages/tts-cli/tts.js index a4512ec..91e1d2e 100755 --- a/packages/tts-cli/tts.js +++ b/packages/tts-cli/tts.js @@ -4,18 +4,15 @@ * to convert it to an audio file. */ const debug = require('debug')('tts-cli') -const { readFileSync } = require('fs') const { cleanup, createProvider } = require('../tts-lib') -const { checkUsage } = require('./lib/check-usage') +const { program } = require('./lib/program') const { moveTempFile } = require('./lib/move-temp-file') const { readText } = require('./lib/read-text') const { sanitizeOpts } = require('./lib/sanitize-opts') -const args = require('minimist')(process.argv.slice(2)) -debug('called with arguments', JSON.stringify(sanitizeOpts(args))) - -let [input, outputFilename] = args._ +program.parse() +let [input, outputFilename] = program.args // If only 1 argument was given, use that for the output filename. if (!outputFilename) { @@ -25,48 +22,12 @@ if (!outputFilename) { debug('input:', input) debug('output:', outputFilename) -// Check the usage. -checkUsage(args, process) - // Set the options. -const opts = Object.assign({}, { - accessKey: args['access-key'], - effect: args.effect, - email: args.email, - engine: args.engine, - ffmpeg: args.ffmpeg || 'ffmpeg', - format: args.format || 'mp3', - gain: args.gain ? parseFloat(args.gain) : undefined, - gender: args.gender, - language: args.language, - lexicon: args.lexicon, - limit: Number(args.throttle) || 5, // eslint-disable-line no-magic-numbers - pitch: args.pitch ? parseFloat(args.pitch) : undefined, - privateKey: args['private-key'], - projectFile: args['project-file'], - projectId: args['project-id'], - region: args.region || 'us-east-1', - sampleRate: args['sample-rate'] ? Number(args['sample-rate']) : undefined, - secretKey: args['secret-key'], - speed: args.speed ? parseFloat(args.speed) : undefined, - type: args.type || 'text', - voice: args.voice -}) -if (typeof opts.effect !== 'undefined' && !Array.isArray(opts.effect)) { - opts.effect = [opts.effect] -} -if (typeof opts.lexicon !== 'undefined' && !Array.isArray(opts.lexicon)) { - opts.lexicon = [opts.lexicon] -} -if (args['private-key-file']) { - debug(`Reading private key from ${args['private-key-file']}`) - opts.privateKey = readFileSync(args['private-key-file'], 'utf8') -} +const opts = program.opts() debug(`Options: ${JSON.stringify(sanitizeOpts(opts))}`) // Create the service provider. -const service = args.service || 'aws' -const provider = createProvider(service, opts) +const provider = createProvider(opts.service, opts) // Define the tasks and options. const tasks = [{ @@ -99,10 +60,9 @@ const tasks = [{ task: moveTempFile }] const context = { - args, input, // only used for testing outputFilename, - service + service: opts.service // only used for testing } // Run the tasks. diff --git a/packages/tts-lib/lib/provider.js b/packages/tts-lib/lib/provider.js index ee3c59a..59eb70d 100644 --- a/packages/tts-lib/lib/provider.js +++ b/packages/tts-lib/lib/provider.js @@ -1,3 +1,5 @@ +const debug = require('debug')('provider') +const { readFileSync } = require('fs') const tempfile = require('tempfile') const { combine } = require('./combine') const { createManifest } = require('./create-manifest') @@ -13,9 +15,17 @@ class Provider { this.name = '[Base Provider]' this.maxCharacterCount = 1500 this.opts = opts + + // Prevent instantiation of the abstract class. if (this.constructor === Provider) { throw new Error('Can\'t instantiate abstract class') } + + // Load the private key from a file if specified. + if (this.opts.privateKeyFile) { + debug(`Reading private key from ${this.opts.privateKeyFile}`) + this.opts.privateKey = readFileSync(this.opts.privateKeyFile, 'utf8') + } } /** @@ -55,7 +65,7 @@ class Provider { async generateSpeech (strParts, task) { // Compile the text parts and options together in a packet. const parts = strParts.map(part => this.buildInfo(part, task)) - const updatedParts = await generateAll(parts, this.opts.limit, this.generate.bind(this), task) + const updatedParts = await generateAll(parts, this.opts.throttle, this.generate.bind(this), task) return createManifest(updatedParts) } diff --git a/packages/tts-lib/test/provider.spec.js b/packages/tts-lib/test/provider.spec.js index e53a621..b57a958 100644 --- a/packages/tts-lib/test/provider.spec.js +++ b/packages/tts-lib/test/provider.spec.js @@ -1,4 +1,6 @@ +const { writeFile } = require('fs/promises') const proxyquire = require('proxyquire') +const tempfile = require('tempfile') describe('provider', () => { describe('base class', () => { @@ -8,6 +10,7 @@ describe('provider', () => { const text = 'hello world' let Provider + let ChildProvider let childProvider let combineStub let createManifestStub @@ -33,14 +36,14 @@ describe('provider', () => { splitText: splitTextStub } })) - class ChildProvider extends Provider { + ChildProvider = class extends Provider { constructor (opts = {}) { super(opts) } } childProvider = new ChildProvider({ ffmpeg: 'ffmpeg-test', - limit: 10 + throttle: 10 }) }) @@ -55,6 +58,15 @@ describe('provider', () => { expect(childProvider.name).toBe('[Base Provider]') expect(childProvider.maxCharacterCount).toBe(1500) }) + + it('should read the private key if privateKeyFile is specified', async () => { + const keyFile = tempfile() + await writeFile(keyFile, 'private-key-content', 'utf8') + childProvider = new ChildProvider({ + privateKeyFile: keyFile + }) + expect(childProvider.opts.privateKey).toBe('private-key-content') + }) }) describe('buildInfo()', () => { @@ -62,7 +74,7 @@ describe('provider', () => { const task = {} const result = childProvider.buildInfo(text, task) expect(result).toEqual({ - opts: { ffmpeg: 'ffmpeg-test', limit: 10 }, + opts: { ffmpeg: 'ffmpeg-test', throttle: 10 }, task, tempfile: jasmine.any(String), text @@ -118,7 +130,7 @@ describe('provider', () => { task, tempfile: jasmine.any(String), text: chunks[1] - }], childProvider.opts.limit, jasmine.any(Function), task) + }], childProvider.opts.throttle, jasmine.any(Function), task) }) it('should call createManifest()', async () => { From a6c814280b09b3989beee2c42637bd003d065ccb Mon Sep 17 00:00:00 2001 From: Eric Heikes Date: Fri, 4 Jul 2025 10:14:00 -0700 Subject: [PATCH 34/48] feat: Use event emitter model --- packages/tts-cli/test/cli.spec.js | 96 ++++++--- packages/tts-cli/tts.js | 50 +++-- packages/tts-lib/index.js | 5 +- packages/tts-lib/lib/cleanup.js | 18 -- packages/tts-lib/lib/generate-all.js | 14 +- packages/tts-lib/lib/provider.js | 95 +++++++-- packages/tts-lib/lib/providers/aws.js | 49 ++--- packages/tts-lib/lib/providers/gcp.js | 57 ++--- packages/tts-lib/test/cleanup.spec.js | 32 --- packages/tts-lib/test/generate-all.spec.js | 52 +---- packages/tts-lib/test/index.spec.js | 2 +- packages/tts-lib/test/provider.spec.js | 177 ++++++++++++---- packages/tts-lib/test/providers/aws.spec.js | 137 ++++++------ packages/tts-lib/test/providers/gcp.spec.js | 223 ++++++++------------ 14 files changed, 531 insertions(+), 476 deletions(-) delete mode 100644 packages/tts-lib/lib/cleanup.js delete mode 100644 packages/tts-lib/test/cleanup.spec.js diff --git a/packages/tts-cli/test/cli.spec.js b/packages/tts-cli/test/cli.spec.js index 34d2764..a5f3966 100644 --- a/packages/tts-cli/test/cli.spec.js +++ b/packages/tts-cli/test/cli.spec.js @@ -1,5 +1,8 @@ +const EventEmitter = require('events') const proxyquire = require('proxyquire') +class TestEmitter extends EventEmitter {} + describe('CLI', () => { const inputFile = 'input-file' const manifestFile = 'manifest file' @@ -10,23 +13,20 @@ describe('CLI', () => { let cli let mocks let cleanup + let convert let readText let createProvider + let emitter beforeEach(() => { process.argv = ['node', 'tts.js', inputFile, outputFile] + emitter = new TestEmitter() cleanup = jasmine.createSpy('cleanup') + convert = jasmine.createSpy('convert').and.returnValue(Promise.resolve(tempFile)) readText = jasmine.createSpy('readText').and.returnValue('test text') createProvider = jasmine.createSpy('createProvider').and.returnValue({ - combineAudio: () => { - return Promise.resolve(tempFile) - }, - generateSpeech: () => { - return Promise.resolve(manifestFile) - }, - splitText: () => { - return Promise.resolve(parts) - } + convert, + events: emitter }) mocks = { fs: { @@ -165,42 +165,88 @@ describe('CLI', () => { }) it('should call readText() in the text-reading task', async () => { + process.argv = ['node', 'tts.js', outputFile] + cli = proxyquire('../tts', mocks) + await cli.tasks[0].task() + expect(readText).toHaveBeenCalledWith(null, process) + }) + + it('should call convert() in the text-reading task', async () => { + process.argv = ['node', 'tts.js', outputFile] + cli = proxyquire('../tts', mocks) + await cli.tasks[0].task() + expect(convert).toHaveBeenCalledWith('test text') + }) + + it('should listen for the "split" event in the text-splitting task', async () => { process.argv = ['node', 'tts.js', outputFile] cli = proxyquire('../tts', mocks) const context = {} - await cli.tasks[0].task(context) - expect(context.text).toBe('test text') + emitter.emit('split', { count: 6 }) + await cli.tasks[1].task(context) // will timeout with no split event }) - it('should call splitText() in the text-splitting task', async () => { + it('should set "count" in the context in the text-splitting task', async () => { process.argv = ['node', 'tts.js', outputFile] cli = proxyquire('../tts', mocks) - const context = { text: 'test text' } + const context = {} + emitter.emit('split', { count: 6 }) await cli.tasks[1].task(context) - expect(context.parts).toEqual(parts) + expect(context.count).toBe(6) + }) + + it('should listen for the "manifest" event in the speech-generation task', async () => { + process.argv = ['node', 'tts.js', outputFile] + cli = proxyquire('../tts', mocks) + const context = { count: 6 } + const task = {} + emitter.emit('manifest', { complete: 2, count: 6 }) + await cli.tasks[2].task(context, task) // will timeout with no manifest event + }) + + it('should set the initial task title in the speech-generation task', async () => { + process.argv = ['node', 'tts.js', outputFile] + cli = proxyquire('../tts', mocks) + const context = { count: 6 } + const task = {} + emitter.emit('manifest') + await cli.tasks[2].task(context, task) + expect(task.title).toBe('Convert to audio (0/6)') }) - it('should call generateSpeech() in the speech-generation task', async () => { + it('should update the task title on "generate" events', async () => { process.argv = ['node', 'tts.js', outputFile] cli = proxyquire('../tts', mocks) - const context = { parts } - await cli.tasks[2].task(context, {}) - expect(context.manifestFile).toBe(manifestFile) + const context = { count: 6 } + const task = {} + const doneWithTask = cli.tasks[2].task(context, task) + emitter.emit('generate', { complete: 2, count: 6 }) + emitter.emit('manifest') + await doneWithTask + expect(task.title).toBe('Convert to audio (2/6)') }) - it('should call combineAudio() in the combine-audio task', async () => { + it('should listen for the "save" event in the combine-audio task', async () => { process.argv = ['node', 'tts.js', outputFile] cli = proxyquire('../tts', mocks) - const context = { manifestFile } + const context = {} + emitter.emit('save', { filename: outputFile }) + await cli.tasks[3].task(context) // will timeout with no save event + }) + + it('should set "tempFile" in the context in the combine-audio task', async () => { + process.argv = ['node', 'tts.js', outputFile] + cli = proxyquire('../tts', mocks) + const context = {} + emitter.emit('save', { filename: outputFile }) await cli.tasks[3].task(context) - expect(context.tempFile).toBe(tempFile) + expect(context.tempFile).toBe(outputFile) }) - it('should call cleanup() in the cleanup task', async () => { + it('should listen for the "clean" event in the cleanup task', async () => { process.argv = ['node', 'tts.js', outputFile] cli = proxyquire('../tts', mocks) - const context = { manifestFile } - await cli.tasks[4].task(context) - expect(cleanup).toHaveBeenCalledWith(manifestFile) + emitter.emit('clean') + await cli.tasks[4].task() // will timeout with no clean event }) }) diff --git a/packages/tts-cli/tts.js b/packages/tts-cli/tts.js index 91e1d2e..e414df8 100755 --- a/packages/tts-cli/tts.js +++ b/packages/tts-cli/tts.js @@ -4,7 +4,7 @@ * to convert it to an audio file. */ const debug = require('debug')('tts-cli') -const { cleanup, createProvider } = require('../tts-lib') +const { createProvider } = require('../tts-lib') const { program } = require('./lib/program') const { moveTempFile } = require('./lib/move-temp-file') @@ -29,32 +29,52 @@ debug(`Options: ${JSON.stringify(sanitizeOpts(opts))}`) // Create the service provider. const provider = createProvider(opts.service, opts) -// Define the tasks and options. +// Define the tasks and event listeners. +const doneSplitting = new Promise((resolve) => { + debug('Listening for "split" event') + provider.events.on('split', (info) => resolve(info.count)) +}) +const doneConverting = new Promise((resolve) => { + debug('Listening for "manifest" event') + provider.events.on('manifest', resolve) +}) +const doneCombining = new Promise((resolve) => { + debug('Listening for "save" event') + provider.events.on('save', (info) => resolve(info.filename)) +}) +const doneCleaning = new Promise((resolve) => { + debug('Listening for "clean" event') + provider.events.on('clean', resolve) +}) const tasks = [{ title: 'Reading text', - task: async (ctx) => { - ctx.text = await readText(input, process) + task: async () => { + const text = await readText(input, process) + provider.convert(text) // kick off the conversion process } }, { title: 'Splitting text', - task: async (ctx) => { - ctx.parts = await provider.splitText(ctx.text) - } + task: (ctx) => doneSplitting.then((count) => { + ctx.count = count + }) }, { title: 'Convert to audio', - task: async (ctx, task) => { - ctx.manifestFile = await provider.generateSpeech(ctx.parts, task) + task: (ctx, task) => { + task.title = `Convert to audio (0/${ctx.count})` + debug('Listening for "generate" event') + provider.events.on('generate', (info) => { + task.title = `Convert to audio (${info.complete}/${info.count})` + }) + return doneConverting } }, { title: 'Combine audio', - task: async (ctx) => { - ctx.tempFile = await provider.combineAudio(ctx.manifestFile) - } + task: (ctx) => doneCombining.then((tempFile) => { + ctx.tempFile = tempFile + }) }, { title: 'Clean up', - task: async (ctx) => { - await cleanup(ctx.manifestFile) - } + task: () => doneCleaning }, { title: 'Saving file', task: moveTempFile diff --git a/packages/tts-lib/index.js b/packages/tts-lib/index.js index 991e899..428e60f 100644 --- a/packages/tts-lib/index.js +++ b/packages/tts-lib/index.js @@ -1,21 +1,20 @@ const { AwsProvider } = require('./lib/providers/aws') const { GcpProvider } = require('./lib/providers/gcp') -const { cleanup } = require('./lib/cleanup') const { combine } = require('./lib/combine') const { createManifest } = require('./lib/create-manifest') const { createProvider } = require('./lib/create-provider') const { generateAll } = require('./lib/generate-all') -const { Provider } = require('./lib/provider') +const { Provider, ProviderEmitter } = require('./lib/provider') const { splitText } = require('./lib/split-text') module.exports = { AwsProvider, - cleanup, combine, createManifest, createProvider, GcpProvider, generateAll, Provider, + ProviderEmitter, splitText } diff --git a/packages/tts-lib/lib/cleanup.js b/packages/tts-lib/lib/cleanup.js deleted file mode 100644 index 706de55..0000000 --- a/packages/tts-lib/lib/cleanup.js +++ /dev/null @@ -1,18 +0,0 @@ -const debug = require('debug')('cleanup') -const { readFile, rm } = require('fs/promises') - -/** - * Deletes the manifest and its files. - */ -exports.cleanup = async (manifestFile) => { - const manifest = await readFile(manifestFile, 'utf8') - debug(`Manifest is ${manifest}`) - const regexpState = /^file\s+'(.*)'$/gm - let match - while ((match = regexpState.exec(manifest)) !== null) { - debug(`Deleting temporary file ${match[1]}`) - rm(match[1], { force: true, recursive: true }) - } - debug(`Deleting manifest file ${manifestFile}`) - rm(manifestFile, { force: true, recursive: true }) -} diff --git a/packages/tts-lib/lib/generate-all.js b/packages/tts-lib/lib/generate-all.js index 173f1f0..e86e125 100644 --- a/packages/tts-lib/lib/generate-all.js +++ b/packages/tts-lib/lib/generate-all.js @@ -2,21 +2,17 @@ const async = require('async') const debug = require('debug') /** - * Calls the API for each text part (throttled). Returns a Promise. + * Calls the API for each text part (throttled). + * The function must be an AsyncFunction (or have a callback). + * Returns a Promise. */ -exports.generateAll = async (parts, limit, func, task) => { +exports.generateAll = async (parts, limit, func) => { const count = parts.length - task.title = `Convert to audio (0/${count})` debug('generateAll')(`Requesting ${count} audio segments, ${limit} at a time`) try { - await async.eachOfLimit(parts, limit, func) + return async.mapLimit(parts, limit, func) } catch (err) { debug('generateAll')(`Requested all parts, with error ${err}`) throw err } - /* istanbul ignore else: not a real-life scenario */ - if (task.title.length < 1000) { // prevent regexp DoS - task.title = task.title.replace(/\d+\//, `${count}/`) - } - return parts } diff --git a/packages/tts-lib/lib/provider.js b/packages/tts-lib/lib/provider.js index 59eb70d..4e5fabf 100644 --- a/packages/tts-lib/lib/provider.js +++ b/packages/tts-lib/lib/provider.js @@ -1,17 +1,22 @@ const debug = require('debug')('provider') +const EventEmitter = require('events') const { readFileSync } = require('fs') +const { readFile, rm } = require('fs/promises') const tempfile = require('tempfile') const { combine } = require('./combine') const { createManifest } = require('./create-manifest') const { generateAll } = require('./generate-all') const { splitText } = require('./split-text') +class ProviderEmitter extends EventEmitter {} + /** * Abstract base class for providers. * @abstract */ class Provider { constructor (opts = {}) { + this.events = new ProviderEmitter() this.name = '[Base Provider]' this.maxCharacterCount = 1500 this.opts = opts @@ -21,6 +26,12 @@ class Provider { throw new Error('Can\'t instantiate abstract class') } + // Add a default error handler for the events. + // This prevents unhandled errors from crashing the process. + this.events.on('error', (err) => { + debug(`Error event in ${this.name}:`, err) + }) + // Load the private key from a file if specified. if (this.opts.privateKeyFile) { debug(`Reading private key from ${this.opts.privateKeyFile}`) @@ -29,24 +40,53 @@ class Provider { } /** - * This should be overridden by subclasses to return the data object. + * Deletes the manifest and its files. */ - buildInfo (text, task) { - return { - opts: this.opts, - task, - tempfile: tempfile(`.${this.extensionFor(this.opts.format)}`), - text + async cleanup (manifestFile) { + const manifest = await readFile(manifestFile, 'utf8') + debug(`Manifest is:\n${manifest}`) + const regexpState = /^file\s+'(.*)'$/gm + let match + while ((match = regexpState.exec(manifest)) !== null) { + debug(`Deleting temporary file ${match[1]}`) + rm(match[1], { force: true, recursive: true }) } + debug(`Deleting manifest file ${manifestFile}`) + rm(manifestFile, { force: true, recursive: true }) + debug('Emitting "clean" event') + this.events.emit('clean') } - combineAudio (manifestFile) { - const newFile = tempfile(`.${this.extensionFor(this.opts.format)}`) + async combine (manifestFile, newFile) { return combine(manifestFile, newFile, 'encoded', this.opts.ffmpeg) } + async combineAudio (manifestFile) { + const newFile = tempfile(`.${this.extensionFor(this.opts.format)}`) + await this.combine(manifestFile, newFile) + const eventData = { filename: newFile } + debug(`Emitting "save" event: ${JSON.stringify(eventData)}`) + this.events.emit('save', eventData) + return newFile + } + + /** + * Converts text to audio speech. + * This method runs all of the necessary subtasks. + * + * Returns a promise that resolves with the audio filename. + */ + async convert (text) { + const parts = await this.splitText(text) + const manifestFile = await this.generateSpeech(parts) + const tempFile = await this.combineAudio(manifestFile) + await this.cleanup(manifestFile) + return tempFile + } + /** * This should be overridden by subclasses to return the file extension for the given format. + * The ffmpeg library uses it for its processing. */ extensionFor (_format) { return 'mp3' @@ -54,24 +94,45 @@ class Provider { /** * This should be overridden by subclasses. + * It should return a Promise that resolves with an object containing the + * audio filename (in a `tempfile` key). */ - async generate (_info, _i) { + async generate (_str) { throw new Error('generate() not implemented') } /** * Returns a Promise with the temporary audio file. */ - async generateSpeech (strParts, task) { - // Compile the text parts and options together in a packet. - const parts = strParts.map(part => this.buildInfo(part, task)) - const updatedParts = await generateAll(parts, this.opts.throttle, this.generate.bind(this), task) - return createManifest(updatedParts) + async generateSpeech (strParts) { + let numComplete = 0 + const updatedParts = await generateAll( + strParts, + this.opts.throttle, + async (str, _i) => { + const info = await this.generate(str) + numComplete++ + const eventData = { count: strParts.length, complete: numComplete, filename: info.tempfile } + debug(`Emitting "generate" event: ${JSON.stringify(eventData)}`) + this.events.emit('generate', eventData) + return info + } + ) + const manifestFile = await createManifest(updatedParts) + const eventData = { filename: manifestFile } + debug(`Emitting "manifest" event: ${JSON.stringify(eventData)}`) + this.events.emit('manifest', eventData) + return manifestFile } - splitText (text) { - return splitText(text, this.maxCharacterCount, this.opts.type) + async splitText (text) { + const parts = await splitText(text, this.maxCharacterCount, this.opts.type) + const eventData = { length: text.length, count: parts.length } + debug(`Emitting "split" event: ${JSON.stringify(eventData)}`) + this.events.emit('split', eventData) + return parts } } exports.Provider = Provider +exports.ProviderEmitter = ProviderEmitter diff --git a/packages/tts-lib/lib/providers/aws.js b/packages/tts-lib/lib/providers/aws.js index a67a726..4e73c86 100644 --- a/packages/tts-lib/lib/providers/aws.js +++ b/packages/tts-lib/lib/providers/aws.js @@ -31,21 +31,7 @@ class AwsProvider extends Provider { }) } - /** - * Creates an object containing all the data. - */ - buildInfo (text, task) { - return { - opts: this.opts, - task, - tempfile: tempfile(`.${this.extensionFor(this.opts.format)}`), - text, - send: this.instance.send.bind(this.instance) - } - } - - combineAudio (manifestFile) { - const newFile = tempfile(`.${this.extensionFor(this.opts.format)}`) + async combine (manifestFile, newFile) { return combine(manifestFile, newFile, this.opts.format === 'pcm' ? 'raw' : 'encoded', this.opts.ffmpeg) } @@ -61,43 +47,40 @@ class AwsProvider extends Provider { } /** - * Calls the Polly API with the given info. + * Calls the Polly API with the given text. */ - async generate (info, i, callback) { - /* istanbul ignore else: not a real-life scenario */ - if (info.task.title.length < 1000) { // prevent regexp DoS - info.task.title = info.task.title.replace(/\d+\//, `${i}/`) - } - + async generate (str) { + const filename = tempfile(`.${this.extensionFor(this.opts.format)}`) const command = new SynthesizeSpeechCommand({ - Engine: info.opts.engine, - LanguageCode: info.opts.language, - LexiconNames: info.opts.lexicon, - OutputFormat: info.opts.format === 'ogg' ? 'ogg_vorbis' : info.opts.format, - SampleRate: info.opts.sampleRate ? String(info.opts.sampleRate) : undefined, - Text: info.text, - TextType: info.opts.type, - VoiceId: info.opts.voice + Engine: this.opts.engine, + LanguageCode: this.opts.language, + LexiconNames: this.opts.lexicon, + OutputFormat: this.opts.format === 'ogg' ? 'ogg_vorbis' : this.opts.format, + SampleRate: this.opts.sampleRate ? String(this.opts.sampleRate) : undefined, + Text: str, + TextType: this.opts.type, + VoiceId: this.opts.voice }) debug('generate')('Making request to Amazon Web Services') let response try { - response = await info.send(command) + response = await this.instance.send(command) } catch (err) { debug('generate')(`Error during request: ${err.message}`) throw err } - debug('generate')(`Writing audio content to ${info.tempfile}`) + debug('generate')(`Writing audio content to ${filename}`) try { - const fileStream = createWriteStream(info.tempfile) + const fileStream = createWriteStream(filename) await pipeline(response.AudioStream, fileStream) fileStream.close() } catch (err) { debug('generate')(`Error writing: ${err.message}`) throw err } + return { tempfile: filename } } } diff --git a/packages/tts-lib/lib/providers/gcp.js b/packages/tts-lib/lib/providers/gcp.js index c12fe92..5109039 100644 --- a/packages/tts-lib/lib/providers/gcp.js +++ b/packages/tts-lib/lib/providers/gcp.js @@ -35,19 +35,6 @@ class GcpProvider extends Provider { } } - /** - * Creates an object containing all the data. - */ - buildInfo = function (text, task) { - return { - opts: this.opts, - task, - tempfile: tempfile(`.${this.extensionFor(this.opts.format)}`), - text, - synthesizer: this.instance.synthesizeSpeech.bind(this.instance) - } - } - extensionFor (format) { if (format === 'mp3') { return 'mp3' @@ -60,32 +47,28 @@ class GcpProvider extends Provider { } /** - * Calls the Google Cloud API with the given info. + * Calls the Google Cloud API with the given text. */ - async generate (info, i) { - /* istanbul ignore else: not a real-life scenario */ - if (info.task.title.length < 1000) { // prevent regexp DoS - info.task.title = info.task.title.replace(/\d+\//, `${i}/`) - } - + async generate (str) { + const filename = tempfile(`.${this.extensionFor(this.opts.format)}`) const request = { - input: info.opts.type === 'ssml' - ? { ssml: info.text } - : { text: info.text }, + input: this.opts.type === 'ssml' + ? { ssml: str } + : { text: str }, voice: { - ssmlGender: info.opts.gender ? String(info.opts.gender).toUpperCase() : undefined, - languageCode: info.opts.language, - name: info.opts.voice + ssmlGender: this.opts.gender ? String(this.opts.gender).toUpperCase() : undefined, + languageCode: this.opts.language, + name: this.opts.voice }, audioConfig: { - audioEncoding: info.opts.format === 'pcm' + audioEncoding: this.opts.format === 'pcm' ? 'LINEAR16' - : info.opts.format === 'ogg' ? 'OGG_OPUS' : 'MP3', - effectsProfileId: info.opts.effect, - pitch: info.opts.pitch, - sampleRateHertz: info.opts.sampleRate, - speakingRate: info.opts.speed, - volumeGainDb: info.opts.gain + : this.opts.format === 'ogg' ? 'OGG_OPUS' : 'MP3', + effectsProfileId: this.opts.effect, + pitch: this.opts.pitch, + sampleRateHertz: this.opts.sampleRate, + speakingRate: this.opts.speed, + volumeGainDb: this.opts.gain } } const opts = { @@ -95,19 +78,21 @@ class GcpProvider extends Provider { let response debug('generate')('Making request to Google Cloud Platform') try { - response = await promisify(info.synthesizer)(request, opts) + response = await promisify(this.instance.synthesizeSpeech.bind(this.instance))(request, opts) } catch (err) { debug('generate')(`Error during request: ${err.message}`) throw err } - debug('generate')(`Writing audio content to ${info.tempfile}`) + debug('generate')(`Writing audio content to ${filename}`) try { - await writeFile(info.tempfile, response.audioContent, 'binary') + await writeFile(filename, response.audioContent, 'binary') } catch (err) { debug('generate')(`Error writing: ${err.message}`) throw err } + + return { tempfile: filename } } } diff --git a/packages/tts-lib/test/cleanup.spec.js b/packages/tts-lib/test/cleanup.spec.js deleted file mode 100644 index 1943282..0000000 --- a/packages/tts-lib/test/cleanup.spec.js +++ /dev/null @@ -1,32 +0,0 @@ -const proxyquire = require('proxyquire') - -describe('cleanup()', () => { - const manifestFilename = 'manifest.txt' - const tempFilenames = ['foo.mp3', 'bar.mp3'] - - let cleanup - let fsSpy - - beforeEach(() => { - fsSpy = jasmine.createSpyObj('fs', ['readFile', 'rm']) - ;({ cleanup } = proxyquire('../lib/cleanup', { - 'fs/promises': fsSpy - })) - }) - - beforeEach(() => { - const manifestContents = tempFilenames.map(filename => `file '${filename}'`).join('\n') - fsSpy.readFile.and.callFake(() => Promise.resolve(manifestContents)) - return cleanup(manifestFilename) - }) - - it('should delete the manifest file', () => { - expect(fsSpy.rm).toHaveBeenCalledWith(manifestFilename, { force: true, recursive: true }) - }) - - it('should delete the temporary audio files', () => { - tempFilenames.forEach(filename => { - expect(fsSpy.rm).toHaveBeenCalledWith(filename, { force: true, recursive: true }) - }) - }) -}) diff --git a/packages/tts-lib/test/generate-all.spec.js b/packages/tts-lib/test/generate-all.spec.js index f43d93c..9b686fb 100644 --- a/packages/tts-lib/test/generate-all.spec.js +++ b/packages/tts-lib/test/generate-all.spec.js @@ -2,7 +2,7 @@ const async = require('async') const proxyquire = require('proxyquire') describe('generateAll()', () => { - let generateAll, task + let generateAll let asyncSpy let iteratorFunction @@ -12,65 +12,33 @@ describe('generateAll()', () => { ] beforeEach(() => { - iteratorFunction = jasmine.createSpy('async iterator') - iteratorFunction.and.callFake((data, i, callback) => { callback() }) - task = { title: '' } - asyncSpy = jasmine.createSpyObj('async', ['eachOfLimit']) - asyncSpy.eachOfLimit.and.callFake(async.eachOfLimit) + iteratorFunction = jasmine.createSpy('iterator').and.callFake((data) => Promise.resolve(data)) + asyncSpy = jasmine.createSpyObj('async', ['mapLimit']) + asyncSpy.mapLimit.and.callFake(async.mapLimit) ;({ generateAll } = proxyquire('../lib/generate-all', { async: asyncSpy })) }) it('should asynchronously call the function for each of the parts', async () => { - await generateAll(textParts, testLimit, iteratorFunction, task) - const [parts] = asyncSpy.eachOfLimit.calls.mostRecent().args + await generateAll(textParts, testLimit, async () => iteratorFunction()) + const [parts] = asyncSpy.mapLimit.calls.mostRecent().args expect(parts).toEqual(textParts) expect(parts.length).toBe(textParts.length) expect(iteratorFunction.calls.count()).toBe(textParts.length) }) it('should limit the async calls according to the option', async () => { - await generateAll(textParts, testLimit, iteratorFunction, task) - const [, limit] = asyncSpy.eachOfLimit.calls.mostRecent().args + await generateAll(textParts, testLimit, async () => iteratorFunction()) + const [, limit] = asyncSpy.mapLimit.calls.mostRecent().args expect(limit).toBe(testLimit) }) - describe('initial spinner', () => { - beforeEach(async () => { - asyncSpy.eachOfLimit.and.callFake((parts, opts, func) => { - throw new Error('reject async') - }) - try { - await generateAll(textParts, 1, iteratorFunction, task) - } catch (_err) { - // Ignore the error. - } - }) - - it('should be updated', () => { - expect(task.title).toMatch('Convert to audio') - }) - - it('should show the part count', () => { - expect(task.title).toMatch(`/${textParts.length}\\)$`) - }) - - it('should start at 0', () => { - expect(task.title).toMatch('\\(0/') - }) - }) - describe('when all requests succeed', () => { it('should respond with the original parts', async () => { - const response = await generateAll(textParts, testLimit, iteratorFunction, task) + const response = await generateAll(textParts, testLimit, async (x) => iteratorFunction(x)) expect(response).toEqual(textParts) }) - - it('should show the final count', async () => { - await generateAll(textParts, testLimit, iteratorFunction, task) - expect(task.title).toMatch(`\\(${textParts.length}/`) - }) }) describe('when a request fails', () => { @@ -84,7 +52,7 @@ describe('generateAll()', () => { it('should return a rejected promise with the error', async () => { try { - await generateAll(textParts, testLimit, iteratorFunction, task) + await generateAll(textParts, testLimit, async (x) => iteratorFunction(x)) throw new Error('Expected generateAll to throw an error') } catch (err) { expect(err.message).toBe(testError) diff --git a/packages/tts-lib/test/index.spec.js b/packages/tts-lib/test/index.spec.js index cbc1c1d..0dc3bf8 100644 --- a/packages/tts-lib/test/index.spec.js +++ b/packages/tts-lib/test/index.spec.js @@ -3,13 +3,13 @@ const api = require('../index') describe('index', () => { it('should expose the API', () => { expect(api.AwsProvider).toBeDefined() - expect(api.cleanup).toBeDefined() expect(api.combine).toBeDefined() expect(api.createManifest).toBeDefined() expect(api.createProvider).toBeDefined() expect(api.GcpProvider).toBeDefined() expect(api.generateAll).toBeDefined() expect(api.Provider).toBeDefined() + expect(api.ProviderEmitter).toBeDefined() expect(api.splitText).toBeDefined() }) }) diff --git a/packages/tts-lib/test/provider.spec.js b/packages/tts-lib/test/provider.spec.js index b57a958..6c08ae8 100644 --- a/packages/tts-lib/test/provider.spec.js +++ b/packages/tts-lib/test/provider.spec.js @@ -2,11 +2,14 @@ const { writeFile } = require('fs/promises') const proxyquire = require('proxyquire') const tempfile = require('tempfile') +const { generateAll } = require('../lib/generate-all') + describe('provider', () => { describe('base class', () => { const chunks = ['hello', 'world'] const manifestFile = 'manifest.txt' const parts = ['foo.txt', 'bar.txt'] + const tempFile = 'tempfile.mp3' const text = 'hello world' let Provider @@ -14,15 +17,18 @@ describe('provider', () => { let childProvider let combineStub let createManifestStub + let fsSpy let generateAllStub let splitTextStub beforeEach(() => { combineStub = jasmine.createSpy('combine').and.returnValue(manifestFile) createManifestStub = jasmine.createSpy('createManifest').and.returnValue(Promise.resolve(manifestFile)) + fsSpy = jasmine.createSpyObj('fs', ['readFile', 'rm']) generateAllStub = jasmine.createSpy('generateAll').and.returnValue(Promise.resolve(parts)) splitTextStub = jasmine.createSpy('generateAll').and.returnValue(chunks) ;({ Provider } = proxyquire('../lib/provider', { + 'fs/promises': fsSpy, './combine': { combine: combineStub }, @@ -59,6 +65,15 @@ describe('provider', () => { expect(childProvider.maxCharacterCount).toBe(1500) }) + it('should create an event emitter', () => { + expect(childProvider.events).toBeDefined() + expect(childProvider.events.on).toEqual(jasmine.any(Function)) + }) + + it('should create a default error handler', () => { + expect(childProvider.events.listenerCount('error')).toBe(1) + }) + it('should read the private key if privateKeyFile is specified', async () => { const keyFile = tempfile() await writeFile(keyFile, 'private-key-content', 'utf8') @@ -69,32 +84,90 @@ describe('provider', () => { }) }) - describe('buildInfo()', () => { - it('should return an object with the text and a tempfile', () => { - const task = {} - const result = childProvider.buildInfo(text, task) - expect(result).toEqual({ - opts: { ffmpeg: 'ffmpeg-test', throttle: 10 }, - task, - tempfile: jasmine.any(String), - text - }) + describe('cleanup()', () => { + const manifestFilename = 'manifest.txt' + const tempFilenames = ['foo.mp3', 'bar.mp3'] + + beforeEach(() => { + const manifestContents = tempFilenames.map(filename => `file '${filename}'`).join('\n') + fsSpy.readFile.and.callFake(() => Promise.resolve(manifestContents)) + }) + + it('should delete the manifest file', async () => { + await childProvider.cleanup(manifestFile) + expect(fsSpy.rm).toHaveBeenCalledWith(manifestFilename, { force: true, recursive: true }) + }) + + it('should delete the temporary audio files', async () => { + await childProvider.cleanup(manifestFile) + for (const filename of tempFilenames) { + expect(fsSpy.rm).toHaveBeenCalledWith(filename, { force: true, recursive: true }) + } + }) + + it('should emit a "clean" event', async () => { + const cleanSpy = jasmine.createSpy('clean') + childProvider.events.on('clean', cleanSpy) + await childProvider.cleanup(manifestFile) + expect(cleanSpy).toHaveBeenCalled() }) }) - describe('combineAudio()', () => { + describe('combine()', () => { it('should call combine() for the audio', async () => { + await childProvider.combine('manifest.txt', 'foo.mp3') + expect(combineStub).toHaveBeenCalledWith('manifest.txt', 'foo.mp3', 'encoded', 'ffmpeg-test') + }) + }) + + describe('combineAudio()', () => { + it('should call combine()', async () => { + spyOn(childProvider, 'combine') + await childProvider.combineAudio('foobar') + expect(childProvider.combine).toHaveBeenCalledWith('foobar', jasmine.any(String)) + }) + + it('should emit a "save" event with the new filename', async () => { + const saveSpy = jasmine.createSpy('save') + childProvider.events.on('save', saveSpy) await childProvider.combineAudio('foobar') - const args = combineStub.calls.mostRecent().args - expect(args[0]).toBe('foobar') - expect(args[1]).toMatch(/\.mp3$/) - expect(args[2]).toBe('encoded') - expect(args[3]).toBe('ffmpeg-test') + expect(saveSpy).toHaveBeenCalledWith({ filename: jasmine.any(String) }) }) it('should return the combine() result', async () => { const result = await childProvider.combineAudio('foobar') - expect(result).toBe(manifestFile) + expect(result).toEqual(jasmine.any(String)) + }) + }) + + describe('convert()', () => { + beforeEach(async () => { + spyOn(childProvider, 'splitText').and.returnValue(Promise.resolve(chunks)) + spyOn(childProvider, 'generateSpeech').and.returnValue(Promise.resolve(manifestFile)) + spyOn(childProvider, 'combineAudio').and.returnValue(Promise.resolve(tempFile)) + spyOn(childProvider, 'cleanup') + await childProvider.convert(text) + }) + + it('should call splitText() with the text', async () => { + expect(childProvider.splitText).toHaveBeenCalledWith(text) + }) + + it('should call generateSpeech() with the text chunks', async () => { + expect(childProvider.generateSpeech).toHaveBeenCalledWith(chunks) + }) + + it('should call combineAudio() with the manifest file', async () => { + expect(childProvider.combineAudio).toHaveBeenCalledWith(manifestFile) + }) + + it('should call cleanup() with the manifest file', async () => { + expect(childProvider.cleanup).toHaveBeenCalledWith(manifestFile) + }) + + it('should return the final audio filename', async () => { + const result = await childProvider.convert(text) + expect(result).toBe(tempFile) }) }) @@ -118,40 +191,72 @@ describe('provider', () => { describe('generateSpeech()', () => { it('should call generateAll()', async () => { - const task = {} - await childProvider.generateSpeech(chunks, task) - expect(generateAllStub).toHaveBeenCalledWith([{ - opts: jasmine.any(Object), - task, - tempfile: jasmine.any(String), - text: chunks[0] - }, { - opts: jasmine.any(Object), - task, - tempfile: jasmine.any(String), - text: chunks[1] - }], childProvider.opts.throttle, jasmine.any(Function), task) + await childProvider.generateSpeech(chunks) + expect(generateAllStub).toHaveBeenCalledWith(chunks, childProvider.opts.throttle, jasmine.any(Function)) + }) + + it('should call generate() for each chunk', async () => { + spyOn(childProvider, 'generate').and.returnValue(Promise.resolve({ tempfile: tempFile })) + generateAllStub.and.callFake((parts, limit, func) => generateAll(parts, limit, func)) + await childProvider.generateSpeech(chunks) + expect(childProvider.generate.calls.count()).toBe(chunks.length) + for (const chunk of chunks) { + expect(childProvider.generate).toHaveBeenCalledWith(chunk) + } + }) + + it('should emit a "generate" event for each chunk', async () => { + spyOn(childProvider, 'generate').and.returnValue(Promise.resolve({ tempfile: tempFile })) + generateAllStub.and.callFake((parts, limit, func) => generateAll(parts, limit, func)) + const generateEventHandler = jasmine.createSpy('generate') + childProvider.events.on('generate', generateEventHandler) + await childProvider.generateSpeech(chunks) + expect(generateEventHandler.calls.count()).toBe(chunks.length) + expect(generateEventHandler).toHaveBeenCalledWith({ + count: 2, + complete: 1, + filename: tempFile + }) + expect(generateEventHandler).toHaveBeenCalledWith({ + count: 2, + complete: 2, + filename: tempFile + }) }) it('should call createManifest()', async () => { - await childProvider.generateSpeech(chunks, {}) + await childProvider.generateSpeech(chunks) expect(createManifestStub).toHaveBeenCalledWith(parts) }) + it('should emit a "manifest" event with the manifest filename', async () => { + const manifestSpy = jasmine.createSpy('manifest') + childProvider.events.on('manifest', manifestSpy) + await childProvider.generateSpeech(chunks) + expect(manifestSpy).toHaveBeenCalledWith({ filename: manifestFile }) + }) + it('should return the manifest file', async () => { - const result = await childProvider.generateSpeech(chunks, {}) + const result = await childProvider.generateSpeech(chunks) expect(result).toBe(manifestFile) }) }) describe('splitText()', () => { - it('should call the splitText routine with the correct parameters', () => { - childProvider.splitText(text) + it('should call the splitText routine with the correct parameters', async () => { + await childProvider.splitText(text) expect(splitTextStub).toHaveBeenCalledWith(text, childProvider.maxCharacterCount, childProvider.opts.type) }) - it('should return the splitText() result', () => { - const result = childProvider.splitText(text) + it('should emit a "split" event with the text length and chunk count', async () => { + const splitSpy = jasmine.createSpy('split') + childProvider.events.on('split', splitSpy) + await childProvider.splitText(text) + expect(splitSpy).toHaveBeenCalledWith({ length: text.length, count: chunks.length }) + }) + + it('should return the splitText() result', async () => { + const result = await childProvider.splitText(text) expect(result).toEqual(chunks) }) }) diff --git a/packages/tts-lib/test/providers/aws.spec.js b/packages/tts-lib/test/providers/aws.spec.js index d8409f8..6657d90 100644 --- a/packages/tts-lib/test/providers/aws.spec.js +++ b/packages/tts-lib/test/providers/aws.spec.js @@ -115,8 +115,8 @@ describe('AWS provider', () => { }) }) - describe('combineAudio()', () => { - it('should call combine() for raw audio', async () => { + describe('combine()', () => { + it('should call the library combine() for raw audio', async () => { provider = new AwsProvider({ region: 'aws-west-1', accessKey: 'ACCESS KEY', @@ -124,10 +124,10 @@ describe('AWS provider', () => { format: 'pcm', ffmpeg: 'ffmpeg-test' }) - await provider.combineAudio('foobar') + await provider.combine('foobar', 'raw.pcm') const args = combineStub.calls.mostRecent().args expect(args[0]).toBe('foobar') - expect(args[1]).toMatch(/\.pcm$/) + expect(args[1]).toBe('raw.pcm') expect(args[2]).toBe('raw') expect(args[3]).toBe('ffmpeg-test') }) @@ -140,32 +140,20 @@ describe('AWS provider', () => { format: 'mp3', ffmpeg: 'ffmpeg-test' }) - await provider.combineAudio('foobar') + await provider.combine('foobar', 'encoded.mp3') const args = combineStub.calls.mostRecent().args expect(args[0]).toBe('foobar') - expect(args[1]).toMatch(/\.mp3$/) + expect(args[1]).toBe('encoded.mp3') expect(args[2]).toBe('encoded') expect(args[3]).toBe('ffmpeg-test') }) }) - describe('buildInfo()', () => { - it('should return an object with the expected properties', () => { - const task = { foo: 1, bar: 2 } - const info = provider.buildInfo('hello world', task) - expect(info).toEqual({ - opts: provider.opts, - task, - tempfile: jasmine.any(String), - text: 'hello world', - send: jasmine.any(Function) - }) - expect(info.tempfile).toMatch(/\.mp3$/) - }) - }) - describe('generate()', () => { + const text = 'hello world' + let task, testData, info, send + let filename = tempfile() beforeEach(() => { task = { @@ -192,6 +180,9 @@ describe('AWS provider', () => { this.push(null) } }) + spyOn(provider.instance, 'send').and.resolveTo({ + AudioStream: Readable.from('testing') + }) send = jasmine.createSpy('send').and.resolveTo({ AudioStream: inStream }) @@ -206,111 +197,112 @@ describe('AWS provider', () => { afterEach(async () => { try { - await unlink(testData.filename) + await unlink(filename) } catch (_err) { // Ignore errors. } }) - it('should update the task title', async () => { - await provider.generate(info, testData.index) - expect(task.title).toMatch(`\\(${testData.index}/`) - }) - it('should work with the MP3 format', async () => { - testData.opts.format = 'mp3' - await provider.generate(info, 0) - const command = send.calls.mostRecent().args[0] + provider.opts.format = 'mp3' + await provider.generate(text) + const command = provider.instance.send.calls.mostRecent().args[0] expect(command.input.OutputFormat).toBe('mp3') }) it('should work with the OGG format', async () => { - testData.opts.format = 'ogg' - await provider.generate(info, 0) - const command = send.calls.mostRecent().args[0] + provider.opts.format = 'ogg' + await provider.generate(text) + const command = provider.instance.send.calls.mostRecent().args[0] expect(command.input.OutputFormat).toBe('ogg_vorbis') }) it('should work with the PCM format', async () => { - testData.opts.format = 'pcm' - await provider.generate(info, 0) - const command = send.calls.mostRecent().args[0] + provider.opts.format = 'pcm' + await provider.generate(text) + const command = provider.instance.send.calls.mostRecent().args[0] expect(command.input.OutputFormat).toBe('pcm') }) it('should use the given voice engine', async () => { - await provider.generate(info, 0) - const command = send.calls.mostRecent().args[0] + provider.opts.engine = 'neural' + await provider.generate(text) + const command = provider.instance.send.calls.mostRecent().args[0] expect(command.input.Engine).toBe('neural') }) it('should not use sample rate if not specified', async () => { - delete info.opts.sampleRate - await provider.generate(info, 0) - const command = send.calls.mostRecent().args[0] + delete provider.opts.sampleRate + await provider.generate(text) + const command = provider.instance.send.calls.mostRecent().args[0] expect(command.input.SampleRate).toBeUndefined() }) it('should use the (stringified) sample rate, when specified', async () => { - await provider.generate(info, 0) - const command = send.calls.mostRecent().args[0] - expect(command.input.SampleRate).toBe(String(testData.opts.sampleRate)) + provider.opts.sampleRate = 16000 + await provider.generate(text) + const command = provider.instance.send.calls.mostRecent().args[0] + expect(command.input.SampleRate).toBe(String(provider.opts.sampleRate)) }) it('should not use lexicon names if not specified', async () => { - delete info.opts.lexicon - await provider.generate(info, 0) - const command = send.calls.mostRecent().args[0] + delete provider.opts.lexicon + await provider.generate(text) + const command = provider.instance.send.calls.mostRecent().args[0] expect(command.input.LexiconNames).toBeUndefined() }) it('should use the lexicon names, when specified', async () => { - await provider.generate(info, 0) - const command = send.calls.mostRecent().args[0] - expect(command.input.LexiconNames).toEqual(testData.opts.lexicon) + provider.opts.lexicon = ['lexicon1', 'lexicon2'] + await provider.generate(text) + const command = provider.instance.send.calls.mostRecent().args[0] + expect(command.input.LexiconNames).toEqual(provider.opts.lexicon) }) it('should use the given text type', async () => { - await provider.generate(info, 0) - const command = send.calls.mostRecent().args[0] - expect(command.input.TextType).toBe(testData.opts.type) + provider.opts.type = 'ssml' + await provider.generate(text) + const command = provider.instance.send.calls.mostRecent().args[0] + expect(command.input.TextType).toBe(provider.opts.type) }) it('should use the given text part', async () => { - await provider.generate(info, 0) - const command = send.calls.mostRecent().args[0] - expect(command.input.Text).toBe(testData.text) + await provider.generate(text) + const command = provider.instance.send.calls.mostRecent().args[0] + expect(command.input.Text).toBe(text) }) it('should not use a language if not specified', async () => { - delete info.opts.language - await provider.generate(info, 0) - const command = send.calls.mostRecent().args[0] + delete provider.opts.language + await provider.generate(text) + const command = provider.instance.send.calls.mostRecent().args[0] expect(command.input.LanguageCode).toBeUndefined() }) it('should use the language, when specified', async () => { - await provider.generate(info, 0) - const command = send.calls.mostRecent().args[0] - expect(command.input.LanguageCode).toBe(testData.opts.language) + provider.opts.language = 'en-US' + await provider.generate(text) + const command = provider.instance.send.calls.mostRecent().args[0] + expect(command.input.LanguageCode).toBe(provider.opts.language) }) it('should use the given voice', async () => { - await provider.generate(info, 0) - const command = send.calls.mostRecent().args[0] - expect(command.input.VoiceId).toBe(String(testData.opts.voice)) + provider.opts.voice = 'John' + await provider.generate(text) + const command = provider.instance.send.calls.mostRecent().args[0] + expect(command.input.VoiceId).toBe(String(provider.opts.voice)) }) it('should pipe the resulting stream into the file', async () => { - await provider.generate(info, 0) - const contents = await readFile(testData.filename, 'utf-8') + const { tempfile } = await provider.generate(text) + const contents = await readFile(tempfile, 'utf-8') expect(contents).toBe('testing') }) it('should callback with an error if send() fails', async () => { - info.send = jasmine.createSpy('send').and.rejectWith(new Error('test error')) + provider.instance.send.and.rejectWith(new Error('test error')) try { - await provider.generate(info, 0) + await provider.generate(text) throw new Error('generate() should have thrown an error') } catch (err) { expect(err).toEqual(new Error('test error')) @@ -326,11 +318,16 @@ describe('AWS provider', () => { return stream }) try { - await provider.generate(info, 0) + await provider.generate(text) throw new Error('generate() should have thrown error') } catch (err) { expect(err).toEqual(new Error('write stream error')) } }) + + it('should return the filename', async () => { + const result = await provider.generate(text) + expect(result.tempfile).toEqual(jasmine.any(String)) + }) }) }) diff --git a/packages/tts-lib/test/providers/gcp.spec.js b/packages/tts-lib/test/providers/gcp.spec.js index 0394478..d6b1f9b 100644 --- a/packages/tts-lib/test/providers/gcp.spec.js +++ b/packages/tts-lib/test/providers/gcp.spec.js @@ -5,38 +5,32 @@ const proxyquire = require('proxyquire') const tempfile = require('tempfile') describe('GCP provider', () => { - const chunks = ['hello world'] - - let combineStub let fsStub - let splitTextStub let GcpProvider let provider beforeEach(() => { - combineStub = jasmine.createSpy('combine') fsStub = jasmine.createSpyObj('fs', ['writeFile']) - fsStub.writeFile.and.callFake((dest, data, opts) => { Promise.resolve() }) - splitTextStub = jasmine.createSpy('splitText').and.returnValue(chunks) + fsStub.writeFile.and.callFake((_dest, _data, _opts) => { Promise.resolve() }) ;({ GcpProvider } = proxyquire('../../lib/providers/gcp', { - 'fs/promises': fsStub, - '../combine': { - combine: combineStub - }, - '../split-text': { - splitText: splitTextStub - } + 'fs/promises': fsStub })) provider = new GcpProvider({ - email: 'foo@example.com', - privateKey: 'private key', - projectFile: 'project-file.json', projectId: 'project ID', format: 'mp3' }) }) describe('constructor', () => { + beforeEach(() => { + provider = new GcpProvider({ + email: 'foo@example.com', + privateKey: 'private key', + projectFile: 'project-file.json', + projectId: 'project ID', + format: 'mp3' + }) + }) it('should allow for no options', () => { expect(() => { provider = new GcpProvider() @@ -145,208 +139,159 @@ describe('GCP provider', () => { }) }) - describe('buildInfo()', () => { - it('should return an object with the expected properties', () => { - const task = { foo: 1, bar: 2 } - const info = provider.buildInfo('hello world', task) - expect(info).toEqual({ - opts: provider.opts, - task, - tempfile: jasmine.any(String), - text: 'hello world', - synthesizer: jasmine.any(Function) - }) - expect(info.tempfile).toMatch(/\.mp3$/) - }) - }) - describe('generate()', () => { - let task, testData, info, synthesizer + const text = 'hello world' + const response = 'fake audio data' beforeEach(() => { - task = { - title: 'Convert to audio (0/42)' - } - testData = { - filename: tempfile(), - index: 6, - opts: { - effect: ['effect1', 'effect2'], - gain: -1.2, - gender: 'neutral', - language: 'en-US', - pitch: -9.8, - sampleRate: 16000, - speed: 4.2, - type: 'text', - voice: 'John' - }, - response: 'fake audio data', - text: 'hello world', - url: 'http://example.com/' - } - synthesizer = jasmine.createSpy('synthesizer') - info = { - opts: testData.opts, - task, - tempfile: testData.filename, - text: testData.text, - synthesizer - } - }) - - afterEach(async () => { - try { - await unlink(testData.filename) - } catch (_err) { - // Ignore any errors. - } + spyOn(provider.instance, 'synthesizeSpeech') }) it('should call the synthesizer function', async () => { - synthesizer.and.callFake((_req, _opts, done) => done(null, {})) - await provider.generate(info, 0, () => {}) - expect(synthesizer).toHaveBeenCalled() + provider.instance.synthesizeSpeech.and.callFake((_req, _opts, done) => done(null, {})) + await provider.generate(text) + expect(provider.instance.synthesizeSpeech).toHaveBeenCalled() }) describe('when everything works', () => { beforeEach(() => { - synthesizer.and.callFake((req, opts, cb) => { - cb(null, { audioContent: testData.response }) + provider.instance.synthesizeSpeech.and.callFake((_req, _opts, cb) => { + cb(null, { audioContent: response }) }) }) - it('should update the task title', async () => { - await provider.generate(info, testData.index) - expect(task.title).toMatch(`\\(${testData.index}/`) - }) - it('should work with the MP3 format', async () => { - testData.opts.format = 'mp3' - await provider.generate(info, 0) - const opts = synthesizer.calls.mostRecent().args[0] + provider.opts.format = 'mp3' + await provider.generate(text) + const opts = provider.instance.synthesizeSpeech.calls.mostRecent().args[0] expect(opts.audioConfig.audioEncoding).toBe('MP3') }) it('should work with the OGG format', async () => { - testData.opts.format = 'ogg' - await provider.generate(info, 0) - const opts = synthesizer.calls.mostRecent().args[0] + provider.opts.format = 'ogg' + await provider.generate(text) + const opts = provider.instance.synthesizeSpeech.calls.mostRecent().args[0] expect(opts.audioConfig.audioEncoding).toBe('OGG_OPUS') }) it('should work with the PCM format', async () => { - testData.opts.format = 'pcm' - await provider.generate(info, 0) - const opts = synthesizer.calls.mostRecent().args[0] + provider.opts.format = 'pcm' + await provider.generate(text) + const opts = provider.instance.synthesizeSpeech.calls.mostRecent().args[0] expect(opts.audioConfig.audioEncoding).toBe('LINEAR16') }) it('should not use sample rate if not specified', async () => { - delete info.opts.sampleRate - await provider.generate(info, 0) - const opts = synthesizer.calls.mostRecent().args[0] + delete provider.opts.sampleRate + await provider.generate(text) + const opts = provider.instance.synthesizeSpeech.calls.mostRecent().args[0] expect(opts.audioConfig.sampleRateHertz).toBeUndefined() }) it('should use the sample rate, when specified', async () => { - await provider.generate(info, 0) - const opts = synthesizer.calls.mostRecent().args[0] - expect(opts.audioConfig.sampleRateHertz).toBe(testData.opts.sampleRate) + await provider.generate(text) + const opts = provider.instance.synthesizeSpeech.calls.mostRecent().args[0] + expect(opts.audioConfig.sampleRateHertz).toBe(provider.opts.sampleRate) }) it('should use the given (plain) text', async () => { - testData.opts.type = 'text' - await provider.generate(info, 0) - const opts = synthesizer.calls.mostRecent().args[0] - expect(opts.input.text).toBe(testData.text) + provider.opts.type = 'text' + await provider.generate(text) + const opts = provider.instance.synthesizeSpeech.calls.mostRecent().args[0] + expect(opts.input.text).toBe(text) expect(opts.input.ssml).toBeUndefined() }) it('should use the given (SSML) text', async () => { - testData.opts.type = 'ssml' - await provider.generate(info, 0) - const opts = synthesizer.calls.mostRecent().args[0] - expect(opts.input.ssml).toBe(testData.text) + provider.opts.type = 'ssml' + await provider.generate(text) + const opts = provider.instance.synthesizeSpeech.calls.mostRecent().args[0] + expect(opts.input.ssml).toBe(text) expect(opts.input.text).toBeUndefined() }) it('should not use effects if not specified', async () => { - delete info.opts.effect - await provider.generate(info, 0) - const opts = synthesizer.calls.mostRecent().args[0] + delete provider.opts.effect + await provider.generate(text) + const opts = provider.instance.synthesizeSpeech.calls.mostRecent().args[0] expect(opts.audioConfig.effectsProfileId).toBeUndefined() }) it('should use the effects, when specified', async () => { - await provider.generate(info, 0) - const opts = synthesizer.calls.mostRecent().args[0] - expect(opts.audioConfig.effectsProfileId).toEqual(testData.opts.effect) + await provider.generate(text) + const opts = provider.instance.synthesizeSpeech.calls.mostRecent().args[0] + expect(opts.audioConfig.effectsProfileId).toEqual(provider.opts.effect) }) it('should use the given volume gain', async () => { - await provider.generate(info, 0) - const opts = synthesizer.calls.mostRecent().args[0] - expect(opts.audioConfig.volumeGainDb).toBe(testData.opts.gain) + await provider.generate(text) + const opts = provider.instance.synthesizeSpeech.calls.mostRecent().args[0] + expect(opts.audioConfig.volumeGainDb).toBe(provider.opts.gain) }) it('should use the given gender', async () => { - await provider.generate(info, 0) - const opts = synthesizer.calls.mostRecent().args[0] - expect(opts.voice.ssmlGender).toBe(testData.opts.gender.toUpperCase()) + provider.opts.gender = 'female' + await provider.generate(text) + const opts = provider.instance.synthesizeSpeech.calls.mostRecent().args[0] + expect(opts.voice.ssmlGender).toBe(provider.opts.gender.toUpperCase()) }) it('should leave out gender if not specified', async () => { - delete testData.opts.gender - await provider.generate(info, 0) - const opts = synthesizer.calls.mostRecent().args[0] + delete provider.opts.gender + await provider.generate(text) + const opts = provider.instance.synthesizeSpeech.calls.mostRecent().args[0] expect(opts.voice.ssmlGender).toBeUndefined() }) it('should use the given language', async () => { - await provider.generate(info, 0) - const opts = synthesizer.calls.mostRecent().args[0] - expect(opts.voice.languageCode).toBe(testData.opts.language) + await provider.generate(text) + const opts = provider.instance.synthesizeSpeech.calls.mostRecent().args[0] + expect(opts.voice.languageCode).toBe(provider.opts.language) }) it('should use the given pitch', async () => { - await provider.generate(info, 0) - const opts = synthesizer.calls.mostRecent().args[0] - expect(opts.audioConfig.pitch).toBe(testData.opts.pitch) + await provider.generate(text) + const opts = provider.instance.synthesizeSpeech.calls.mostRecent().args[0] + expect(opts.audioConfig.pitch).toBe(provider.opts.pitch) }) it('should use the given speed', async () => { - await provider.generate(info, 0) - const opts = synthesizer.calls.mostRecent().args[0] - expect(opts.audioConfig.speakingRate).toBe(testData.opts.speed) + await provider.generate(text) + const opts = provider.instance.synthesizeSpeech.calls.mostRecent().args[0] + expect(opts.audioConfig.speakingRate).toBe(provider.opts.speed) }) it('should use the given voice', async () => { - await provider.generate(info, 0) - const opts = synthesizer.calls.mostRecent().args[0] - expect(opts.voice.name).toBe(testData.opts.voice) + await provider.generate(text) + const opts = provider.instance.synthesizeSpeech.calls.mostRecent().args[0] + expect(opts.voice.name).toBe(provider.opts.voice) }) it('should write the GCP response to the temp file', async () => { - await provider.generate(info, 0) + await provider.generate(text) expect(fsStub.writeFile).toHaveBeenCalledWith( - testData.filename, - testData.response, + jasmine.any(String), + response, 'binary' ) }) + + it('should return the filename', async () => { + const result = await provider.generate(text) + expect(result.tempfile).toEqual(jasmine.any(String)) + }) }) describe('when GCP returns an error', () => { beforeEach(() => { - synthesizer.and.callFake((req, opts, cb) => { + provider.instance.synthesizeSpeech.and.callFake((_req, _opts, cb) => { cb(new Error('testing GCP error')) }) }) it('should call back with the error', async () => { try { - await provider.generate(info, 0) + await provider.generate(text) throw new Error('generate() should have thrown an error') } catch (err) { expect(err.message).toBe('testing GCP error') @@ -356,17 +301,17 @@ describe('GCP provider', () => { describe('when file writing fails', () => { beforeEach(() => { - synthesizer.and.callFake((req, opts, cb) => { - cb(null, { audioContent: testData.response }) + provider.instance.synthesizeSpeech.and.callFake((_req, _opts, cb) => { + cb(null, { audioContent: response }) }) - fsStub.writeFile.and.callFake((dest, data, opts) => { + fsStub.writeFile.and.callFake((_dest, _data, _opts) => { throw new Error('testing write error') }) }) it('should call back with the error', async () => { try { - await provider.generate(info, 0) + await provider.generate(text) throw new Error('generate() should have thrown an error') } catch (err) { expect(err.message).toBe('testing write error') From cfb877324a61cfe3f640eb6dd16dc73764612819 Mon Sep 17 00:00:00 2001 From: Eric Heikes Date: Fri, 4 Jul 2025 11:07:23 -0700 Subject: [PATCH 35/48] chore(providers): Clean up unused code --- packages/tts-lib/test/providers/aws.spec.js | 38 +-------------------- packages/tts-lib/test/providers/gcp.spec.js | 2 +- 2 files changed, 2 insertions(+), 38 deletions(-) diff --git a/packages/tts-lib/test/providers/aws.spec.js b/packages/tts-lib/test/providers/aws.spec.js index 6657d90..082f838 100644 --- a/packages/tts-lib/test/providers/aws.spec.js +++ b/packages/tts-lib/test/providers/aws.spec.js @@ -151,48 +151,12 @@ describe('AWS provider', () => { describe('generate()', () => { const text = 'hello world' - - let task, testData, info, send - let filename = tempfile() + const filename = tempfile() beforeEach(() => { - task = { - title: 'Convert to audio (0/42)' - } - testData = { - filename: tempfile(), - index: 6, - opts: { - engine: 'neural', - format: 'ogg', - language: 'en-US', - lexicon: ['lexicon1', 'lexicon2'], - sampleRate: 16000, - type: 'ssml', - voice: 'John' - }, - text: 'hello world', - url: 'http://example.com/' - } - const inStream = new Readable({ - read () { - this.push('testing') - this.push(null) - } - }) spyOn(provider.instance, 'send').and.resolveTo({ AudioStream: Readable.from('testing') }) - send = jasmine.createSpy('send').and.resolveTo({ - AudioStream: inStream - }) - info = { - opts: testData.opts, - task, - tempfile: testData.filename, - text: testData.text, - send - } }) afterEach(async () => { diff --git a/packages/tts-lib/test/providers/gcp.spec.js b/packages/tts-lib/test/providers/gcp.spec.js index d6b1f9b..61d40d7 100644 --- a/packages/tts-lib/test/providers/gcp.spec.js +++ b/packages/tts-lib/test/providers/gcp.spec.js @@ -1,5 +1,5 @@ const TextToSpeechClient = require('@google-cloud/text-to-speech').TextToSpeechClient -const { unlink, writeFile } = require('fs/promises') +const { writeFile } = require('fs/promises') const path = require('path') const proxyquire = require('proxyquire') const tempfile = require('tempfile') From 638a31f87268d59de98f171f097b479b1928a981 Mon Sep 17 00:00:00 2001 From: Eric Heikes Date: Fri, 4 Jul 2025 11:08:46 -0700 Subject: [PATCH 36/48] chore(cli): Clean up unused vars --- packages/tts-cli/test/cli.spec.js | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/tts-cli/test/cli.spec.js b/packages/tts-cli/test/cli.spec.js index a5f3966..c829afb 100644 --- a/packages/tts-cli/test/cli.spec.js +++ b/packages/tts-cli/test/cli.spec.js @@ -5,9 +5,7 @@ class TestEmitter extends EventEmitter {} describe('CLI', () => { const inputFile = 'input-file' - const manifestFile = 'manifest file' const outputFile = 'output-file' - const parts = ['part1', 'part2'] const tempFile = 'temp file' let cli From 102174a6a331030971ea37aecf60b562856697cd Mon Sep 17 00:00:00 2001 From: Eric Heikes Date: Fri, 4 Jul 2025 11:10:21 -0700 Subject: [PATCH 37/48] ci(github): Add Node.js v24 --- .github/workflows/ci.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 3b5741d..2d9e65c 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -13,7 +13,7 @@ jobs: strategy: matrix: - node-version: [18.x, 20.x, 21.x, 22.x, 23.x] + node-version: [18.x, 20.x, 21.x, 22.x, 23.x, 24.x] steps: - uses: actions/checkout@v2 From eeadf59224287391f48882cc82f9dbfd7b6f550a Mon Sep 17 00:00:00 2001 From: Eric Heikes Date: Wed, 16 Jul 2025 23:11:34 -0700 Subject: [PATCH 38/48] test: Set up E2E tests --- packages/tts-cli/package.json | 4 ++- .../tts-cli/test/{jasmine.json => e2e.json} | 2 +- packages/tts-cli/test/e2e/aws.spec.js | 5 +++ packages/tts-cli/test/unit.json | 7 ++++ packages/tts-cli/test/{ => unit}/cli.spec.js | 36 +++++++++---------- .../test/{ => unit}/move-temp-file.spec.js | 2 +- .../tts-cli/test/{ => unit}/program.spec.js | 4 +-- .../tts-cli/test/{ => unit}/read-text.spec.js | 2 +- .../test/{ => unit}/sanitize-opts.spec.js | 2 +- 9 files changed, 39 insertions(+), 25 deletions(-) rename packages/tts-cli/test/{jasmine.json => e2e.json} (71%) create mode 100644 packages/tts-cli/test/e2e/aws.spec.js create mode 100644 packages/tts-cli/test/unit.json rename packages/tts-cli/test/{ => unit}/cli.spec.js (90%) rename packages/tts-cli/test/{ => unit}/move-temp-file.spec.js (94%) rename packages/tts-cli/test/{ => unit}/program.spec.js (92%) rename packages/tts-cli/test/{ => unit}/read-text.spec.js (97%) rename packages/tts-cli/test/{ => unit}/sanitize-opts.spec.js (93%) diff --git a/packages/tts-cli/package.json b/packages/tts-cli/package.json index 0aa466f..39c448d 100644 --- a/packages/tts-cli/package.json +++ b/packages/tts-cli/package.json @@ -14,7 +14,9 @@ "scripts": { "lint": "standard --env jasmine --fix --verbose", "report-coverage": "codecov", - "test": "cross-env JASMINE_CONFIG_PATH=test/jasmine.json nyc jasmine" + "test": "npm run test:unit && npm run test:e2e", + "test:e2e": "cross-env JASMINE_CONFIG_PATH=test/e2e.json jasmine", + "test:unit": "cross-env JASMINE_CONFIG_PATH=test/unit.json nyc jasmine" }, "repository": { "type": "git", diff --git a/packages/tts-cli/test/jasmine.json b/packages/tts-cli/test/e2e.json similarity index 71% rename from packages/tts-cli/test/jasmine.json rename to packages/tts-cli/test/e2e.json index 7ae68eb..b28f577 100644 --- a/packages/tts-cli/test/jasmine.json +++ b/packages/tts-cli/test/e2e.json @@ -1,5 +1,5 @@ { - "spec_dir": "test", + "spec_dir": "test/e2e", "spec_files": [ "**/*.spec.js" ], diff --git a/packages/tts-cli/test/e2e/aws.spec.js b/packages/tts-cli/test/e2e/aws.spec.js new file mode 100644 index 0000000..3160ac9 --- /dev/null +++ b/packages/tts-cli/test/e2e/aws.spec.js @@ -0,0 +1,5 @@ +describe('aws', () => { + it('should stub', () => { + expect(true).toBe(true) + }) +}) diff --git a/packages/tts-cli/test/unit.json b/packages/tts-cli/test/unit.json new file mode 100644 index 0000000..6bea30d --- /dev/null +++ b/packages/tts-cli/test/unit.json @@ -0,0 +1,7 @@ +{ + "spec_dir": "test/unit", + "spec_files": [ + "**/*.spec.js" + ], + "random": false +} diff --git a/packages/tts-cli/test/cli.spec.js b/packages/tts-cli/test/unit/cli.spec.js similarity index 90% rename from packages/tts-cli/test/cli.spec.js rename to packages/tts-cli/test/unit/cli.spec.js index c829afb..239f479 100644 --- a/packages/tts-cli/test/cli.spec.js +++ b/packages/tts-cli/test/unit/cli.spec.js @@ -33,7 +33,7 @@ describe('CLI', () => { '../tts-lib': { cleanup, createProvider }, './lib/read-text': { readText } } - cli = proxyquire('../tts', mocks) + cli = proxyquire('../../tts', mocks) }) it('should construct an array of tasks', () => { @@ -48,7 +48,7 @@ describe('CLI', () => { beforeEach(() => { process.argv = ['node', 'tts.js', inputFile, outputFile] - cli = proxyquire('../tts', mocks) + cli = proxyquire('../../tts', mocks) }) it('should use the first argument for the input filename', () => { @@ -63,7 +63,7 @@ describe('CLI', () => { describe('when only 1 argument is given', () => { beforeEach(() => { process.argv = ['node', 'tts.js', outputFile] - cli = proxyquire('../tts', mocks) + cli = proxyquire('../../tts', mocks) }) it('should use null for the input filename', () => { @@ -77,19 +77,19 @@ describe('CLI', () => { it('should create an AWS service', () => { process.argv = ['node', 'tts.js', outputFile, '--service', 'aws'] - cli = proxyquire('../tts', mocks) + cli = proxyquire('../../tts', mocks) expect(cli.context.service).toBe('aws') }) it('should create a GCP service', () => { process.argv = ['node', 'tts.js', outputFile, '--service', 'gcp'] - cli = proxyquire('../tts', mocks) + cli = proxyquire('../../tts', mocks) expect(cli.context.service).toBe('gcp') }) it('should create a default service', () => { process.argv = ['node', 'tts.js', outputFile] - cli = proxyquire('../tts', mocks) + cli = proxyquire('../../tts', mocks) expect(cli.context.service).toBe('aws') }) @@ -122,7 +122,7 @@ describe('CLI', () => { '--type', 'text', '--voice', 'test voice' ] - cli = proxyquire('../tts', mocks) + cli = proxyquire('../../tts', mocks) expect(createProvider).toHaveBeenCalledWith('aws', { accessKey: 'test access key', effect: ['test effect'], @@ -151,7 +151,7 @@ describe('CLI', () => { it('should create the service with default options', () => { process.argv = ['node', 'tts.js', inputFile, outputFile] - cli = proxyquire('../tts', mocks) + cli = proxyquire('../../tts', mocks) expect(createProvider).toHaveBeenCalledWith('aws', { ffmpeg: 'ffmpeg', format: 'mp3', @@ -164,21 +164,21 @@ describe('CLI', () => { it('should call readText() in the text-reading task', async () => { process.argv = ['node', 'tts.js', outputFile] - cli = proxyquire('../tts', mocks) + cli = proxyquire('../../tts', mocks) await cli.tasks[0].task() expect(readText).toHaveBeenCalledWith(null, process) }) it('should call convert() in the text-reading task', async () => { process.argv = ['node', 'tts.js', outputFile] - cli = proxyquire('../tts', mocks) + cli = proxyquire('../../tts', mocks) await cli.tasks[0].task() expect(convert).toHaveBeenCalledWith('test text') }) it('should listen for the "split" event in the text-splitting task', async () => { process.argv = ['node', 'tts.js', outputFile] - cli = proxyquire('../tts', mocks) + cli = proxyquire('../../tts', mocks) const context = {} emitter.emit('split', { count: 6 }) await cli.tasks[1].task(context) // will timeout with no split event @@ -186,7 +186,7 @@ describe('CLI', () => { it('should set "count" in the context in the text-splitting task', async () => { process.argv = ['node', 'tts.js', outputFile] - cli = proxyquire('../tts', mocks) + cli = proxyquire('../../tts', mocks) const context = {} emitter.emit('split', { count: 6 }) await cli.tasks[1].task(context) @@ -195,7 +195,7 @@ describe('CLI', () => { it('should listen for the "manifest" event in the speech-generation task', async () => { process.argv = ['node', 'tts.js', outputFile] - cli = proxyquire('../tts', mocks) + cli = proxyquire('../../tts', mocks) const context = { count: 6 } const task = {} emitter.emit('manifest', { complete: 2, count: 6 }) @@ -204,7 +204,7 @@ describe('CLI', () => { it('should set the initial task title in the speech-generation task', async () => { process.argv = ['node', 'tts.js', outputFile] - cli = proxyquire('../tts', mocks) + cli = proxyquire('../../tts', mocks) const context = { count: 6 } const task = {} emitter.emit('manifest') @@ -214,7 +214,7 @@ describe('CLI', () => { it('should update the task title on "generate" events', async () => { process.argv = ['node', 'tts.js', outputFile] - cli = proxyquire('../tts', mocks) + cli = proxyquire('../../tts', mocks) const context = { count: 6 } const task = {} const doneWithTask = cli.tasks[2].task(context, task) @@ -226,7 +226,7 @@ describe('CLI', () => { it('should listen for the "save" event in the combine-audio task', async () => { process.argv = ['node', 'tts.js', outputFile] - cli = proxyquire('../tts', mocks) + cli = proxyquire('../../tts', mocks) const context = {} emitter.emit('save', { filename: outputFile }) await cli.tasks[3].task(context) // will timeout with no save event @@ -234,7 +234,7 @@ describe('CLI', () => { it('should set "tempFile" in the context in the combine-audio task', async () => { process.argv = ['node', 'tts.js', outputFile] - cli = proxyquire('../tts', mocks) + cli = proxyquire('../../tts', mocks) const context = {} emitter.emit('save', { filename: outputFile }) await cli.tasks[3].task(context) @@ -243,7 +243,7 @@ describe('CLI', () => { it('should listen for the "clean" event in the cleanup task', async () => { process.argv = ['node', 'tts.js', outputFile] - cli = proxyquire('../tts', mocks) + cli = proxyquire('../../tts', mocks) emitter.emit('clean') await cli.tasks[4].task() // will timeout with no clean event }) diff --git a/packages/tts-cli/test/move-temp-file.spec.js b/packages/tts-cli/test/unit/move-temp-file.spec.js similarity index 94% rename from packages/tts-cli/test/move-temp-file.spec.js rename to packages/tts-cli/test/unit/move-temp-file.spec.js index f03b1d3..408c75d 100644 --- a/packages/tts-cli/test/move-temp-file.spec.js +++ b/packages/tts-cli/test/unit/move-temp-file.spec.js @@ -11,7 +11,7 @@ describe('moveTempFile()', () => { beforeEach(async () => { fsSpy = jasmine.createSpyObj('fs', ['rename']) fsSpy.rename.and.callFake((src, dest) => Promise.resolve()) - ;({ moveTempFile } = proxyquire('../lib/move-temp-file', { + ;({ moveTempFile } = proxyquire('../../lib/move-temp-file', { 'fs/promises': fsSpy })) ctx = { diff --git a/packages/tts-cli/test/program.spec.js b/packages/tts-cli/test/unit/program.spec.js similarity index 92% rename from packages/tts-cli/test/program.spec.js rename to packages/tts-cli/test/unit/program.spec.js index e9b89c2..6aef0a1 100644 --- a/packages/tts-cli/test/program.spec.js +++ b/packages/tts-cli/test/unit/program.spec.js @@ -1,5 +1,5 @@ const proxyquire = require('proxyquire') -const { program: originalProgram } = require('../lib/program') +const { program: originalProgram } = require('../../lib/program') class FakeOption { argParser () { return this } @@ -28,7 +28,7 @@ describe('program', () => { action: (func) => { actionFunc = func }, addOption: () => {} }) - proxyquire('../lib/program', { + proxyquire('../../lib/program', { commander: { Command: commandSpy, Option: FakeOption diff --git a/packages/tts-cli/test/read-text.spec.js b/packages/tts-cli/test/unit/read-text.spec.js similarity index 97% rename from packages/tts-cli/test/read-text.spec.js rename to packages/tts-cli/test/unit/read-text.spec.js index b170a37..c5d1722 100644 --- a/packages/tts-cli/test/read-text.spec.js +++ b/packages/tts-cli/test/unit/read-text.spec.js @@ -9,7 +9,7 @@ describe('readText()', () => { beforeEach(() => { fsSpy = jasmine.createSpyObj('fs', ['readFile']) - ;({ readText } = proxyquire('../lib/read-text', { + ;({ readText } = proxyquire('../../lib/read-text', { 'fs/promises': fsSpy })) }) diff --git a/packages/tts-cli/test/sanitize-opts.spec.js b/packages/tts-cli/test/unit/sanitize-opts.spec.js similarity index 93% rename from packages/tts-cli/test/sanitize-opts.spec.js rename to packages/tts-cli/test/unit/sanitize-opts.spec.js index f129fe0..8846fd8 100644 --- a/packages/tts-cli/test/sanitize-opts.spec.js +++ b/packages/tts-cli/test/unit/sanitize-opts.spec.js @@ -1,4 +1,4 @@ -const { sanitizeOpts } = require('../lib/sanitize-opts') +const { sanitizeOpts } = require('../../lib/sanitize-opts') describe('sanitizeOpts()', () => { const exampleOpts = { foo: 1, From d1887457db0a3a0d4dd78be0bb5ad88365edcae9 Mon Sep 17 00:00:00 2001 From: Eric Heikes Date: Thu, 17 Jul 2025 23:03:27 -0700 Subject: [PATCH 39/48] test: Add E2E tests --- .github/workflows/ci.yaml | 6 +- package-lock.json | 279 ++++++++++++++++++ packages/tts-cli/package.json | 1 + packages/tts-cli/test/e2e/aws.spec.js | 42 ++- packages/tts-cli/test/e2e/gcp.spec.js | 44 +++ packages/tts-cli/test/e2e/helper.js | 15 + .../tts-cli/test/fixtures/lorem-ipsum.txt | 5 + 7 files changed, 389 insertions(+), 3 deletions(-) create mode 100644 packages/tts-cli/test/e2e/gcp.spec.js create mode 100644 packages/tts-cli/test/e2e/helper.js create mode 100644 packages/tts-cli/test/fixtures/lorem-ipsum.txt diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 2d9e65c..8a8c14a 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -24,6 +24,10 @@ jobs: - name: Install dependencies run: npm install - run: npm run lint - - run: npm run test + - run: npm run + env: + AWS_ACCESS_KEY: ${{ secrets.AWS_ACCESS_KEY }} + AWS_SECRET_KEY: ${{ secrets.AWS_SECRET_KEY }} + GCP_PROJECT_FILE: ${{ secrets.GCP_PROJECT_FILE }} - name: Upload coverage report uses: codecov/codecov-action@v2 diff --git a/package-lock.json b/package-lock.json index 412bf4c..5c59ff4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1417,6 +1417,26 @@ "node": ">=10" } }, + "node_modules/@sec-ant/readable-stream": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@sec-ant/readable-stream/-/readable-stream-0.4.1.tgz", + "integrity": "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sindresorhus/merge-streams": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-4.0.0.tgz", + "integrity": "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/@smithy/abort-controller": { "version": "3.1.4", "resolved": "https://registry.npmjs.org/@smithy/abort-controller/-/abort-controller-3.1.4.tgz", @@ -3903,6 +3923,157 @@ "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==" }, + "node_modules/execa": { + "version": "9.6.0", + "resolved": "https://registry.npmjs.org/execa/-/execa-9.6.0.tgz", + "integrity": "sha512-jpWzZ1ZhwUmeWRhS7Qv3mhpOhLfwI+uAX4e5fOcXqwMR7EcJ0pj2kV1CVzHVMX/LphnKWD3LObjZCoJ71lKpHw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sindresorhus/merge-streams": "^4.0.0", + "cross-spawn": "^7.0.6", + "figures": "^6.1.0", + "get-stream": "^9.0.0", + "human-signals": "^8.0.1", + "is-plain-obj": "^4.1.0", + "is-stream": "^4.0.1", + "npm-run-path": "^6.0.0", + "pretty-ms": "^9.2.0", + "signal-exit": "^4.1.0", + "strip-final-newline": "^4.0.0", + "yoctocolors": "^2.1.1" + }, + "engines": { + "node": "^18.19.0 || >=20.5.0" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/execa/node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/execa/node_modules/get-stream": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-9.0.1.tgz", + "integrity": "sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sec-ant/readable-stream": "^0.4.1", + "is-stream": "^4.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/execa/node_modules/is-stream": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-4.0.1.tgz", + "integrity": "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/execa/node_modules/npm-run-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-6.0.0.tgz", + "integrity": "sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^4.0.0", + "unicorn-magic": "^0.3.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/execa/node_modules/npm-run-path/node_modules/path-key": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", + "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/execa/node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/execa/node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/execa/node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/execa/node_modules/strip-final-newline": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-4.0.0.tgz", + "integrity": "sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/extend": { "version": "3.0.2", "license": "MIT" @@ -3987,6 +4158,22 @@ "pend": "~1.2.0" } }, + "node_modules/figures": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/figures/-/figures-6.1.0.tgz", + "integrity": "sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-unicode-supported": "^2.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/file-entry-cache": { "version": "6.0.1", "dev": true, @@ -4565,6 +4752,16 @@ "node": ">= 6.0.0" } }, + "node_modules/human-signals": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-8.0.1.tgz", + "integrity": "sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, "node_modules/ignore": { "version": "5.3.0", "dev": true, @@ -4848,6 +5045,19 @@ "node": ">=8" } }, + "node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-regex": { "version": "1.1.4", "dev": true, @@ -4937,6 +5147,19 @@ "integrity": "sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA==", "dev": true }, + "node_modules/is-unicode-supported": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz", + "integrity": "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-weakmap": { "version": "2.0.1", "dev": true, @@ -6283,6 +6506,19 @@ "node": ">=4" } }, + "node_modules/parse-ms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/parse-ms/-/parse-ms-4.0.0.tgz", + "integrity": "sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/path-exists": { "version": "3.0.0", "dev": true, @@ -6415,6 +6651,22 @@ "node": ">=8" } }, + "node_modules/pretty-ms": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-9.2.0.tgz", + "integrity": "sha512-4yf0QO/sllf/1zbZWYnvWw3NxCQwLXKzIj0G849LSufP15BXKM0rbD2Z3wVnkMfjdn/CB0Dpp444gYAACdsplg==", + "dev": true, + "license": "MIT", + "dependencies": { + "parse-ms": "^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/process-on-spawn": { "version": "1.0.0", "dev": true, @@ -7674,6 +7926,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/unicorn-magic": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.3.0.tgz", + "integrity": "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/unique-string": { "version": "2.0.0", "license": "MIT", @@ -8046,6 +8311,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/yoctocolors": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/yoctocolors/-/yoctocolors-2.1.1.tgz", + "integrity": "sha512-GQHQqAopRhwU8Kt1DDM8NjibDXHC8eoh1erhGAJPEyveY9qqVeXvVikNKrDz69sHowPMorbPUrH/mx8c50eiBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/zen-observable": { "version": "0.10.0", "license": "MIT" @@ -8080,6 +8358,7 @@ "devDependencies": { "codecov": "^3.6.5", "cross-env": "^5.2.0", + "execa": "^9.6.0", "jasmine": "^3.5.0", "nyc": "^15.0.1", "proxyquire": "^2.1.3", diff --git a/packages/tts-cli/package.json b/packages/tts-cli/package.json index 39c448d..e70dbe8 100644 --- a/packages/tts-cli/package.json +++ b/packages/tts-cli/package.json @@ -44,6 +44,7 @@ "devDependencies": { "codecov": "^3.6.5", "cross-env": "^5.2.0", + "execa": "^9.6.0", "jasmine": "^3.5.0", "nyc": "^15.0.1", "proxyquire": "^2.1.3", diff --git a/packages/tts-cli/test/e2e/aws.spec.js b/packages/tts-cli/test/e2e/aws.spec.js index 3160ac9..5fa03d3 100644 --- a/packages/tts-cli/test/e2e/aws.spec.js +++ b/packages/tts-cli/test/e2e/aws.spec.js @@ -1,5 +1,43 @@ +const { copyFile } = require('fs/promises') +const { join } = require('path') +const tempfile = require('tempfile') +const { runWith } = require('./helper') + describe('aws', () => { - it('should stub', () => { - expect(true).toBe(true) + jasmine.DEFAULT_TIMEOUT_INTERVAL = 10000 + + const AWS_ACCESS_KEY = process.env.AWS_ACCESS_KEY + const AWS_SECRET_KEY = process.env.AWS_SECRET_KEY + + const defaults = ['--service', 'aws', '--access-key', AWS_ACCESS_KEY, '--secret-key', AWS_SECRET_KEY] + + let inputFile + let outputFile + let stderr + let stdout + let exitCode + + beforeEach(async () => { + inputFile = tempfile() + outputFile = tempfile() + await copyFile(join(__dirname, '../fixtures/lorem-ipsum.txt'), inputFile) + }) + + it('should succeed', async () => { + ({ exitCode, stdout, stderr } = await runWith(inputFile, outputFile, ...defaults)) + expect(stdout).toContain('❯ Reading text') + expect(stdout).toContain('✔ Reading text') + expect(stdout).toContain('❯ Splitting text') + expect(stdout).toContain('✔ Splitting text') + expect(stdout).toContain('❯ Convert to audio') + expect(stdout).toContain('✔ Convert to audio') + expect(stdout).toContain('❯ Combine audio') + expect(stdout).toContain('✔ Combine audio') + expect(stdout).toContain('❯ Clean up') + expect(stdout).toContain('✔ Clean up') + expect(stdout).toContain('❯ Saving file') + expect(stdout).toContain(`✔ Done. Saved to ${outputFile}`) + expect(stderr).toBe('') + expect(exitCode).toBe(0) }) }) diff --git a/packages/tts-cli/test/e2e/gcp.spec.js b/packages/tts-cli/test/e2e/gcp.spec.js new file mode 100644 index 0000000..f5c2495 --- /dev/null +++ b/packages/tts-cli/test/e2e/gcp.spec.js @@ -0,0 +1,44 @@ +const { copyFile, writeFile } = require('fs/promises') +const { join } = require('path') +const tempfile = require('tempfile') +const { runWith } = require('./helper') + +describe('gcp', () => { + jasmine.DEFAULT_TIMEOUT_INTERVAL = 30000 + + const GCP_PROJECT_FILE = process.env.GCP_PROJECT_FILE + + const projectFile = tempfile('.json') + const defaults = ['--service', 'gcp', '--project-file', projectFile] + + let inputFile + let outputFile + let stderr + let stdout + let exitCode + + beforeEach(async () => { + await writeFile(projectFile, GCP_PROJECT_FILE, 'utf-8') + inputFile = tempfile() + outputFile = tempfile() + await copyFile(join(__dirname, '../fixtures/lorem-ipsum.txt'), inputFile) + }) + + it('should succeed', async () => { + ({ exitCode, stdout, stderr } = await runWith(inputFile, outputFile, ...defaults)) + expect(stdout).toContain('❯ Reading text') + expect(stdout).toContain('✔ Reading text') + expect(stdout).toContain('❯ Splitting text') + expect(stdout).toContain('✔ Splitting text') + expect(stdout).toContain('❯ Convert to audio') + expect(stdout).toContain('✔ Convert to audio') + expect(stdout).toContain('❯ Combine audio') + expect(stdout).toContain('✔ Combine audio') + expect(stdout).toContain('❯ Clean up') + expect(stdout).toContain('✔ Clean up') + expect(stdout).toContain('❯ Saving file') + expect(stdout).toContain(`✔ Done. Saved to ${outputFile}`) + // expect(stderr).toBe('') // there is a deprecation warning + expect(exitCode).toBe(0) + }) +}) diff --git a/packages/tts-cli/test/e2e/helper.js b/packages/tts-cli/test/e2e/helper.js new file mode 100644 index 0000000..190973e --- /dev/null +++ b/packages/tts-cli/test/e2e/helper.js @@ -0,0 +1,15 @@ +const { execa } = require('execa') + +const runWith = async (...args) => { + return execa( + 'node', + [ + 'tts.js' + ].concat(args), + { cwd: '../../packages/tts-cli' } + ) +} + +module.exports = { + runWith +} diff --git a/packages/tts-cli/test/fixtures/lorem-ipsum.txt b/packages/tts-cli/test/fixtures/lorem-ipsum.txt new file mode 100644 index 0000000..46927ba --- /dev/null +++ b/packages/tts-cli/test/fixtures/lorem-ipsum.txt @@ -0,0 +1,5 @@ +Lorem ipsum dolor sit amet, consectetur adipiscing elit. Duis fermentum pellentesque ex non lacinia. Aliquam mollis augue tellus, non vestibulum libero viverra volutpat. Nam ultricies iaculis nulla in iaculis. Aliquam imperdiet tempus ante, in venenatis lectus volutpat tincidunt. Maecenas quis eleifend enim. Vestibulum posuere dolor non nibh mollis, quis aliquet nisi commodo. Donec vel vestibulum mauris, vel dapibus mauris. + +In consectetur elementum leo, at molestie quam porttitor vitae. Donec sed tellus ipsum. Pellentesque nec varius magna. Donec tincidunt congue ipsum eu molestie. Vivamus sit amet massa urna. In a euismod nunc. Suspendisse magna velit, elementum eu libero quis, consectetur tristique dolor. Donec at ante tortor. Pellentesque scelerisque fermentum odio, accumsan fermentum lectus porttitor a. Mauris molestie sem non convallis cursus. + +Vivamus sollicitudin pretium diam in lobortis. Nullam id accumsan lectus. Suspendisse aliquam lobortis sapien non tristique. Suspendisse dictum commodo nulla vitae varius. Nulla sit amet lectus gravida, blandit ipsum at, euismod mi. Praesent tincidunt hendrerit neque, in eleifend eros. Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos. Curabitur posuere erat feugiat sem vehicula congue. From e7188ae351213115acd24c70d0edc6ebd2c69870 Mon Sep 17 00:00:00 2001 From: Eric Heikes Date: Thu, 17 Jul 2025 23:07:24 -0700 Subject: [PATCH 40/48] test(gcp): Fix linting error --- packages/tts-cli/test/e2e/gcp.spec.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/tts-cli/test/e2e/gcp.spec.js b/packages/tts-cli/test/e2e/gcp.spec.js index f5c2495..f96ce1a 100644 --- a/packages/tts-cli/test/e2e/gcp.spec.js +++ b/packages/tts-cli/test/e2e/gcp.spec.js @@ -38,7 +38,7 @@ describe('gcp', () => { expect(stdout).toContain('✔ Clean up') expect(stdout).toContain('❯ Saving file') expect(stdout).toContain(`✔ Done. Saved to ${outputFile}`) - // expect(stderr).toBe('') // there is a deprecation warning + expect(stderr).toEqual(jasmine.any(String)) // there is a deprecation warning expect(exitCode).toBe(0) }) }) From 49f7f3e254c1b13e16158766fd898756f581f77a Mon Sep 17 00:00:00 2001 From: Eric Heikes Date: Thu, 17 Jul 2025 23:13:39 -0700 Subject: [PATCH 41/48] ci(github): Fix typo --- .github/workflows/ci.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 8a8c14a..84d6582 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -24,7 +24,7 @@ jobs: - name: Install dependencies run: npm install - run: npm run lint - - run: npm run + - run: npm run test env: AWS_ACCESS_KEY: ${{ secrets.AWS_ACCESS_KEY }} AWS_SECRET_KEY: ${{ secrets.AWS_SECRET_KEY }} From ef167f5abadd995c349f7f612ea880ebe996b272 Mon Sep 17 00:00:00 2001 From: Eric Heikes Date: Thu, 17 Jul 2025 23:24:46 -0700 Subject: [PATCH 42/48] test: Switch to `await import` to load execa It's an ESM-only package. Use this until this project is converted to ESM. --- packages/tts-cli/test/e2e/helper.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/tts-cli/test/e2e/helper.js b/packages/tts-cli/test/e2e/helper.js index 190973e..ffe5f35 100644 --- a/packages/tts-cli/test/e2e/helper.js +++ b/packages/tts-cli/test/e2e/helper.js @@ -1,6 +1,5 @@ -const { execa } = require('execa') - const runWith = async (...args) => { + const { execa } = await import('execa') return execa( 'node', [ From 7e5393b711ebd426a6a2b160756208576dd3777b Mon Sep 17 00:00:00 2001 From: Eric Heikes Date: Fri, 18 Jul 2025 16:24:06 -0700 Subject: [PATCH 43/48] test(e2e): Use mock ffmpeg binary --- packages/tts-cli/test/e2e/aws.spec.js | 8 +++++++- packages/tts-cli/test/e2e/gcp.spec.js | 7 ++++++- packages/tts-cli/test/fixtures/ffmpeg-mock.sh | 6 ++++++ 3 files changed, 19 insertions(+), 2 deletions(-) create mode 100755 packages/tts-cli/test/fixtures/ffmpeg-mock.sh diff --git a/packages/tts-cli/test/e2e/aws.spec.js b/packages/tts-cli/test/e2e/aws.spec.js index 5fa03d3..3ceea7f 100644 --- a/packages/tts-cli/test/e2e/aws.spec.js +++ b/packages/tts-cli/test/e2e/aws.spec.js @@ -8,8 +8,14 @@ describe('aws', () => { const AWS_ACCESS_KEY = process.env.AWS_ACCESS_KEY const AWS_SECRET_KEY = process.env.AWS_SECRET_KEY + const ffmpegBinary = join(__dirname, '../fixtures/ffmpeg-mock.sh') - const defaults = ['--service', 'aws', '--access-key', AWS_ACCESS_KEY, '--secret-key', AWS_SECRET_KEY] + const defaults = [ + '--service', 'aws', + '--ffmpeg', ffmpegBinary, + '--access-key', AWS_ACCESS_KEY, + '--secret-key', AWS_SECRET_KEY + ] let inputFile let outputFile diff --git a/packages/tts-cli/test/e2e/gcp.spec.js b/packages/tts-cli/test/e2e/gcp.spec.js index f96ce1a..3b711c3 100644 --- a/packages/tts-cli/test/e2e/gcp.spec.js +++ b/packages/tts-cli/test/e2e/gcp.spec.js @@ -7,9 +7,14 @@ describe('gcp', () => { jasmine.DEFAULT_TIMEOUT_INTERVAL = 30000 const GCP_PROJECT_FILE = process.env.GCP_PROJECT_FILE + const ffmpegBinary = join(__dirname, '../fixtures/ffmpeg-mock.sh') const projectFile = tempfile('.json') - const defaults = ['--service', 'gcp', '--project-file', projectFile] + const defaults = [ + '--service', 'gcp', + '--ffmpeg', ffmpegBinary, + '--project-file', projectFile + ] let inputFile let outputFile diff --git a/packages/tts-cli/test/fixtures/ffmpeg-mock.sh b/packages/tts-cli/test/fixtures/ffmpeg-mock.sh new file mode 100755 index 0000000..061d685 --- /dev/null +++ b/packages/tts-cli/test/fixtures/ffmpeg-mock.sh @@ -0,0 +1,6 @@ +#!/bin/sh +echo "Running fake ffmpeg with \"$*\"" + +# Make sure the output file exists. +for last; do true; done # set $last to the last argument +touch "$last" From 266666811fa4124c2f0d410b4348d6fc9506e984 Mon Sep 17 00:00:00 2001 From: Eric Heikes Date: Sat, 19 Jul 2025 09:46:10 -0700 Subject: [PATCH 44/48] test: Temporary debugging output --- packages/tts-cli/test/e2e/aws.spec.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/tts-cli/test/e2e/aws.spec.js b/packages/tts-cli/test/e2e/aws.spec.js index 3ceea7f..2968ea4 100644 --- a/packages/tts-cli/test/e2e/aws.spec.js +++ b/packages/tts-cli/test/e2e/aws.spec.js @@ -29,8 +29,10 @@ describe('aws', () => { await copyFile(join(__dirname, '../fixtures/lorem-ipsum.txt'), inputFile) }) - it('should succeed', async () => { + fit('should succeed', async () => { ({ exitCode, stdout, stderr } = await runWith(inputFile, outputFile, ...defaults)) + console.log('**stdout:', Buffer.from(stdout)) + console.log('**comparison:', Buffer.from('❯ Reading text')) expect(stdout).toContain('❯ Reading text') expect(stdout).toContain('✔ Reading text') expect(stdout).toContain('❯ Splitting text') From da077ac9c03565b157ff87b6bdded83c967e0109 Mon Sep 17 00:00:00 2001 From: Eric Heikes Date: Sat, 19 Jul 2025 10:15:01 -0700 Subject: [PATCH 45/48] test(e2e): Turn off terminal color --- packages/tts-cli/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/tts-cli/package.json b/packages/tts-cli/package.json index e70dbe8..2da24d5 100644 --- a/packages/tts-cli/package.json +++ b/packages/tts-cli/package.json @@ -15,7 +15,7 @@ "lint": "standard --env jasmine --fix --verbose", "report-coverage": "codecov", "test": "npm run test:unit && npm run test:e2e", - "test:e2e": "cross-env JASMINE_CONFIG_PATH=test/e2e.json jasmine", + "test:e2e": "cross-env JASMINE_CONFIG_PATH=test/e2e.json NO_COLOR=1 jasmine", "test:unit": "cross-env JASMINE_CONFIG_PATH=test/unit.json nyc jasmine" }, "repository": { From a63bd9990c3c6c7c7c49065c19faf3f2e3d2aa15 Mon Sep 17 00:00:00 2001 From: Eric Heikes Date: Sat, 19 Jul 2025 10:19:20 -0700 Subject: [PATCH 46/48] Revert "test: Temporary debugging output" This reverts commit 266666811fa4124c2f0d410b4348d6fc9506e984. --- packages/tts-cli/test/e2e/aws.spec.js | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/tts-cli/test/e2e/aws.spec.js b/packages/tts-cli/test/e2e/aws.spec.js index 2968ea4..3ceea7f 100644 --- a/packages/tts-cli/test/e2e/aws.spec.js +++ b/packages/tts-cli/test/e2e/aws.spec.js @@ -29,10 +29,8 @@ describe('aws', () => { await copyFile(join(__dirname, '../fixtures/lorem-ipsum.txt'), inputFile) }) - fit('should succeed', async () => { + it('should succeed', async () => { ({ exitCode, stdout, stderr } = await runWith(inputFile, outputFile, ...defaults)) - console.log('**stdout:', Buffer.from(stdout)) - console.log('**comparison:', Buffer.from('❯ Reading text')) expect(stdout).toContain('❯ Reading text') expect(stdout).toContain('✔ Reading text') expect(stdout).toContain('❯ Splitting text') From 4634a3e2a4bdacf8f5da6a4e7fb9f2e4fcf4acb2 Mon Sep 17 00:00:00 2001 From: Eric Heikes Date: Sat, 19 Jul 2025 13:14:26 -0700 Subject: [PATCH 47/48] npm: Add husky pre-commit hooks --- package-lock.json | 18 ++++++++++++++++++ packages/tts-cli/.husky/pre-commit | 1 + packages/tts-cli/package.json | 2 ++ packages/tts-lib/.husky/pre-commit | 1 + packages/tts-lib/package.json | 2 ++ 5 files changed, 24 insertions(+) create mode 100644 packages/tts-cli/.husky/pre-commit create mode 100644 packages/tts-lib/.husky/pre-commit diff --git a/package-lock.json b/package-lock.json index 5c59ff4..fecd82b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4762,6 +4762,22 @@ "node": ">=18.18.0" } }, + "node_modules/husky": { + "version": "9.1.7", + "resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz", + "integrity": "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==", + "dev": true, + "license": "MIT", + "bin": { + "husky": "bin.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/typicode" + } + }, "node_modules/ignore": { "version": "5.3.0", "dev": true, @@ -8359,6 +8375,7 @@ "codecov": "^3.6.5", "cross-env": "^5.2.0", "execa": "^9.6.0", + "husky": "^9.1.7", "jasmine": "^3.5.0", "nyc": "^15.0.1", "proxyquire": "^2.1.3", @@ -8400,6 +8417,7 @@ "devDependencies": { "codecov": "^3.6.5", "cross-env": "^5.2.0", + "husky": "^9.1.7", "jasmine": "^3.5.0", "nyc": "^15.0.1", "proxyquire": "^2.1.3", diff --git a/packages/tts-cli/.husky/pre-commit b/packages/tts-cli/.husky/pre-commit new file mode 100644 index 0000000..3867a0f --- /dev/null +++ b/packages/tts-cli/.husky/pre-commit @@ -0,0 +1 @@ +npm run lint diff --git a/packages/tts-cli/package.json b/packages/tts-cli/package.json index 2da24d5..fca59e9 100644 --- a/packages/tts-cli/package.json +++ b/packages/tts-cli/package.json @@ -12,6 +12,7 @@ "yarn.lock" ], "scripts": { + "prepare": "husky", "lint": "standard --env jasmine --fix --verbose", "report-coverage": "codecov", "test": "npm run test:unit && npm run test:e2e", @@ -45,6 +46,7 @@ "codecov": "^3.6.5", "cross-env": "^5.2.0", "execa": "^9.6.0", + "husky": "^9.1.7", "jasmine": "^3.5.0", "nyc": "^15.0.1", "proxyquire": "^2.1.3", diff --git a/packages/tts-lib/.husky/pre-commit b/packages/tts-lib/.husky/pre-commit new file mode 100644 index 0000000..3867a0f --- /dev/null +++ b/packages/tts-lib/.husky/pre-commit @@ -0,0 +1 @@ +npm run lint diff --git a/packages/tts-lib/package.json b/packages/tts-lib/package.json index 732bfc1..9eb9da4 100644 --- a/packages/tts-lib/package.json +++ b/packages/tts-lib/package.json @@ -20,6 +20,7 @@ "url": "git+https://github.com/eheikes/tts.git" }, "scripts": { + "prepare": "husky", "lint": "standard --env jasmine --fix --verbose", "report-coverage": "codecov", "test": "cross-env JASMINE_CONFIG_PATH=test/jasmine.json nyc jasmine" @@ -40,6 +41,7 @@ "devDependencies": { "codecov": "^3.6.5", "cross-env": "^5.2.0", + "husky": "^9.1.7", "jasmine": "^3.5.0", "nyc": "^15.0.1", "proxyquire": "^2.1.3", From aba5c17f09ea1756a3be94f9a38b5bda45640f0d Mon Sep 17 00:00:00 2001 From: Eric Heikes Date: Sat, 19 Jul 2025 13:29:08 -0700 Subject: [PATCH 48/48] build(deps): Bump GCP lib to latest version --- package-lock.json | 630 +++++++++++++++++++++----- packages/tts-cli/test/e2e/gcp.spec.js | 2 +- packages/tts-lib/package.json | 2 +- 3 files changed, 522 insertions(+), 112 deletions(-) diff --git a/package-lock.json b/package-lock.json index fecd82b..f3e3ca2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1114,34 +1114,39 @@ } }, "node_modules/@google-cloud/text-to-speech": { - "version": "5.0.1", + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/@google-cloud/text-to-speech/-/text-to-speech-6.2.0.tgz", + "integrity": "sha512-CsmGFNZVL0mPrgcrkBPJ5LB8TPEKiPsER0N5h3nAkkPIeyAGGn3YwuRWyQhDYI5/XWLp11LlDEWqL8B14aYtNg==", "license": "Apache-2.0", "dependencies": { - "google-gax": "^4.0.3" + "google-gax": "^5.0.0" }, "engines": { - "node": ">=14.0.0" + "node": ">=18" } }, "node_modules/@grpc/grpc-js": { - "version": "1.9.15", - "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.9.15.tgz", - "integrity": "sha512-nqE7Hc0AzI+euzUwDAy0aY5hCp10r734gMGRdU+qOPX0XSceI2ULrcXB5U2xSc5VkWwalCj4M7GzCAygZl2KoQ==", + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.13.4.tgz", + "integrity": "sha512-GsFaMXCkMqkKIvwCQjCrwH+GHbPKBjhwo/8ZuUkWHqbI73Kky9I+pQltrlT0+MWpedCoosda53lgjYfyEPgxBg==", + "license": "Apache-2.0", "dependencies": { - "@grpc/proto-loader": "^0.7.8", - "@types/node": ">=12.12.47" + "@grpc/proto-loader": "^0.7.13", + "@js-sdsl/ordered-map": "^4.4.2" }, "engines": { - "node": "^8.13.0 || >=10.10.0" + "node": ">=12.10.0" } }, "node_modules/@grpc/proto-loader": { - "version": "0.7.10", + "version": "0.7.15", + "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.7.15.tgz", + "integrity": "sha512-tMXdRCfYVixjuFK+Hk0Q1s38gV9zDiDJfWL3h1rv4Qc39oILCu1TRTDt7+fGUI8K4G1Fj125Hx/ru3azECWTyQ==", "license": "Apache-2.0", "dependencies": { "lodash.camelcase": "^4.3.0", "long": "^5.0.0", - "protobufjs": "^7.2.4", + "protobufjs": "^7.2.5", "yargs": "^17.7.2" }, "bin": { @@ -1308,6 +1313,16 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@js-sdsl/ordered-map": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/@js-sdsl/ordered-map/-/ordered-map-4.4.2.tgz", + "integrity": "sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/js-sdsl" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "dev": true, @@ -1342,22 +1357,32 @@ }, "node_modules/@protobufjs/aspromise": { "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", "license": "BSD-3-Clause" }, "node_modules/@protobufjs/base64": { "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", "license": "BSD-3-Clause" }, "node_modules/@protobufjs/codegen": { "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.4.tgz", + "integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==", "license": "BSD-3-Clause" }, "node_modules/@protobufjs/eventemitter": { "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz", + "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==", "license": "BSD-3-Clause" }, "node_modules/@protobufjs/fetch": { "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz", + "integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==", "license": "BSD-3-Clause", "dependencies": { "@protobufjs/aspromise": "^1.1.1", @@ -1366,22 +1391,32 @@ }, "node_modules/@protobufjs/float": { "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", "license": "BSD-3-Clause" }, "node_modules/@protobufjs/inquire": { "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.0.tgz", + "integrity": "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==", "license": "BSD-3-Clause" }, "node_modules/@protobufjs/path": { "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", "license": "BSD-3-Clause" }, "node_modules/@protobufjs/pool": { "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", "license": "BSD-3-Clause" }, "node_modules/@protobufjs/utf8": { "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz", + "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==", "license": "BSD-3-Clause" }, "node_modules/@puppeteer/browsers": { @@ -2024,6 +2059,8 @@ }, "node_modules/@types/caseless": { "version": "0.12.5", + "resolved": "https://registry.npmjs.org/@types/caseless/-/caseless-0.12.5.tgz", + "integrity": "sha512-hWtVTC2q7hc7xZ/RLbxapMvDMgUnDvKvMOpKal4DrMyfGBUfB1oKaZlIRr6mJL+If3bAP6sV/QneGzF6tJjZDg==", "license": "MIT" }, "node_modules/@types/js-yaml": { @@ -2036,10 +2073,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/long": { - "version": "4.0.2", - "license": "MIT" - }, "node_modules/@types/minimist": { "version": "1.2.0", "dev": true, @@ -2050,7 +2083,9 @@ "license": "MIT" }, "node_modules/@types/request": { - "version": "2.48.11", + "version": "2.48.12", + "resolved": "https://registry.npmjs.org/@types/request/-/request-2.48.12.tgz", + "integrity": "sha512-G3sY+NpsA9jnwm0ixhAFQSJ3Q9JkpLZpJbI3GMv0mIAT0y3mRabYeINzal5WOChIiaTEGQYlHOKgkaM9EisWHw==", "license": "MIT", "dependencies": { "@types/caseless": "*", @@ -2060,7 +2095,9 @@ } }, "node_modules/@types/tough-cookie": { - "version": "4.0.4", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz", + "integrity": "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==", "license": "MIT" }, "node_modules/@types/yauzl": { @@ -2080,6 +2117,8 @@ }, "node_modules/abort-controller": { "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", "license": "MIT", "dependencies": { "event-target-shim": "^5.0.0" @@ -2330,6 +2369,8 @@ }, "node_modules/asynckit": { "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", "license": "MIT" }, "node_modules/available-typed-arrays": { @@ -2427,7 +2468,23 @@ } }, "node_modules/base64-js": { - "version": "1.3.0", + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], "license": "MIT" }, "node_modules/basic-ftp": { @@ -2440,7 +2497,9 @@ } }, "node_modules/bignumber.js": { - "version": "9.1.2", + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", + "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", "license": "MIT", "engines": { "node": "*" @@ -2509,6 +2568,8 @@ }, "node_modules/buffer-equal-constant-time": { "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", "license": "BSD-3-Clause" }, "node_modules/buffer-from": { @@ -2566,6 +2627,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/camelcase": { "version": "5.3.1", "dev": true, @@ -2685,6 +2759,8 @@ }, "node_modules/combined-stream": { "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", "license": "MIT", "dependencies": { "delayed-stream": "~1.0.0" @@ -2956,6 +3032,8 @@ }, "node_modules/delayed-stream": { "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", "license": "MIT", "engines": { "node": ">=0.4.0" @@ -2986,18 +3064,36 @@ "node": ">=6.0.0" } }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/duplexify": { - "version": "4.1.2", + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/duplexify/-/duplexify-4.1.3.tgz", + "integrity": "sha512-M3BmBhwJRZsSx38lZyhE53Csddgzl5R7xGJNk7CVddZD6CcmwMCH8J+7AprIrQKH7TonKxaCjcv27Qmf+sQ+oA==", "license": "MIT", "dependencies": { "end-of-stream": "^1.4.1", "inherits": "^2.0.3", "readable-stream": "^3.1.1", - "stream-shift": "^1.0.0" + "stream-shift": "^1.0.2" } }, "node_modules/ecdsa-sig-formatter": { "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", "license": "Apache-2.0", "dependencies": { "safe-buffer": "^5.0.1" @@ -3015,6 +3111,7 @@ }, "node_modules/encoding": { "version": "0.1.13", + "dev": true, "license": "MIT", "optional": true, "peer": true, @@ -3024,6 +3121,7 @@ }, "node_modules/encoding/node_modules/iconv-lite": { "version": "0.6.2", + "dev": true, "license": "MIT", "optional": true, "peer": true, @@ -3109,6 +3207,24 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/es-iterator-helpers": { "version": "1.0.15", "dev": true, @@ -3130,14 +3246,28 @@ "safe-array-concat": "^1.0.1" } }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/es-set-tostringtag": { - "version": "2.0.2", - "dev": true, + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", "license": "MIT", "dependencies": { - "get-intrinsic": "^1.2.2", - "has-tostringtag": "^1.0.0", - "hasown": "^2.0.0" + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" }, "engines": { "node": ">= 0.4" @@ -3913,6 +4043,8 @@ }, "node_modules/event-target-shim": { "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", "license": "MIT", "engines": { "node": ">=6" @@ -4076,6 +4208,8 @@ }, "node_modules/extend": { "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", "license": "MIT" }, "node_modules/extract-zip": { @@ -4158,6 +4292,29 @@ "pend": "~1.2.0" } }, + "node_modules/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + } + }, "node_modules/figures": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/figures/-/figures-6.1.0.tgz", @@ -4300,17 +4457,54 @@ } }, "node_modules/form-data": { - "version": "2.5.1", + "version": "2.5.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.5.5.tgz", + "integrity": "sha512-jqdObeR2rxZZbPSGL+3VckHMYtu+f9//KXBsVny6JSX/pa38Fy+bGjuG8eW/H6USNQWhLi8Num++cU2yOCNz4A==", "license": "MIT", "dependencies": { "asynckit": "^0.4.0", - "combined-stream": "^1.0.6", - "mime-types": "^2.1.12" + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.35", + "safe-buffer": "^5.2.1" }, "engines": { "node": ">= 0.12" } }, + "node_modules/form-data/node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "license": "MIT", + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/fromentries": { "version": "1.2.1", "dev": true, @@ -4337,7 +4531,6 @@ }, "node_modules/function-bind": { "version": "1.1.2", - "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" @@ -4369,48 +4562,80 @@ } }, "node_modules/gaxios": { - "version": "6.1.1", + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.1.tgz", + "integrity": "sha512-Odju3uBUJyVCkW64nLD4wKLhbh93bh6vIg/ZIXkWiLPBrdgtc65+tls/qml+un3pr6JqYVFDZbbmLDQT68rTOQ==", "license": "Apache-2.0", "dependencies": { "extend": "^3.0.2", "https-proxy-agent": "^7.0.1", - "is-stream": "^2.0.0", - "node-fetch": "^2.6.9" + "node-fetch": "^3.3.2" }, "engines": { - "node": ">=14" + "node": ">=18" } }, "node_modules/gaxios/node_modules/agent-base": { - "version": "7.1.0", + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", "license": "MIT", - "dependencies": { - "debug": "^4.3.4" - }, "engines": { "node": ">= 14" } }, + "node_modules/gaxios/node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/gaxios/node_modules/https-proxy-agent": { - "version": "7.0.2", + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", "license": "MIT", "dependencies": { - "agent-base": "^7.0.2", + "agent-base": "^7.1.2", "debug": "4" }, "engines": { "node": ">= 14" } }, + "node_modules/gaxios/node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, "node_modules/gcp-metadata": { - "version": "6.0.0", + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-7.0.1.tgz", + "integrity": "sha512-UcO3kefx6dCcZkgcTGgVOTFb7b1LlQ02hY1omMjjrrBzkajRMCFgYOjs7J71WqnuG1k2b+9ppGL7FsOfhZMQKQ==", "license": "Apache-2.0", "dependencies": { - "gaxios": "^6.0.0", + "gaxios": "^7.0.0", + "google-logging-utils": "^1.0.0", "json-bigint": "^1.0.0" }, "engines": { - "node": ">=14" + "node": ">=18" } }, "node_modules/gensync": { @@ -4441,14 +4666,24 @@ } }, "node_modules/get-intrinsic": { - "version": "1.2.2", - "dev": true, + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", "license": "MIT", "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", - "has-proto": "^1.0.1", - "has-symbols": "^1.0.3", - "hasown": "^2.0.0" + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -4462,6 +4697,19 @@ "node": ">=8.0.0" } }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/get-stdin": { "version": "8.0.0", "dev": true, @@ -4558,46 +4806,88 @@ } }, "node_modules/google-auth-library": { - "version": "9.2.0", + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.1.0.tgz", + "integrity": "sha512-GspVjZj1RbyRWpQ9FbAXMKjFGzZwDKnUHi66JJ+tcjcu5/xYAP1pdlWotCuIkMwjfVsxxDvsGZXGLzRt72D0sQ==", "license": "Apache-2.0", "dependencies": { "base64-js": "^1.3.0", "ecdsa-sig-formatter": "^1.0.11", - "gaxios": "^6.0.0", - "gcp-metadata": "^6.0.0", - "gtoken": "^7.0.0", + "gaxios": "^7.0.0", + "gcp-metadata": "^7.0.0", + "google-logging-utils": "^1.0.0", + "gtoken": "^8.0.0", "jws": "^4.0.0" }, "engines": { - "node": ">=14" + "node": ">=18" } }, "node_modules/google-gax": { - "version": "4.0.5", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/google-gax/-/google-gax-5.0.1.tgz", + "integrity": "sha512-I8fTFXvIG8tYpiDxDXwCXoFsTVsvHJ2GA7DToH+eaRccU8r3nqPMFghVb2GdHSVcu4pq9ScRyB2S1BjO+vsa1Q==", "license": "Apache-2.0", "dependencies": { - "@grpc/grpc-js": "~1.9.6", - "@grpc/proto-loader": "^0.7.0", - "@types/long": "^4.0.0", + "@grpc/grpc-js": "^1.12.6", + "@grpc/proto-loader": "^0.7.13", "abort-controller": "^3.0.0", - "duplexify": "^4.0.0", - "google-auth-library": "^9.0.0", - "node-fetch": "^2.6.1", + "duplexify": "^4.1.3", + "google-auth-library": "^10.1.0", + "google-logging-utils": "^1.1.1", + "node-fetch": "^3.3.2", "object-hash": "^3.0.0", - "proto3-json-serializer": "^2.0.0", - "protobufjs": "7.2.5", - "retry-request": "^7.0.0" + "proto3-json-serializer": "^3.0.0", + "protobufjs": "^7.5.3", + "retry-request": "^8.0.0" }, + "engines": { + "node": ">=18" + } + }, + "node_modules/google-gax/node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/google-gax/node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, + "node_modules/google-logging-utils": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.1.tgz", + "integrity": "sha512-rcX58I7nqpu4mbKztFeOAObbomBbHU2oIb/d3tJfF3dizGSApqtSwYJigGCooHdnMyQBIw8BrWyK96w3YXgr6A==", + "license": "Apache-2.0", "engines": { "node": ">=14" } }, "node_modules/gopd": { - "version": "1.0.1", - "dev": true, + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", "license": "MIT", - "dependencies": { - "get-intrinsic": "^1.1.3" + "engines": { + "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -4614,14 +4904,16 @@ "license": "MIT" }, "node_modules/gtoken": { - "version": "7.0.1", + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/gtoken/-/gtoken-8.0.0.tgz", + "integrity": "sha512-+CqsMbHPiSTdtSO14O51eMNlrp9N79gmeqmXeouJOhfucAedHw9noVe/n5uJk3tbKE6a+6ZCQg3RPhVhHByAIw==", "license": "MIT", "dependencies": { - "gaxios": "^6.0.0", + "gaxios": "^7.0.0", "jws": "^4.0.0" }, "engines": { - "node": ">=14.0.0" + "node": ">=18" } }, "node_modules/has-bigints": { @@ -4662,8 +4954,9 @@ } }, "node_modules/has-symbols": { - "version": "1.0.3", - "dev": true, + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", "license": "MIT", "engines": { "node": ">= 0.4" @@ -4673,11 +4966,12 @@ } }, "node_modules/has-tostringtag": { - "version": "1.0.0", - "dev": true, + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", "license": "MIT", "dependencies": { - "has-symbols": "^1.0.2" + "has-symbols": "^1.0.3" }, "engines": { "node": ">= 0.4" @@ -4703,8 +4997,9 @@ } }, "node_modules/hasown": { - "version": "2.0.0", - "dev": true, + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -5408,6 +5703,8 @@ }, "node_modules/json-bigint": { "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", + "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", "license": "MIT", "dependencies": { "bignumber.js": "^9.0.0" @@ -5463,16 +5760,20 @@ } }, "node_modules/jwa": { - "version": "2.0.0", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", "license": "MIT", "dependencies": { - "buffer-equal-constant-time": "1.0.1", + "buffer-equal-constant-time": "^1.0.1", "ecdsa-sig-formatter": "1.0.11", "safe-buffer": "^5.0.1" } }, "node_modules/jws": { "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.0.tgz", + "integrity": "sha512-KDncfTmOZoOMTFG4mBlG0qUIOlc03fmzH+ru6RgYVZhPkyiy/92Owlt/8UEN+a4TXR1FQetfIpJE8ApdvdVxTg==", "license": "MIT", "dependencies": { "jwa": "^2.0.0", @@ -5761,6 +6062,8 @@ }, "node_modules/lodash.camelcase": { "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", + "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==", "license": "MIT" }, "node_modules/lodash.flattendeep": { @@ -5775,7 +6078,9 @@ "license": "MIT" }, "node_modules/long": { - "version": "5.2.3", + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", "license": "Apache-2.0" }, "node_modules/loose-envify": { @@ -5819,6 +6124,15 @@ "dev": true, "license": "ISC" }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/memorystream": { "version": "0.3.1", "dev": true, @@ -5837,6 +6151,8 @@ }, "node_modules/mime-db": { "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", "license": "MIT", "engines": { "node": ">= 0.6" @@ -5844,6 +6160,8 @@ }, "node_modules/mime-types": { "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", "license": "MIT", "dependencies": { "mime-db": "1.52.0" @@ -5913,8 +6231,29 @@ "dev": true, "license": "MIT" }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" + } + }, "node_modules/node-fetch": { "version": "2.7.0", + "dev": true, "license": "MIT", "dependencies": { "whatwg-url": "^5.0.0" @@ -6189,6 +6528,8 @@ }, "node_modules/object-hash": { "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", "license": "MIT", "engines": { "node": ">= 6" @@ -6714,17 +7055,21 @@ } }, "node_modules/proto3-json-serializer": { - "version": "2.0.0", + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/proto3-json-serializer/-/proto3-json-serializer-3.0.1.tgz", + "integrity": "sha512-Rug90pDIefARAG9MgaFjd0yR/YP4bN3Fov00kckXMjTZa0x86c4WoWfCQFdSeWi9DvRXjhfLlPDIvODB5LOTfg==", "license": "Apache-2.0", "dependencies": { - "protobufjs": "^7.0.0" + "protobufjs": "^7.4.0" }, "engines": { - "node": ">=14.0.0" + "node": ">=18" } }, "node_modules/protobufjs": { - "version": "7.2.5", + "version": "7.5.3", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.3.tgz", + "integrity": "sha512-sildjKwVqOI2kmFDiXQ6aEB0fjYTafpEvIBs8tOR8qI4spuL9OPROLVu2qZqi/xgCfsHIwVqlaF8JBjWFHnKbw==", "hasInstallScript": true, "license": "BSD-3-Clause", "dependencies": { @@ -6912,7 +7257,9 @@ } }, "node_modules/readable-stream": { - "version": "3.4.0", + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", "license": "MIT", "dependencies": { "inherits": "^2.0.3", @@ -7019,27 +7366,41 @@ } }, "node_modules/retry-request": { - "version": "7.0.1", + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/retry-request/-/retry-request-8.0.0.tgz", + "integrity": "sha512-dJkZNmyV9C8WKUmbdj1xcvVlXBSvsUQCkg89TCK8rD72RdSn9A2jlXlS2VuYSTHoPJjJEfUHhjNYrlvuksF9cg==", "license": "MIT", "dependencies": { - "@types/request": "^2.48.8", - "debug": "^4.1.1", + "@types/request": "^2.48.12", "extend": "^3.0.2", - "teeny-request": "^9.0.0" + "teeny-request": "^10.0.0" }, "engines": { - "node": ">=14" + "node": ">=18" } }, "node_modules/retry-request/node_modules/@tootallnate/once": { "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz", + "integrity": "sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==", "license": "MIT", "engines": { "node": ">= 10" } }, + "node_modules/retry-request/node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/retry-request/node_modules/http-proxy-agent": { "version": "5.0.0", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz", + "integrity": "sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==", "license": "MIT", "dependencies": { "@tootallnate/once": "2", @@ -7052,6 +7413,8 @@ }, "node_modules/retry-request/node_modules/https-proxy-agent": { "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", "license": "MIT", "dependencies": { "agent-base": "6", @@ -7061,29 +7424,37 @@ "node": ">= 6" } }, + "node_modules/retry-request/node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, "node_modules/retry-request/node_modules/teeny-request": { - "version": "9.0.0", + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/teeny-request/-/teeny-request-10.1.0.tgz", + "integrity": "sha512-3ZnLvgWF29jikg1sAQ1g0o+lr5JX6sVgYvfUJazn7ZjJroDBUTWp44/+cFVX0bULjv4vci+rBD+oGVAkWqhUbw==", "license": "Apache-2.0", "dependencies": { "http-proxy-agent": "^5.0.0", "https-proxy-agent": "^5.0.0", - "node-fetch": "^2.6.9", - "stream-events": "^1.0.5", - "uuid": "^9.0.0" + "node-fetch": "^3.3.2", + "stream-events": "^1.0.5" }, "engines": { - "node": ">=14" - } - }, - "node_modules/retry-request/node_modules/uuid": { - "version": "9.0.1", - "funding": [ - "https://github.com/sponsors/broofa", - "https://github.com/sponsors/ctavan" - ], - "license": "MIT", - "bin": { - "uuid": "dist/bin/uuid" + "node": ">=18" } }, "node_modules/reusify": { @@ -7160,6 +7531,7 @@ }, "node_modules/safer-buffer": { "version": "2.1.2", + "dev": true, "license": "MIT", "optional": true, "peer": true @@ -7444,7 +7816,9 @@ } }, "node_modules/stream-shift": { - "version": "1.0.1", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/stream-shift/-/stream-shift-1.0.3.tgz", + "integrity": "sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ==", "license": "MIT" }, "node_modules/streamx": { @@ -7461,12 +7835,34 @@ } }, "node_modules/string_decoder": { - "version": "1.2.0", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", "license": "MIT", "dependencies": { - "safe-buffer": "~5.1.0" + "safe-buffer": "~5.2.0" } }, + "node_modules/string_decoder/node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/string-width": { "version": "4.2.3", "license": "MIT", @@ -7777,6 +8173,7 @@ }, "node_modules/tr46": { "version": "0.0.3", + "dev": true, "license": "MIT" }, "node_modules/ts-node": { @@ -8024,6 +8421,8 @@ }, "node_modules/util-deprecate": { "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "license": "MIT" }, "node_modules/uuid": { @@ -8051,16 +8450,27 @@ "node": ">=0.10.48" } }, + "node_modules/web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, "node_modules/web-tts": { "resolved": "packages/web-tts", "link": true }, "node_modules/webidl-conversions": { "version": "3.0.1", + "dev": true, "license": "BSD-2-Clause" }, "node_modules/whatwg-url": { "version": "5.0.0", + "dev": true, "license": "MIT", "dependencies": { "tr46": "~0.0.3", @@ -8406,7 +8816,7 @@ "license": "Apache-2.0", "dependencies": { "@aws-sdk/client-polly": "^3.651.1", - "@google-cloud/text-to-speech": "^5.0.1", + "@google-cloud/text-to-speech": "^6.2.0", "async": "^3.0.0", "debug": "^3.1.0", "sax": "^1.2.4", diff --git a/packages/tts-cli/test/e2e/gcp.spec.js b/packages/tts-cli/test/e2e/gcp.spec.js index 3b711c3..14fb96f 100644 --- a/packages/tts-cli/test/e2e/gcp.spec.js +++ b/packages/tts-cli/test/e2e/gcp.spec.js @@ -43,7 +43,7 @@ describe('gcp', () => { expect(stdout).toContain('✔ Clean up') expect(stdout).toContain('❯ Saving file') expect(stdout).toContain(`✔ Done. Saved to ${outputFile}`) - expect(stderr).toEqual(jasmine.any(String)) // there is a deprecation warning + expect(stderr).toBe('') expect(exitCode).toBe(0) }) }) diff --git a/packages/tts-lib/package.json b/packages/tts-lib/package.json index 9eb9da4..08d2ec8 100644 --- a/packages/tts-lib/package.json +++ b/packages/tts-lib/package.json @@ -30,7 +30,7 @@ }, "dependencies": { "@aws-sdk/client-polly": "^3.651.1", - "@google-cloud/text-to-speech": "^5.0.1", + "@google-cloud/text-to-speech": "^6.2.0", "async": "^3.0.0", "debug": "^3.1.0", "sax": "^1.2.4",