diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 3b5741d..84d6582 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 @@ -25,5 +25,9 @@ jobs: run: npm install - run: npm run lint - run: npm run test + 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 4287360..f3e3ca2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,15 +10,10 @@ "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", - "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", @@ -1119,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": { @@ -1313,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, @@ -1347,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", @@ -1371,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": { @@ -1422,6 +1452,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", @@ -2009,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": { @@ -2021,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, @@ -2035,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": "*", @@ -2045,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": { @@ -2065,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" @@ -2315,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": { @@ -2412,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": { @@ -2425,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": "*" @@ -2494,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": { @@ -2551,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, @@ -2670,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" @@ -2678,6 +2769,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", @@ -2932,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" @@ -2962,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" @@ -2991,6 +3111,7 @@ }, "node_modules/encoding": { "version": "0.1.13", + "dev": true, "license": "MIT", "optional": true, "peer": true, @@ -3000,6 +3121,7 @@ }, "node_modules/encoding/node_modules/iconv-lite": { "version": "0.6.2", + "dev": true, "license": "MIT", "optional": true, "peer": true, @@ -3085,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, @@ -3106,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" @@ -3889,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" @@ -3899,8 +4055,161 @@ "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", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", "license": "MIT" }, "node_modules/extract-zip": { @@ -3983,6 +4292,45 @@ "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", + "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, @@ -4109,20 +4457,26 @@ } }, "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/fromentries": { - "version": "1.2.1", - "dev": true, + "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", @@ -4139,23 +4493,53 @@ ], "license": "MIT" }, - "node_modules/fs.realpath": { - "version": "1.0.0", - "dev": true, - "license": "ISC" - }, - "node_modules/function-bind": { - "version": "1.1.2", - "dev": true, + "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", - "funding": { - "url": "https://github.com/sponsors/ljharb" + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" } }, - "node_modules/function.prototype.name": { - "version": "1.1.6", + "node_modules/fromentries": { + "version": "1.2.1", "dev": true, - "license": "MIT", + "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/fs.realpath": { + "version": "1.0.0", + "dev": true, + "license": "ISC" + }, + "node_modules/function-bind": { + "version": "1.1.2", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/function.prototype.name": { + "version": "1.1.6", + "dev": true, + "license": "MIT", "dependencies": { "call-bind": "^1.0.2", "define-properties": "^1.2.0", @@ -4178,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": { @@ -4250,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" @@ -4271,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, @@ -4367,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" @@ -4414,6 +4895,7 @@ }, "node_modules/graceful-fs": { "version": "4.2.0", + "dev": true, "license": "ISC" }, "node_modules/graphemer": { @@ -4422,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": { @@ -4470,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" @@ -4481,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" @@ -4511,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" @@ -4560,6 +5047,32 @@ "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/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, @@ -4843,6 +5356,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, @@ -4932,6 +5458,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, @@ -5164,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" @@ -5219,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", @@ -5517,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": { @@ -5531,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": { @@ -5575,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, @@ -5593,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" @@ -5600,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" @@ -5669,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" @@ -5945,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" @@ -6278,6 +6863,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, @@ -6410,6 +7008,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, @@ -6441,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": { @@ -6541,21 +7159,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", @@ -6654,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", @@ -6761,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", @@ -6794,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", @@ -6803,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": { @@ -6902,6 +7531,7 @@ }, "node_modules/safer-buffer": { "version": "2.1.2", + "dev": true, "license": "MIT", "optional": true, "peer": true @@ -7186,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": { @@ -7203,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", @@ -7519,6 +8173,7 @@ }, "node_modules/tr46": { "version": "0.0.3", + "dev": true, "license": "MIT" }, "node_modules/ts-node": { @@ -7684,6 +8339,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", @@ -7753,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": { @@ -7780,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", @@ -8056,6 +8737,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,23 +8774,22 @@ "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", + "commander": "^14.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" + "listr2": "^8.0.2" }, "bin": { "tts": "tts.js" }, "devDependencies": { - "codecov": "^3.6.5" + "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", + "standard": "^17.1.0" } }, "packages/tts-cli/node_modules/debug": { @@ -8106,30 +8799,64 @@ "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": "^6.2.0", + "async": "^3.0.0", + "debug": "^3.1.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", + "cross-env": "^5.2.0", + "husky": "^9.1.7", + "jasmine": "^3.5.0", + "nyc": "^15.0.1", + "proxyquire": "^2.1.3", + "standard": "^17.1.0" + } + }, + "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..37cbfd6 100644 --- a/package.json +++ b/package.json @@ -29,14 +29,10 @@ "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" + "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/.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/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/cleanup.js b/packages/tts-cli/lib/cleanup.js deleted file mode 100644 index d5085dc..0000000 --- a/packages/tts-cli/lib/cleanup.js +++ /dev/null @@ -1,19 +0,0 @@ -const debug = require('debug')('cleanup') -const fs = require('fs-extra') - -/** - * Deletes the manifest and its files. - */ -exports.cleanup = ctx => { - const manifestFile = ctx.manifestFile - const manifest = fs.readFileSync(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]) - } - debug(`Deleting manifest file ${manifestFile}`) - fs.removeSync(manifestFile) -} diff --git a/packages/tts-cli/lib/combine-parts.js b/packages/tts-cli/lib/combine-parts.js deleted file mode 100644 index a866366..0000000 --- a/packages/tts-cli/lib/combine-parts.js +++ /dev/null @@ -1,75 +0,0 @@ -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. - */ -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 = (manifestFile, outputFile) => { - const manifest = fs.readFileSync(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) - let match - while ((match = regexpState.exec(manifest)) !== null) { - debug('combineRawAudio')(`Reading data from ${match[1]}`) - const dataBuffer = fs.readFileSync(match[1]) - debug('combineRawAudio')(`Appending data to ${outputFile}`) - fs.appendFileSync(outputFile, dataBuffer) - } - return Promise.resolve() -} - -/** - * 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)}`) - 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 - }) -} 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/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/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/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/lib/read-text.js b/packages/tts-cli/lib/read-text.js index bb4115f..b5b85c4 100644 --- a/packages/tts-cli/lib/read-text.js +++ b/packages/tts-cli/lib/read-text.js @@ -1,38 +1,26 @@ 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 = (ctx) => { - const inputFilename = ctx.input - const proc = ctx.process - 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) } - debug(`Finished reading (${data.length} bytes)`) - resolve(data) - }) - } 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) - }) +exports.readText = async (inputFilename, proc) => { + 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 } - }).then(text => { - ctx.text = text - }) + debug(`Finished reading (${data.length} bytes)`) + return data + } } diff --git a/packages/tts-cli/package.json b/packages/tts-cli/package.json index f0f4a26..fca59e9 100644 --- a/packages/tts-cli/package.json +++ b/packages/tts-cli/package.json @@ -12,9 +12,12 @@ "yarn.lock" ], "scripts": { + "prepare": "husky", "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 NO_COLOR=1 jasmine", + "test:unit": "cross-env JASMINE_CONFIG_PATH=test/unit.json nyc jasmine" }, "repository": { "type": "git", @@ -35,20 +38,19 @@ }, "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", + "commander": "^14.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" + "listr2": "^8.0.2" }, "devDependencies": { - "codecov": "^3.6.5" + "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", + "standard": "^17.1.0" }, "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/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/cleanup.spec.js b/packages/tts-cli/test/cleanup.spec.js deleted file mode 100644 index 4414be9..0000000 --- a/packages/tts-cli/test/cleanup.spec.js +++ /dev/null @@ -1,30 +0,0 @@ -describe('cleanup()', () => { - const manifestFilename = 'manifest.txt' - 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) - }) - - it('should delete the manifest file', () => { - expect(fs.removeSync).toHaveBeenCalledWith(manifestFilename) - }) - - it('should delete the temporary audio files', () => { - tempFilenames.forEach(filename => { - expect(fs.removeSync).toHaveBeenCalledWith(filename) - }) - }) -}) diff --git a/packages/tts-cli/test/cli.spec.js b/packages/tts-cli/test/cli.spec.js deleted file mode 100644 index f1a5423..0000000 --- a/packages/tts-cli/test/cli.spec.js +++ /dev/null @@ -1,111 +0,0 @@ -const proxyquire = require('proxyquire') - -describe('CLI', () => { - const outputFile = 'output-file' - - let cli - let args, minimist - - beforeEach(() => { - args = { _: [outputFile] } - minimist = jasmine.createSpy('minimist').and.callFake(() => args) - cli = proxyquire('../tts', { minimist }) - }) - - it('should construct an array of tasks', () => { - expect(cli.tasks).toEqual(jasmine.any(Array)) - expect(cli.tasks.every(task => { - return typeof task.title === 'string' && typeof task.task === 'function' - })).toBe(true) - }) - - it('should pass the CLI arguments to Listr', () => { - 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)) - }) - - describe('when 2 arguments are given', () => { - const inputFile = 'input-file' - - beforeEach(() => { - args = { _: [inputFile, outputFile] } - cli = proxyquire('../tts', { minimist }) - }) - - it('should use the first argument for the input filename', () => { - expect(cli.context.input).toBe(inputFile) - }) - - it('should use the second argument for the output filename', () => { - expect(cli.context.outputFilename).toBe(outputFile) - }) - }) - - describe('when only 1 argument is given', () => { - beforeEach(() => { - args = { _: [outputFile] } - cli = proxyquire('../tts', { minimist }) - }) - - it('should use null for the input filename', () => { - expect(cli.context.input).toBe(null) - }) - - it('should use the first argument for the output filename', () => { - expect(cli.context.outputFilename).toBe(outputFile) - }) - }) - - describe('when the "aws" service is specified', () => { - beforeEach(() => { - args = { _: [outputFile], service: 'aws' } - cli = proxyquire('../tts', { minimist }) - }) - - it('should save that as the service', () => { - expect(cli.context.service).toBe('aws') - }) - - it('should set the appropriate maxCharacterCount', () => { - expect(cli.context.maxCharacterCount).toBe(1500) - }) - }) - - describe('when the "gcp" service is specified', () => { - beforeEach(() => { - args = { _: [outputFile], service: 'gcp' } - cli = proxyquire('../tts', { minimist }) - }) - - it('should save that as the service', () => { - expect(cli.context.service).toBe('gcp') - }) - - it('should set the appropriate maxCharacterCount', () => { - expect(cli.context.maxCharacterCount).toBe(5000) - }) - }) - - describe('when no service is specified', () => { - beforeEach(() => { - args = { _: [outputFile] } - cli = proxyquire('../tts', { minimist }) - }) - - it('should use the default service', () => { - expect(cli.context.service).toBe('aws') - }) - - it('should set the appropriate maxCharacterCount', () => { - expect(cli.context.maxCharacterCount).toBe(1500) - }) - }) -}) diff --git a/packages/tts-cli/test/combine-raw-audio.spec.js b/packages/tts-cli/test/combine-raw-audio.spec.js deleted file mode 100644 index d070bef..0000000 --- a/packages/tts-cli/test/combine-raw-audio.spec.js +++ /dev/null @@ -1,29 +0,0 @@ -describe('combineRawAudio()', () => { - const manifestFilename = 'manifest.txt' - const outputFilename = 'foobar.mp3' - const tempFilenames = ['foo.mp3', 'bar.mp3'] - - let combineRawAudio, fs - - beforeEach(() => { - ({ combineRawAudio, fs } = require('./helpers').loadLib('combine-parts')) - }) - - beforeEach(done => { - const manifestContents = tempFilenames.map(filename => `file '${filename}'`).join('\n') - fs.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) - }) - - it('should read and append each file from the manifest', () => { - tempFilenames.forEach(filename => { - expect(fs.readFileSync).toHaveBeenCalledWith(filename) - }) - expect(fs.appendFileSync.calls.count()).toBe(tempFilenames.length) - }) -}) 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/e2e.json b/packages/tts-cli/test/e2e.json new file mode 100644 index 0000000..b28f577 --- /dev/null +++ b/packages/tts-cli/test/e2e.json @@ -0,0 +1,7 @@ +{ + "spec_dir": "test/e2e", + "spec_files": [ + "**/*.spec.js" + ], + "random": false +} 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..3ceea7f --- /dev/null +++ b/packages/tts-cli/test/e2e/aws.spec.js @@ -0,0 +1,49 @@ +const { copyFile } = require('fs/promises') +const { join } = require('path') +const tempfile = require('tempfile') +const { runWith } = require('./helper') + +describe('aws', () => { + jasmine.DEFAULT_TIMEOUT_INTERVAL = 10000 + + 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', + '--ffmpeg', ffmpegBinary, + '--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..14fb96f --- /dev/null +++ b/packages/tts-cli/test/e2e/gcp.spec.js @@ -0,0 +1,49 @@ +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 ffmpegBinary = join(__dirname, '../fixtures/ffmpeg-mock.sh') + + const projectFile = tempfile('.json') + const defaults = [ + '--service', 'gcp', + '--ffmpeg', ffmpegBinary, + '--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('') + 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..ffe5f35 --- /dev/null +++ b/packages/tts-cli/test/e2e/helper.js @@ -0,0 +1,14 @@ +const runWith = async (...args) => { + const { execa } = await import('execa') + return execa( + 'node', + [ + 'tts.js' + ].concat(args), + { cwd: '../../packages/tts-cli' } + ) +} + +module.exports = { + runWith +} 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/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" 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. diff --git a/packages/tts-cli/test/generate-all.spec.js b/packages/tts-cli/test/generate-all.spec.js deleted file mode 100644 index f13afdb..0000000 --- a/packages/tts-cli/test/generate-all.spec.js +++ /dev/null @@ -1,87 +0,0 @@ -describe('generateAll()', () => { - let async, generateAll, task - let iteratorFunction - - const testLimit = 2 - const textParts = [ - 'hello', 'world', 'how are you?' - ] - - beforeEach(() => { - 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 - }) - - it('should asynchronously call the function for each of the parts', done => { - generateAll(textParts, { limit: testLimit }, iteratorFunction, task).then(() => { - const [parts] = async.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 limit the async calls according to the option', done => { - generateAll(textParts, { limit: testLimit }, iteratorFunction, task).then(() => { - const [, limit] = async.eachOfLimit.calls.mostRecent().args - expect(limit).toBe(testLimit) - }).then(done) - }) - - describe('initial spinner', () => { - beforeEach(done => { - async.eachOfLimit.and.callFake((parts, opts, func, callback) => { - callback(new Error('reject async')) - }) - generateAll(textParts, {}, iteratorFunction, task).catch(() => { - done() - }) - }) - - 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', done => { - generateAll(textParts, { limit: 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(() => { - expect(task.title).toMatch(`\\(${textParts.length}/`) - }).then(done) - }) - }) - - describe('when a request fails', () => { - const testError = 'test error' - - beforeEach(() => { - iteratorFunction.and.callFake((data, i, callback) => { - callback(new Error(testError)) - }) - }) - - it('should return a rejected promise with the error', done => { - generateAll(textParts, { limit: 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-cli/test/generate-speech.spec.js deleted file mode 100644 index 05696a1..0000000 --- a/packages/tts-cli/test/generate-speech.spec.js +++ /dev/null @@ -1,136 +0,0 @@ -describe('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)) - }) - }) -}) diff --git a/packages/tts-cli/test/helpers.js b/packages/tts-cli/test/helpers.js deleted file mode 100644 index 3d5aec1..0000000 --- a/packages/tts-cli/test/helpers.js +++ /dev/null @@ -1,68 +0,0 @@ -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-cli/test/move-temp-file.spec.js b/packages/tts-cli/test/move-temp-file.spec.js deleted file mode 100644 index 2fbf741..0000000 --- a/packages/tts-cli/test/move-temp-file.spec.js +++ /dev/null @@ -1,39 +0,0 @@ -describe('moveTempFile()', () => { - const sourceFile = 'source file' - const destFile = 'destination file' - - let moveTempFile, fs - let ctx, task - - beforeEach(() => { - ({ fs, moveTempFile } = require('./helpers').loadLib('move-temp-file')) - ctx = { - tempFile: sourceFile, - outputFilename: destFile - } - task = { title: 'test task' } - return moveTempFile(ctx, task) - }) - - it('should overwrite the destination filename with the specified temp file', () => { - expect(fs.move).toHaveBeenCalledWith( - sourceFile, - destFile, - { overwrite: true }, - jasmine.any(Function) - ) - }) - - it('should update the task title', () => { - expect(task.title).toContain('Done. Saved to') - }) - - it('should return the error if the filesystem call fails', () => { - fs.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 => { - expect(err.message).toBe('test error') - }) - }) -}) diff --git a/packages/tts-cli/test/providers/aws.spec.js b/packages/tts-cli/test/providers/aws.spec.js deleted file mode 100644 index a668295..0000000 --- a/packages/tts-cli/test/providers/aws.spec.js +++ /dev/null @@ -1,241 +0,0 @@ -const { Polly } = require('@aws-sdk/client-polly') -const fs = require('fs') -const { Readable } = require('stream') -const tempfile = require('tempfile') - -describe('AWS provider', () => { - let create - let fsStub - let PollyProvider - let provider - - beforeEach(() => { - ({ create, fs: fsStub, PollyProvider } = require('../helpers').loadLib('providers/aws')) - provider = create({ - region: 'aws-west-1', - accessKey: 'ACCESS KEY', - secretKey: 'SECRET KEY' - }) - }) - - describe('create()', () => { - it('should create a provider instance', () => { - expect(provider).toEqual(jasmine.any(PollyProvider)) - }) - - it('should have an underlying AWS object', () => { - expect(provider.instance).toEqual(jasmine.any(Polly)) - }) - - it('should configure the Polly object from the options', async () => { - const credentials = await provider.instance.config.credentials() - const region = await provider.instance.config.region() - expect(region).toBe('aws-west-1') - expect(credentials.accessKeyId).toBe('ACCESS KEY') - expect(credentials.secretAccessKey).toBe('SECRET KEY') - }) - }) - - describe('buildPart()', () => { - it('should return an object with a `send` property', () => { - expect(provider.buildPart()).toEqual({ - send: jasmine.any(Function) - }) - }) - }) - - describe('generate()', () => { - let task, testData, info, send - - 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) - } - }) - send = jasmine.createSpy('send').and.resolveTo({ - AudioStream: inStream - }) - info = { - opts: testData.opts, - task, - tempfile: testData.filename, - text: testData.text, - send - } - }) - - afterEach(done => { - fs.access(testData.filename, fs.constants.F_OK, err => { - if (err) { return done() } - fs.unlink(testData.filename, done) - }) - }) - - it('should update the task title', done => { - provider.generate(info, testData.index, () => { - expect(task.title).toMatch(`\\(${testData.index}/`) - done() - }) - }) - - it('should work with the MP3 format', done => { - testData.opts.format = 'mp3' - provider.generate(info, 0, () => { - const command = send.calls.mostRecent().args[0] - expect(command.input.OutputFormat).toBe('mp3') - done() - }) - }) - - it('should work with the OGG format', done => { - testData.opts.format = 'ogg' - provider.generate(info, 0, () => { - const command = send.calls.mostRecent().args[0] - expect(command.input.OutputFormat).toBe('ogg_vorbis') - done() - }) - }) - - it('should work with the PCM format', done => { - testData.opts.format = 'pcm' - provider.generate(info, 0, () => { - const command = send.calls.mostRecent().args[0] - expect(command.input.OutputFormat).toBe('pcm') - done() - }) - }) - - 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 not use sample rate if not specified', done => { - delete info.opts.sampleRate - provider.generate(info, 0, () => { - const command = send.calls.mostRecent().args[0] - expect(command.input.SampleRate).toBeUndefined() - done() - }) - }) - - 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 not use lexicon names if not specified', done => { - delete info.opts.lexicon - provider.generate(info, 0, () => { - const command = send.calls.mostRecent().args[0] - expect(command.input.LexiconNames).toBeUndefined() - done() - }) - }) - - 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 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 part', done => { - provider.generate(info, 0, () => { - const command = send.calls.mostRecent().args[0] - expect(command.input.Text).toBe(testData.text) - done() - }) - }) - - it('should not use a language if not specified', done => { - delete info.opts.language - provider.generate(info, 0, () => { - const command = send.calls.mostRecent().args[0] - expect(command.input.LanguageCode).toBeUndefined() - done() - }) - }) - - 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 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 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 callback with an error if send() fails', done => { - info.send = jasmine.createSpy('send').and.rejectWith(new Error('test error')) - provider.generate(info, 0, err => { - expect(err).toEqual(new Error('test error')) - done() - }) - }) - - it('should callback with an error if file saving fails', done => { - fsStub.createWriteStream.and.callFake(filename => { - const stream = fs.createWriteStream(filename) - stream.on('pipe', () => { - stream.emit('error', new Error('write stream error')) - }) - return stream - }) - provider.generate(info, 0, err => { - expect(err).toEqual(new Error('write stream error')) - done() - }) - }) - }) -}) diff --git a/packages/tts-cli/test/providers/gcp.spec.js b/packages/tts-cli/test/providers/gcp.spec.js deleted file mode 100644 index 54daf3e..0000000 --- a/packages/tts-cli/test/providers/gcp.spec.js +++ /dev/null @@ -1,337 +0,0 @@ -const TextToSpeechClient = require('@google-cloud/text-to-speech').TextToSpeechClient -const fs = require('fs') -const path = require('path') -const tempfile = require('tempfile') - -describe('Google Cloud provider', () => { - let fsStub - let create - let GoogleProvider - let provider - - beforeEach(() => { - ({ fs: fsStub, create, GoogleProvider } = require('../helpers').loadLib('providers/gcp')) - provider = create({ - email: 'foo@example.com', - privateKey: 'private key', - projectFile: 'project-file.json', - projectId: 'project ID' - }) - }) - - describe('create()', () => { - it('should create a provider instance', () => { - expect(provider).toEqual(jasmine.any(GoogleProvider)) - }) - - it('should have an underlying Google Cloud object', () => { - expect(provider.instance).toEqual(jasmine.any(TextToSpeechClient)) - }) - - it('should use the email address from the options', () => { - expect(provider.instance.auth.jsonContent.client_email).toBe('foo@example.com') - }) - - it('should use the private key from the options', () => { - 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 leave out the project file if not specified', () => { - provider = create({ - email: 'foo@example.com', - 'private-key': 'private key' - }) - expect(provider.instance.auth.keyFilename).toBeUndefined() - }) - - it('should convert the project file to an absolute path if relative', () => { - expect(provider.instance.auth.keyFilename).toBe( - path.resolve('project-file.json') - ) - }) - - it('should use the project file as-is if an absolute path', () => { - provider = create({ - email: 'foo@example.com', - privateKey: 'fake key', - projectFile: path.resolve('project-file.json') - }) - expect(provider.instance.auth.keyFilename).toBe( - path.resolve('project-file.json') - ) - }) - - it('should work if email and private key are not specified', () => { - const filename = tempfile() - const fakeProject = { - client_email: 'foo@example.com', - private_key: 'fake key' - } - fs.writeFileSync(filename, JSON.stringify(fakeProject), 'utf8') - expect(() => { - create({ 'project-file': filename }) - }).not.toThrow() - }) - }) - - describe('buildPart()', () => { - it('should return an object with a `synthesizer` property', () => { - expect(provider.buildPart()).toEqual({ - synthesizer: jasmine.any(Function) - }) - }) - }) - - describe('generate()', () => { - let task, testData, info, synthesizer - - 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(done => { - fs.unlink(testData.filename, () => { - // ignore any errors - done() - }) - }) - - it('should call the synthesizer function', done => { - synthesizer.and.callFake(() => done()) - provider.generate(info, 0, () => {}) - expect(synthesizer).toHaveBeenCalled() - }) - - describe('when everything works', () => { - beforeEach(() => { - synthesizer.and.callFake((req, opts, cb) => { - cb(null, { audioContent: testData.response }) - }) - }) - - it('should update the task title', done => { - provider.generate(info, testData.index, () => { - expect(task.title).toMatch(`\\(${testData.index}/`) - done() - }) - }) - - it('should work with the MP3 format', done => { - testData.opts.format = 'mp3' - provider.generate(info, 0, () => { - const opts = synthesizer.calls.mostRecent().args[0] - expect(opts.audioConfig.audioEncoding).toBe('MP3') - done() - }) - }) - - it('should work with the OGG format', done => { - testData.opts.format = 'ogg' - provider.generate(info, 0, () => { - const opts = synthesizer.calls.mostRecent().args[0] - expect(opts.audioConfig.audioEncoding).toBe('OGG_OPUS') - done() - }) - }) - - it('should work with the PCM format', done => { - testData.opts.format = 'pcm' - provider.generate(info, 0, () => { - const opts = synthesizer.calls.mostRecent().args[0] - expect(opts.audioConfig.audioEncoding).toBe('LINEAR16') - done() - }) - }) - - it('should not use sample rate if not specified', done => { - delete info.opts.sampleRate - provider.generate(info, 0, () => { - const opts = synthesizer.calls.mostRecent().args[0] - expect(opts.audioConfig.sampleRateHertz).toBeUndefined() - done() - }) - }) - - 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 given (plain) text', done => { - 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() - }) - }) - - it('should use the given (SSML) text', done => { - 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() - }) - }) - - it('should not use effects if not specified', done => { - delete info.opts.effect - provider.generate(info, 0, () => { - const opts = synthesizer.calls.mostRecent().args[0] - expect(opts.audioConfig.effectsProfileId).toBeUndefined() - done() - }) - }) - - 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 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 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 leave out gender if not specified', done => { - delete testData.opts.gender - provider.generate(info, 0, () => { - const opts = synthesizer.calls.mostRecent().args[0] - expect(opts.voice.ssmlGender).toBeUndefined() - done() - }) - }) - - 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 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 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 voice', done => { - provider.generate(info, 0, () => { - const opts = synthesizer.calls.mostRecent().args[0] - expect(opts.voice.name).toBe(testData.opts.voice) - done() - }) - }) - - 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() - }) - }) - }) - - describe('when GCP returns an error', () => { - beforeEach(() => { - synthesizer.and.callFake((req, opts, cb) => { - cb(new Error('testing GCP error')) - }) - }) - - it('should call back with the error', done => { - provider.generate(info, 0, (err) => { - expect(err.message).toBe('testing GCP error') - done() - }) - }) - }) - - describe('when file writing fails', () => { - beforeEach(() => { - 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')) - }) - }) - - it('should call back with the error', done => { - provider.generate(info, 0, (err) => { - expect(err.message).toBe('testing write error') - done() - }) - }) - }) - }) -}) diff --git a/packages/tts-cli/test/read-text.spec.js b/packages/tts-cli/test/read-text.spec.js deleted file mode 100644 index efa7747..0000000 --- a/packages/tts-cli/test/read-text.spec.js +++ /dev/null @@ -1,91 +0,0 @@ -describe('readText()', () => { - const testData = 'hello world' - - let readText, fs - let ctx, stdin - - beforeEach(() => { - ({ readText, fs } = require('./helpers').loadLib('read-text')) - }) - - 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 - }) - ctx = { - input: null, - process: { - stdin - } - } - }) - - describe('when it succeeds', () => { - it('should set the read text', done => { - readText(ctx).then(() => { - expect(ctx.text).toBe(testData) - }).then(done) - }) - }) - - describe('when no filename is specified', () => { - it('should read data from stdin', done => { - readText(ctx).then(() => { - expect(stdin.on).toHaveBeenCalled() - expect(stdin.read).toHaveBeenCalled() - }).then(done) - }) - - it('should use UTF-8 encoding', done => { - readText(ctx).then(() => { - expect(stdin.setEncoding).toHaveBeenCalledWith('utf8') - }).then(done) - }) - }) - - describe('when a filename is specified', () => { - const testFilename = 'test.txt' - - beforeEach(() => { - ctx.input = testFilename - fs.readFile.and.callFake((filename, opts, callback) => { - callback(null, testData) - }) - }) - - it('should read data from the file', done => { - readText(ctx).then(() => { - expect(fs.readFile).toHaveBeenCalledWith( - testFilename, - 'utf8', - jasmine.any(Function) - ) - }).then(done) - }) - - describe('and can read the file', () => { - it('should set the file\'s data', done => { - readText(ctx).then(() => { - expect(ctx.text).toBe(testData) - }).then(done) - }) - }) - - describe('and cannot read the file', () => { - it('should reject with the error', done => { - const testError = 'error object' - fs.readFile.and.callFake((filename, opts, callback) => { - callback(testError) - }) - readText(ctx).catch(err => { - expect(err).toBe(testError) - }).then(done) - }) - }) - }) -}) diff --git a/packages/tts-cli/test/split-text.spec.js b/packages/tts-cli/test/split-text.spec.js deleted file mode 100644 index 2177223..0000000 --- a/packages/tts-cli/test/split-text.spec.js +++ /dev/null @@ -1,92 +0,0 @@ -describe('splitText()', () => { - const maxChars = 1000 - 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]) - }).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']) - }).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([ - 'Hello', - 'there', - 'world' - ]) - }).then(done) - }) - - 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([ - '

Section 1

', - '

Introduction

' - ]) - }).then(done) - }) - - 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([ - 'Hello', - 'there', - 'world<', - '/speak', - '>' - ]) - }).then(done) - }) - - it('should condense whitespace', done => { - ctx.text = 'hello world' - splitText(ctx).then(() => { - expect(ctx.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']) - }).then(done) - }) - - describe('when no args are specified', () => { - it('should still work', () => { - ctx.args = undefined - return splitText(ctx).then(() => { - expect(ctx.parts).toEqual([testData]) - }) - }) - }) -}) 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/unit/cli.spec.js b/packages/tts-cli/test/unit/cli.spec.js new file mode 100644 index 0000000..239f479 --- /dev/null +++ b/packages/tts-cli/test/unit/cli.spec.js @@ -0,0 +1,250 @@ +const EventEmitter = require('events') +const proxyquire = require('proxyquire') + +class TestEmitter extends EventEmitter {} + +describe('CLI', () => { + const inputFile = 'input-file' + const outputFile = 'output-file' + const tempFile = 'temp file' + + 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({ + convert, + events: emitter + }) + mocks = { + fs: { + readFileSync: () => 'file contents' + }, + '../tts-lib': { cleanup, createProvider }, + './lib/read-text': { readText } + } + cli = proxyquire('../../tts', mocks) + }) + + it('should construct an array of tasks', () => { + expect(cli.tasks).toEqual(jasmine.any(Array)) + expect(cli.tasks.every(task => { + return typeof task.title === 'string' && typeof task.task === 'function' + })).toBe(true) + }) + + describe('when 2 arguments are given', () => { + const inputFile = 'input-file' + + beforeEach(() => { + process.argv = ['node', 'tts.js', inputFile, outputFile] + cli = proxyquire('../../tts', mocks) + }) + + it('should use the first argument for the input filename', () => { + expect(cli.context.input).toBe(inputFile) + }) + + it('should use the second argument for the output filename', () => { + expect(cli.context.outputFilename).toBe(outputFile) + }) + }) + + describe('when only 1 argument is given', () => { + beforeEach(() => { + process.argv = ['node', 'tts.js', outputFile] + cli = proxyquire('../../tts', mocks) + }) + + it('should use null for the input filename', () => { + expect(cli.context.input).toBe(null) + }) + + it('should use the first argument for the output filename', () => { + expect(cli.context.outputFilename).toBe(outputFile) + }) + }) + + it('should create an AWS service', () => { + process.argv = ['node', 'tts.js', outputFile, '--service', 'aws'] + 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) + expect(cli.context.service).toBe('gcp') + }) + + it('should create a default service', () => { + 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', () => { + 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: 'neural', + ffmpeg: 'test ffmpeg', + format: 'ogg', + gain: 11.13, + gender: 'female', + language: 'test language', + lexicon: ['test lexicon'], + pitch: 2.3, + privateKey: 'test private key', + projectFile: 'test project file', + projectId: 'test project id', + region: 'test region', + sampleRate: 16000, + secretKey: 'test secret key', + service: 'aws', + speed: 2.5, + throttle: 4, + type: 'text', + voice: 'test voice' + }) + }) + + it('should create the service with default options', () => { + process.argv = ['node', 'tts.js', inputFile, outputFile] + cli = proxyquire('../../tts', mocks) + expect(createProvider).toHaveBeenCalledWith('aws', { + ffmpeg: 'ffmpeg', + format: 'mp3', + region: 'us-east-1', + service: 'aws', + throttle: 5, + type: 'text' + }) + }) + + 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 = {} + emitter.emit('split', { count: 6 }) + await cli.tasks[1].task(context) // will timeout with no split event + }) + + 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 = {} + emitter.emit('split', { count: 6 }) + await cli.tasks[1].task(context) + 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 update the task title on "generate" events', async () => { + process.argv = ['node', 'tts.js', outputFile] + cli = proxyquire('../../tts', mocks) + 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 listen for the "save" event 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) // 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(outputFile) + }) + + it('should listen for the "clean" event in the cleanup task', async () => { + process.argv = ['node', 'tts.js', outputFile] + 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/unit/move-temp-file.spec.js b/packages/tts-cli/test/unit/move-temp-file.spec.js new file mode 100644 index 0000000..408c75d --- /dev/null +++ b/packages/tts-cli/test/unit/move-temp-file.spec.js @@ -0,0 +1,42 @@ +const proxyquire = require('proxyquire') + +describe('moveTempFile()', () => { + const sourceFile = 'source file' + const destFile = 'destination file' + + let moveTempFile + let fsSpy + let ctx, task + + beforeEach(async () => { + fsSpy = jasmine.createSpyObj('fs', ['rename']) + fsSpy.rename.and.callFake((src, dest) => Promise.resolve()) + ;({ moveTempFile } = proxyquire('../../lib/move-temp-file', { + 'fs/promises': fsSpy + })) + ctx = { + tempFile: sourceFile, + outputFilename: destFile + } + task = { title: 'test task' } + await moveTempFile(ctx, task) + }) + + it('should overwrite the destination filename with the specified temp file', () => { + 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', 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) { + expect(err.message).toBe('test error') + } + }) +}) diff --git a/packages/tts-cli/test/unit/program.spec.js b/packages/tts-cli/test/unit/program.spec.js new file mode 100644 index 0000000..6aef0a1 --- /dev/null +++ b/packages/tts-cli/test/unit/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/test/unit/read-text.spec.js b/packages/tts-cli/test/unit/read-text.spec.js new file mode 100644 index 0000000..c5d1722 --- /dev/null +++ b/packages/tts-cli/test/unit/read-text.spec.js @@ -0,0 +1,81 @@ +const proxyquire = require('proxyquire') + +describe('readText()', () => { + const testData = 'hello world' + + let readText + let fsSpy + let stdin + + beforeEach(() => { + fsSpy = jasmine.createSpyObj('fs', ['readFile']) + ;({ readText } = proxyquire('../../lib/read-text', { + 'fs/promises': fsSpy + })) + }) + + beforeEach(() => { + 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', async () => { + const text = await readText(null, { stdin }) + expect(text).toBe(testData) + }) + + it('should use UTF-8 encoding', async () => { + await readText(null, { stdin }) + expect(stdin.setEncoding).toHaveBeenCalledWith('utf8') + }) + }) + + describe('when a filename is specified', () => { + const testFilename = 'test.txt' + + beforeEach(() => { + fsSpy.readFile.and.callFake((filename, opts) => Promise.resolve(testData)) + }) + + 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', async () => { + const text = await readText(testFilename, { stdin }) + expect(text).toBe(testData) + }) + }) + + describe('and cannot read the file', () => { + it('should reject with the error', async () => { + const testError = 'error object' + fsSpy.readFile.and.callFake((filename, opts) => Promise.reject(testError)) + try { + await readText(testFilename, { stdin }) + throw new Error('Expected readText to throw an error') + } catch (err) { + expect(err).toBe(testError) + } + }) + }) + }) +}) 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, diff --git a/packages/tts-cli/tts.js b/packages/tts-cli/tts.js index 4a510e4..e414df8 100755 --- a/packages/tts-cli/tts.js +++ b/packages/tts-cli/tts.js @@ -1,22 +1,18 @@ #!/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 { checkUsage } = require('./lib/check-usage') -const { cleanup } = require('./lib/cleanup') -const { combine } = require('./lib/combine-parts') -const { generateSpeech } = require('./lib/generate-speech') +const { createProvider } = require('../tts-lib') + +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 { splitText } = require('./lib/split-text') - -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) { @@ -26,41 +22,72 @@ if (!outputFilename) { debug('input:', input) debug('output:', outputFilename) -// Check the usage. -checkUsage(args, process) +// Set the options. +const opts = program.opts() +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: readText + task: async () => { + const text = await readText(input, process) + provider.convert(text) // kick off the conversion process + } }, { title: 'Splitting text', - task: splitText + task: (ctx) => doneSplitting.then((count) => { + ctx.count = count + }) }, { title: 'Convert to audio', - task: generateSpeech + 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: combine + task: (ctx) => doneCombining.then((tempFile) => { + ctx.tempFile = tempFile + }) }, { title: 'Clean up', - task: cleanup + task: () => doneCleaning }, { title: 'Saving file', task: moveTempFile }] -const service = args.service || 'aws' const context = { - args, - input, - maxCharacterCount: service === 'gcp' ? 5000 : 1500, + input, // only used for testing outputFilename, - process, - service + service: opts.service // only used for testing } // 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/.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/index.js b/packages/tts-lib/index.js index c198d64..428e60f 100644 --- a/packages/tts-lib/index.js +++ b/packages/tts-lib/index.js @@ -1 +1,20 @@ -// Nothing yet, sorry! +const { AwsProvider } = require('./lib/providers/aws') +const { GcpProvider } = require('./lib/providers/gcp') +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, ProviderEmitter } = require('./lib/provider') +const { splitText } = require('./lib/split-text') + +module.exports = { + AwsProvider, + combine, + createManifest, + createProvider, + GcpProvider, + generateAll, + Provider, + ProviderEmitter, + splitText +} 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-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/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/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/generate-all.js b/packages/tts-lib/lib/generate-all.js new file mode 100644 index 0000000..e86e125 --- /dev/null +++ b/packages/tts-lib/lib/generate-all.js @@ -0,0 +1,18 @@ +const async = require('async') +const debug = require('debug') + +/** + * 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) => { + const count = parts.length + debug('generateAll')(`Requesting ${count} audio segments, ${limit} at a time`) + try { + return async.mapLimit(parts, limit, func) + } catch (err) { + debug('generateAll')(`Requested all parts, with error ${err}`) + throw err + } +} diff --git a/packages/tts-lib/lib/provider.js b/packages/tts-lib/lib/provider.js new file mode 100644 index 0000000..4e5fabf --- /dev/null +++ b/packages/tts-lib/lib/provider.js @@ -0,0 +1,138 @@ +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 + + // Prevent instantiation of the abstract class. + if (this.constructor === 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}`) + this.opts.privateKey = readFileSync(this.opts.privateKeyFile, 'utf8') + } + } + + /** + * Deletes the manifest and its files. + */ + 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') + } + + 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' + } + + /** + * 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 (_str) { + throw new Error('generate() not implemented') + } + + /** + * Returns a Promise with the temporary audio file. + */ + 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 + } + + 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 new file mode 100644 index 0000000..4e73c86 --- /dev/null +++ b/packages/tts-lib/lib/providers/aws.js @@ -0,0 +1,87 @@ +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') +const { Provider } = require('../provider') + +class AwsProvider extends Provider { + constructor (opts = {}) { + super(opts) + this.name = 'AWS' + this.maxCharacterCount = 1500 + + 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 + }) + } + + async combine (manifestFile, newFile) { + return combine(manifestFile, newFile, this.opts.format === 'pcm' ? 'raw' : 'encoded', this.opts.ffmpeg) + } + + 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`) + } + + /** + * Calls the Polly API with the given text. + */ + async generate (str) { + const filename = tempfile(`.${this.extensionFor(this.opts.format)}`) + const command = new SynthesizeSpeechCommand({ + 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 this.instance.send(command) + } catch (err) { + debug('generate')(`Error during request: ${err.message}`) + throw err + } + + debug('generate')(`Writing audio content to ${filename}`) + try { + const fileStream = createWriteStream(filename) + await pipeline(response.AudioStream, fileStream) + fileStream.close() + } catch (err) { + debug('generate')(`Error writing: ${err.message}`) + throw err + } + return { tempfile: filename } + } +} + +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..5109039 --- /dev/null +++ b/packages/tts-lib/lib/providers/gcp.js @@ -0,0 +1,99 @@ +const debug = require('debug') +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 +const { Provider } = require('../provider') + +class GcpProvider extends Provider { + constructor (opts = {}) { + super(opts) + this.name = 'GCP' + this.maxCharacterCount = 5000 + + 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 + } + } + + 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 text. + */ + async generate (str) { + const filename = tempfile(`.${this.extensionFor(this.opts.format)}`) + const request = { + input: this.opts.type === 'ssml' + ? { ssml: str } + : { text: str }, + voice: { + ssmlGender: this.opts.gender ? String(this.opts.gender).toUpperCase() : undefined, + languageCode: this.opts.language, + name: this.opts.voice + }, + audioConfig: { + audioEncoding: this.opts.format === 'pcm' + ? 'LINEAR16' + : 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 = { + retry: null + } + + let response + debug('generate')('Making request to Google Cloud Platform') + try { + 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 ${filename}`) + try { + await writeFile(filename, response.audioContent, 'binary') + } catch (err) { + debug('generate')(`Error writing: ${err.message}`) + throw err + } + + return { tempfile: filename } + } +} + +exports.GcpProvider = GcpProvider diff --git a/packages/tts-cli/lib/split-text.js b/packages/tts-lib/lib/split-text.js similarity index 85% rename from packages/tts-cli/lib/split-text.js rename to packages/tts-lib/lib/split-text.js index 0866776..8ed4dab 100644 --- a/packages/tts-cli/lib/split-text.js +++ b/packages/tts-lib/lib/split-text.js @@ -97,21 +97,15 @@ 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 - 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() - }) - }).then(parts => { - ctx.parts = parts +exports.splitText = async (text, maxCharacterCount, type = 'text') => { + const chunker = type === 'ssml' ? chunkXml : chunkText + 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-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..08d2ec8 100644 --- a/packages/tts-lib/package.json +++ b/packages/tts-lib/package.json @@ -19,11 +19,56 @@ "type": "git", "url": "git+https://github.com/eheikes/tts.git" }, - "scripts": {}, + "scripts": { + "prepare": "husky", + "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": "^6.2.0", + "async": "^3.0.0", + "debug": "^3.1.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", + "cross-env": "^5.2.0", + "husky": "^9.1.7", + "jasmine": "^3.5.0", + "nyc": "^15.0.1", + "proxyquire": "^2.1.3", + "standard": "^17.1.0" + }, + "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/combine-encoded-audio.spec.js b/packages/tts-lib/test/combine-encoded-audio.spec.js similarity index 54% rename from packages/tts-cli/test/combine-encoded-audio.spec.js rename to packages/tts-lib/test/combine-encoded-audio.spec.js index 6a205a1..3e30d41 100644 --- a/packages/tts-cli/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-encoded-audio', { + 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,15 +68,18 @@ 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() } }) }) - 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) + } }) }) @@ -62,19 +88,22 @@ 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) } }) }) - 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) + } }) }) }) diff --git a/packages/tts-lib/test/combine-raw-audio.spec.js b/packages/tts-lib/test/combine-raw-audio.spec.js new file mode 100644 index 0000000..8efe327 --- /dev/null +++ b/packages/tts-lib/test/combine-raw-audio.spec.js @@ -0,0 +1,38 @@ +const proxyquire = require('proxyquire') + +describe('combineRawAudio()', () => { + const manifestFilename = 'manifest.txt' + const outputFilename = 'foobar.mp3' + const tempFilenames = ['foo.mp3', 'bar.mp3'] + + let combineRawAudio + let fsSpy + + beforeEach(() => { + fsSpy = jasmine.createSpyObj('fs', [ + 'appendFile', + 'readFile', + 'writeFile' + ]) + ;({ combineRawAudio } = proxyquire('../lib/combine-raw-audio', { + 'fs/promises': fsSpy + })) + }) + + beforeEach(async () => { + const manifestContents = tempFilenames.map(filename => `file '${filename}'`).join('\n') + fsSpy.readFile.and.callFake(() => Promise.resolve(manifestContents)) + await combineRawAudio(manifestFilename, outputFilename) + }) + + it('should create the output file and truncate it', () => { + expect(fsSpy.writeFile).toHaveBeenCalledWith(outputFilename, '') + }) + + it('should read and append each file from the manifest', () => { + tempFilenames.forEach(filename => { + expect(fsSpy.readFile).toHaveBeenCalledWith(filename) + }) + expect(fsSpy.appendFile.calls.count()).toBe(tempFilenames.length) + }) +}) diff --git a/packages/tts-lib/test/combine.spec.js b/packages/tts-lib/test/combine.spec.js new file mode 100644 index 0000000..f085459 --- /dev/null +++ b/packages/tts-lib/test/combine.spec.js @@ -0,0 +1,53 @@ +const proxyquire = require('proxyquire') +const tempfile = require('tempfile') + +describe('combine()', () => { + const testManifest = 'manifest.txt' + + let combine + let combineEncodedAudioSpy + let combineRawAudioSpy + + beforeEach(() => { + 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(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(combineRawAudioSpy).toHaveBeenCalledWith('manifest.txt', jasmine.any(String)) + }) + }) + + describe('default format', () => { + it('should call combineEncodedAudio()', async () => { + await combine(testManifest, tempfile()) + expect(combineEncodedAudioSpy).toHaveBeenCalledWith('ffmpeg', 'manifest.txt', jasmine.any(String)) + }) + }) + + 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) + }) + }) +}) diff --git a/packages/tts-cli/test/create-manifest.spec.js b/packages/tts-lib/test/create-manifest.spec.js similarity index 65% rename from packages/tts-cli/test/create-manifest.spec.js rename to packages/tts-lib/test/create-manifest.spec.js index 206456b..14513be 100644 --- a/packages/tts-cli/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', ['writeFile']) + ;({ createManifest } = proxyquire('../lib/create-manifest', { + 'fs/promises': fsSpy + })) }) - beforeEach(() => { - response = createManifest(testParts); - [outputFilename, fileContents, options] = fs.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/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/generate-all.spec.js b/packages/tts-lib/test/generate-all.spec.js new file mode 100644 index 0000000..9b686fb --- /dev/null +++ b/packages/tts-lib/test/generate-all.spec.js @@ -0,0 +1,62 @@ +const async = require('async') +const proxyquire = require('proxyquire') + +describe('generateAll()', () => { + let generateAll + let asyncSpy + let iteratorFunction + + const testLimit = 2 + const textParts = [ + 'hello', 'world', 'how are you?' + ] + + beforeEach(() => { + 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, 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, async () => iteratorFunction()) + const [, limit] = asyncSpy.mapLimit.calls.mostRecent().args + expect(limit).toBe(testLimit) + }) + + describe('when all requests succeed', () => { + it('should respond with the original parts', async () => { + const response = await generateAll(textParts, testLimit, async (x) => iteratorFunction(x)) + expect(response).toEqual(textParts) + }) + }) + + describe('when a request fails', () => { + const testError = 'test error' + + beforeEach(() => { + iteratorFunction.and.callFake((data, i) => { + throw new Error(testError) + }) + }) + + it('should return a rejected promise with the error', async () => { + try { + 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 new file mode 100644 index 0000000..0dc3bf8 --- /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.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-cli/test/jasmine.json b/packages/tts-lib/test/jasmine.json similarity index 100% rename from packages/tts-cli/test/jasmine.json rename to packages/tts-lib/test/jasmine.json diff --git a/packages/tts-lib/test/provider.spec.js b/packages/tts-lib/test/provider.spec.js new file mode 100644 index 0000000..6c08ae8 --- /dev/null +++ b/packages/tts-lib/test/provider.spec.js @@ -0,0 +1,264 @@ +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 + let ChildProvider + 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 + }, + './create-manifest': { + createManifest: createManifestStub + }, + './generate-all': { + generateAll: generateAllStub + }, + './split-text': { + splitText: splitTextStub + } + })) + ChildProvider = class extends Provider { + constructor (opts = {}) { + super(opts) + } + } + childProvider = new ChildProvider({ + ffmpeg: 'ffmpeg-test', + throttle: 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/) + }) + + it('should set a default name and maxCharacterCount', () => { + expect(childProvider.name).toBe('[Base 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') + childProvider = new ChildProvider({ + privateKeyFile: keyFile + }) + expect(childProvider.opts.privateKey).toBe('private-key-content') + }) + }) + + 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('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') + expect(saveSpy).toHaveBeenCalledWith({ filename: jasmine.any(String) }) + }) + + it('should return the combine() result', async () => { + const result = await childProvider.combineAudio('foobar') + 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) + }) + }) + + describe('extensionFor()', () => { + it('should default to mp3', () => { + const result = childProvider.extensionFor('aaa') + expect(result).toBe('mp3') + }) + }) + + describe('generate()', () => { + 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') + } + }) + }) + + describe('generateSpeech()', () => { + it('should call generateAll()', async () => { + 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) + 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) + expect(result).toBe(manifestFile) + }) + }) + + describe('splitText()', () => { + 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 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 new file mode 100644 index 0000000..082f838 --- /dev/null +++ b/packages/tts-lib/test/providers/aws.spec.js @@ -0,0 +1,297 @@ +const { Polly } = require('@aws-sdk/client-polly') +const { createWriteStream } = require('fs') +const { readFile, unlink } = require('fs/promises') +const proxyquire = require('proxyquire') +const { Readable } = require('stream') +const tempfile = require('tempfile') + +describe('AWS provider', () => { + const chunks = ['hello world'] + + let combineStub + let fsSpy + let splitTextStub + let AwsProvider + let provider + + beforeEach(() => { + combineStub = jasmine.createSpy('combine') + fsSpy = jasmine.createSpyObj('fs', ['createWriteStream']) + fsSpy.createWriteStream.and.callFake(filename => { + const stream = createWriteStream(filename) + return stream + }) + splitTextStub = jasmine.createSpy('splitText').and.returnValue(chunks) + ;({ AwsProvider } = proxyquire('../../lib/providers/aws', { + fs: fsSpy, + '../combine': { + combine: combineStub + }, + '../split-text': { + splitText: splitTextStub + } + })) + provider = new AwsProvider({ + region: 'aws-west-1', + accessKey: 'ACCESS KEY', + secretKey: 'SECRET KEY', + format: 'mp3' + }) + }) + + 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', () => { + expect(provider.instance).toEqual(jasmine.any(Polly)) + }) + + it('should configure the Polly object from the options', async () => { + const credentials = await provider.instance.config.credentials() + const region = await provider.instance.config.region() + expect(region).toBe('aws-west-1') + expect(credentials.accessKeyId).toBe('ACCESS KEY') + expect(credentials.secretAccessKey).toBe('SECRET KEY') + }) + }) + + 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('combine()', () => { + it('should call the library 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.combine('foobar', 'raw.pcm') + const args = combineStub.calls.mostRecent().args + expect(args[0]).toBe('foobar') + expect(args[1]).toBe('raw.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.combine('foobar', 'encoded.mp3') + const args = combineStub.calls.mostRecent().args + expect(args[0]).toBe('foobar') + expect(args[1]).toBe('encoded.mp3') + expect(args[2]).toBe('encoded') + expect(args[3]).toBe('ffmpeg-test') + }) + }) + + describe('generate()', () => { + const text = 'hello world' + const filename = tempfile() + + beforeEach(() => { + spyOn(provider.instance, 'send').and.resolveTo({ + AudioStream: Readable.from('testing') + }) + }) + + afterEach(async () => { + try { + await unlink(filename) + } catch (_err) { + // Ignore errors. + } + }) + + it('should work with the MP3 format', async () => { + 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 () => { + 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 () => { + 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 () => { + 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 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 () => { + 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 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 () => { + 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 () => { + 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(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 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 () => { + 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 () => { + 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 () => { + 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 () => { + provider.instance.send.and.rejectWith(new Error('test error')) + try { + await provider.generate(text) + throw new Error('generate() should have thrown an error') + } catch (err) { + expect(err).toEqual(new Error('test error')) + } + }) + + it('should callback with an error if file saving fails', async () => { + fsSpy.createWriteStream.and.callFake(filename => { + const stream = createWriteStream(filename) + stream.on('pipe', () => { + stream.emit('error', new Error('write stream error')) + }) + return stream + }) + try { + 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 new file mode 100644 index 0000000..61d40d7 --- /dev/null +++ b/packages/tts-lib/test/providers/gcp.spec.js @@ -0,0 +1,322 @@ +const TextToSpeechClient = require('@google-cloud/text-to-speech').TextToSpeechClient +const { writeFile } = require('fs/promises') +const path = require('path') +const proxyquire = require('proxyquire') +const tempfile = require('tempfile') + +describe('GCP provider', () => { + let fsStub + let GcpProvider + let provider + + beforeEach(() => { + fsStub = jasmine.createSpyObj('fs', ['writeFile']) + fsStub.writeFile.and.callFake((_dest, _data, _opts) => { Promise.resolve() }) + ;({ GcpProvider } = proxyquire('../../lib/providers/gcp', { + 'fs/promises': fsStub + })) + provider = new GcpProvider({ + 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() + }).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', () => { + expect(provider.instance).toEqual(jasmine.any(TextToSpeechClient)) + }) + + it('should use the email address from the options', () => { + expect(provider.instance.auth.jsonContent.client_email).toBe('foo@example.com') + }) + + it('should use the private key from the options', () => { + expect(provider.instance.auth.jsonContent.private_key).toBe('private key') + }) + + 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', () => { + provider = new GcpProvider({ + email: 'foo@example.com', + 'private-key': 'private key' + }) + expect(provider.instance.auth.keyFilename).toBeUndefined() + }) + + it('should convert the project file to an absolute path if relative', () => { + expect(provider.instance.auth.keyFilename).toBe( + path.resolve('project-file.json') + ) + }) + + it('should use the project file as-is if an absolute path', () => { + provider = new GcpProvider({ + email: 'foo@example.com', + privateKey: 'fake key', + projectFile: path.resolve('project-file.json') + }) + expect(provider.instance.auth.keyFilename).toBe( + path.resolve('project-file.json') + ) + }) + + 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' + } + await writeFile(filename, JSON.stringify(fakeProject), 'utf8') + expect(() => { + new GcpProvider({ 'project-file': filename }) // eslint-disable-line no-new + }).not.toThrow() + }) + }) + + 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('generate()', () => { + const text = 'hello world' + const response = 'fake audio data' + + beforeEach(() => { + spyOn(provider.instance, 'synthesizeSpeech') + }) + + it('should call the synthesizer function', async () => { + provider.instance.synthesizeSpeech.and.callFake((_req, _opts, done) => done(null, {})) + await provider.generate(text) + expect(provider.instance.synthesizeSpeech).toHaveBeenCalled() + }) + + describe('when everything works', () => { + beforeEach(() => { + provider.instance.synthesizeSpeech.and.callFake((_req, _opts, cb) => { + cb(null, { audioContent: response }) + }) + }) + + it('should work with the MP3 format', async () => { + 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 () => { + 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 () => { + 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 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(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 () => { + 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 () => { + 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 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(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(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 () => { + 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 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(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(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(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(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(text) + expect(fsStub.writeFile).toHaveBeenCalledWith( + 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(() => { + 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(text) + throw new Error('generate() should have thrown an error') + } catch (err) { + expect(err.message).toBe('testing GCP error') + } + }) + }) + + describe('when file writing fails', () => { + beforeEach(() => { + provider.instance.synthesizeSpeech.and.callFake((_req, _opts, cb) => { + cb(null, { audioContent: response }) + }) + 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(text) + throw new Error('generate() should have thrown an error') + } catch (err) { + expect(err.message).toBe('testing write error') + } + }) + }) + }) +}) diff --git a/packages/tts-lib/test/split-text.spec.js b/packages/tts-lib/test/split-text.spec.js new file mode 100644 index 0000000..6bedf75 --- /dev/null +++ b/packages/tts-lib/test/split-text.spec.js @@ -0,0 +1,67 @@ +const { splitText } = require('../lib/split-text') + +describe('splitText()', () => { + const maxChars = 1000 + const testData = 'hello world' + + 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', async () => { + const parts = await splitText(testData, 2) + expect(parts).toEqual(['he', 'll', 'o', 'wo', 'rl', 'd']) + }) + + it('should propagate SSML tags through the chunks', async () => { + const text = 'Hello there world' + const parts = await splitText(text, 6, 'ssml') + expect(parts).toEqual([ + 'Hello', + 'there', + 'world' + ]) + }) + + it('should work when SSML tags are duplicated in sequence', async () => { + const text = '

Section 1

Introduction

' + const parts = await splitText(text, 1500, 'ssml') + expect(parts).toEqual([ + '

Section 1

', + '

Introduction

' + ]) + }) + + it('should NOT propagate SSML tags for non-SSML text', async () => { + const text = 'Hello there world' + const parts = await splitText(text, 6) + expect(parts).toEqual([ + 'Hello', + 'there', + 'world<', + '/speak', + '>' + ]) + }) + + it('should condense whitespace', async () => { + const text = 'hello world' + const parts = await splitText(text, maxChars) + expect(parts).toEqual(['hello world']) + }) + + it('should trim whitespace from the ends', async () => { + const text = ' hello world ' + const parts = await splitText(text, maxChars) + expect(parts).toEqual(['hello world']) + }) + + describe('when type is undefined', () => { + it('should still work', async () => { + const parts = await splitText(testData, maxChars, undefined) + 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