js-tts-wrapper
Version:
A JavaScript/TypeScript library that provides a unified API for working with multiple cloud-based Text-to-Speech (TTS) services
501 lines (500 loc) • 18.4 kB
JavaScript
/**
* Utility functions for playing audio in Node.js
*/
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
Object.defineProperty(exports, "__esModule", { value: true });
exports.isNodeAudioAvailable = isNodeAudioAvailable;
exports.playAudioInNode = playAudioInNode;
exports.pauseAudioPlayback = pauseAudioPlayback;
exports.resumeAudioPlayback = resumeAudioPlayback;
exports.stopAudioPlayback = stopAudioPlayback;
const environment_1 = require("./environment");
const audioState = {
isPlaying: false,
isPaused: false,
currentProcess: null,
tempFile: null,
childProcess: null,
fs: null,
};
// This function is no longer used, but we keep it for reference
// eslint-disable-next-line @typescript-eslint/no-unused-vars
// @ts-ignore
async function _unusedDynamicRequire(moduleName) {
// In Node.js, we can use a dynamic import
if (environment_1.isNode) {
try {
// For Node.js built-in modules
if (moduleName.startsWith("node:")) {
return await Promise.resolve(`${moduleName}`).then(s => __importStar(require(s)));
}
// For third-party modules
return await Promise.resolve(`${moduleName}`).then(s => __importStar(require(s)));
}
catch (_importError) {
// Fallback to global require if available
try {
// @ts-ignore
return require(moduleName);
}
catch (_requireError) {
// Ignore errors
}
}
}
throw new Error(`Failed to load module: ${moduleName}`);
}
/**
* Check if Node.js audio playback is available
* @returns True if Node.js audio playback is available
*/
async function isNodeAudioAvailable() {
if (!environment_1.isNode)
return false;
try {
// Try to load required modules
const childProcess = await Promise.resolve().then(() => __importStar(require("node:child_process")));
const fs = await Promise.resolve().then(() => __importStar(require("node:fs")));
// Store for later use
audioState.childProcess = childProcess;
audioState.fs = fs;
// Check if we have a suitable audio player
const platform = process.platform;
if (platform === "darwin") {
// Check if afplay is available on macOS
try {
childProcess.execSync("which afplay", { stdio: "ignore" });
return true;
}
catch (_error) {
return false;
}
}
else if (platform === "win32") {
// Windows should have PowerShell available
return true;
}
else {
// Check if aplay is available on Linux
try {
childProcess.execSync("which aplay", { stdio: "ignore" });
return true;
}
catch (_error) {
return false;
}
}
}
catch (_error) {
return false;
}
}
// These functions are no longer used, but we keep them for reference
// in case we need to handle WAV files in the future
/**
* Parse a WAV file header to extract audio format information
* @param audioBytes Audio data as Uint8Array
* @returns Object containing audio format information
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
// @ts-ignore
function _unusedParseWavHeader(audioBytes) {
const result = {
sampleRate: 0,
numChannels: 0,
bitsPerSample: 0,
dataOffset: 0,
dataSize: 0,
isValidWav: false,
};
// Check if it's a valid WAV file (has RIFF header)
const hasRiffHeader = audioBytes.length >= 4 &&
audioBytes[0] === 0x52 && // 'R'
audioBytes[1] === 0x49 && // 'I'
audioBytes[2] === 0x46 && // 'F'
audioBytes[3] === 0x46; // 'F'
if (!hasRiffHeader || audioBytes.length < 44) {
return result;
}
result.isValidWav = true;
result.numChannels = audioBytes[22] | (audioBytes[23] << 8);
result.sampleRate =
audioBytes[24] | (audioBytes[25] << 8) | (audioBytes[26] << 16) | (audioBytes[27] << 24);
result.bitsPerSample = audioBytes[34] | (audioBytes[35] << 8);
// Find the data chunk
let offset = 36;
while (offset < audioBytes.length - 8) {
if (audioBytes[offset] === 0x64 && // 'd'
audioBytes[offset + 1] === 0x61 && // 'a'
audioBytes[offset + 2] === 0x74 && // 't'
audioBytes[offset + 3] === 0x61 // 'a'
) {
// Found the data chunk
const dataSize = audioBytes[offset + 4] |
(audioBytes[offset + 5] << 8) |
(audioBytes[offset + 6] << 16) |
(audioBytes[offset + 7] << 24);
result.dataOffset = offset + 8;
result.dataSize = dataSize;
break;
}
offset += 4;
// Skip the chunk size
const chunkSize = audioBytes[offset] |
(audioBytes[offset + 1] << 8) |
(audioBytes[offset + 2] << 16) |
(audioBytes[offset + 3] << 24);
offset += 4 + chunkSize;
}
return result;
}
/**
* Create a WAV file with the given audio data
* @param audioBytes Audio data as Uint8Array
* @param sampleRate Sample rate in Hz
* @param numChannels Number of channels
* @param bitsPerSample Bits per sample
* @returns Uint8Array containing the WAV file
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
// @ts-ignore
function _unusedCreateWavFile(audioBytes, sampleRate, numChannels, bitsPerSample) {
// Calculate sizes
const dataSize = audioBytes.length;
const blockAlign = numChannels * (bitsPerSample / 8);
const byteRate = sampleRate * blockAlign;
// Create WAV header
const headerSize = 44; // Standard WAV header size
const wavFile = new Uint8Array(headerSize + dataSize);
// RIFF header
wavFile.set([0x52, 0x49, 0x46, 0x46]); // "RIFF"
// Chunk size (file size - 8)
const chunkSize = headerSize + dataSize - 8;
wavFile[4] = chunkSize & 0xff;
wavFile[5] = (chunkSize >> 8) & 0xff;
wavFile[6] = (chunkSize >> 16) & 0xff;
wavFile[7] = (chunkSize >> 24) & 0xff;
wavFile.set([0x57, 0x41, 0x56, 0x45], 8); // "WAVE"
// Format chunk
wavFile.set([0x66, 0x6d, 0x74, 0x20], 12); // "fmt "
wavFile[16] = 16; // Chunk size (16 for PCM)
wavFile[17] = 0;
wavFile[18] = 0;
wavFile[19] = 0;
wavFile[20] = 1; // Audio format (1 for PCM)
wavFile[21] = 0;
wavFile[22] = numChannels; // Number of channels
wavFile[23] = 0;
// Sample rate
wavFile[24] = sampleRate & 0xff;
wavFile[25] = (sampleRate >> 8) & 0xff;
wavFile[26] = (sampleRate >> 16) & 0xff;
wavFile[27] = (sampleRate >> 24) & 0xff;
// Byte rate
wavFile[28] = byteRate & 0xff;
wavFile[29] = (byteRate >> 8) & 0xff;
wavFile[30] = (byteRate >> 16) & 0xff;
wavFile[31] = (byteRate >> 24) & 0xff;
// Block align
wavFile[32] = blockAlign & 0xff;
wavFile[33] = (blockAlign >> 8) & 0xff;
// Bits per sample
wavFile[34] = bitsPerSample & 0xff;
wavFile[35] = (bitsPerSample >> 8) & 0xff;
// Data chunk
wavFile.set([0x64, 0x61, 0x74, 0x61], 36); // "data"
// Data size
wavFile[40] = dataSize & 0xff;
wavFile[41] = (dataSize >> 8) & 0xff;
wavFile[42] = (dataSize >> 16) & 0xff;
wavFile[43] = (dataSize >> 24) & 0xff;
// Add audio data
wavFile.set(audioBytes, 44);
return wavFile;
}
/**
* Create a WAV header for raw PCM audio data
* @param audioData Raw PCM audio data
* @param sampleRate Sample rate in Hz
* @param numChannels Number of audio channels (default: 1)
* @param bitsPerSample Bits per sample (default: 16)
* @returns Uint8Array containing the complete WAV file
*/
function createWavFile(audioData, sampleRate, numChannels = 1, bitsPerSample = 16) {
// Calculate sizes
const dataSize = audioData.length;
const blockAlign = numChannels * (bitsPerSample / 8);
const byteRate = sampleRate * blockAlign;
// Create WAV header (44 bytes)
const headerSize = 44;
const wavFile = new Uint8Array(headerSize + dataSize);
// RIFF header
wavFile.set([0x52, 0x49, 0x46, 0x46]); // "RIFF"
// Chunk size (file size - 8)
const chunkSize = headerSize + dataSize - 8;
wavFile[4] = chunkSize & 0xff;
wavFile[5] = (chunkSize >> 8) & 0xff;
wavFile[6] = (chunkSize >> 16) & 0xff;
wavFile[7] = (chunkSize >> 24) & 0xff;
wavFile.set([0x57, 0x41, 0x56, 0x45], 8); // "WAVE"
// Format chunk
wavFile.set([0x66, 0x6d, 0x74, 0x20], 12); // "fmt "
wavFile[16] = 16; // Chunk size (16 for PCM)
wavFile[17] = 0;
wavFile[18] = 0;
wavFile[19] = 0;
wavFile[20] = 1; // Audio format (1 for PCM)
wavFile[21] = 0;
wavFile[22] = numChannels; // Number of channels
wavFile[23] = 0;
// Sample rate
wavFile[24] = sampleRate & 0xff;
wavFile[25] = (sampleRate >> 8) & 0xff;
wavFile[26] = (sampleRate >> 16) & 0xff;
wavFile[27] = (sampleRate >> 24) & 0xff;
// Byte rate
wavFile[28] = byteRate & 0xff;
wavFile[29] = (byteRate >> 8) & 0xff;
wavFile[30] = (byteRate >> 16) & 0xff;
wavFile[31] = (byteRate >> 24) & 0xff;
// Block align
wavFile[32] = blockAlign & 0xff;
wavFile[33] = (blockAlign >> 8) & 0xff;
// Bits per sample
wavFile[34] = bitsPerSample & 0xff;
wavFile[35] = (bitsPerSample >> 8) & 0xff;
// Data chunk
wavFile.set([0x64, 0x61, 0x74, 0x61], 36); // "data"
// Data size
wavFile[40] = dataSize & 0xff;
wavFile[41] = (dataSize >> 8) & 0xff;
wavFile[42] = (dataSize >> 16) & 0xff;
wavFile[43] = (dataSize >> 24) & 0xff;
// Add audio data
wavFile.set(audioData, 44);
return wavFile;
}
/**
* Check if audio data is raw PCM (no WAV header)
* @param audioBytes Audio data to check
* @returns True if the data appears to be raw PCM
*/
function isRawPCM(audioBytes) {
// Check if it's NOT a WAV file (doesn't start with RIFF header)
const hasRiffHeader = audioBytes.length >= 4 &&
audioBytes[0] === 0x52 && // 'R'
audioBytes[1] === 0x49 && // 'I'
audioBytes[2] === 0x46 && // 'F'
audioBytes[3] === 0x46; // 'F'
return !hasRiffHeader;
}
/**
* Play audio in Node.js
* @param audioBytes Audio data as Uint8Array
* @param sampleRate Sample rate in Hz (default: 24000 for WitAI, 16000 for others)
* @param engineName Name of the TTS engine (used to determine if raw PCM conversion is needed)
* @returns Promise that resolves when audio playback is complete
*/
async function playAudioInNode(audioBytes, sampleRate, engineName) {
if (!environment_1.isNode) {
throw new Error("This function can only be used in Node.js");
}
// Stop any currently playing audio
stopAudioPlayback();
try {
// Load required modules if not already loaded
if (!audioState.childProcess || !audioState.fs) {
audioState.childProcess = await Promise.resolve().then(() => __importStar(require("node:child_process")));
audioState.fs = await Promise.resolve().then(() => __importStar(require("node:fs")));
}
const os = await Promise.resolve().then(() => __importStar(require("node:os")));
const path = await Promise.resolve().then(() => __importStar(require("node:path")));
// Create a temporary file to play
const tempDir = os.tmpdir();
const tempFile = path.join(tempDir, `tts-audio-${Date.now()}.wav`);
audioState.tempFile = tempFile;
// Determine if we need to add a WAV header
let finalAudioBytes = audioBytes;
// Check if this is raw PCM data that needs a WAV header
if (isRawPCM(audioBytes)) {
// Determine sample rate based on engine
let actualSampleRate = sampleRate || 24000; // Default to WitAI's sample rate
if (engineName === "witai") {
actualSampleRate = 24000;
}
else if (engineName === "polly") {
actualSampleRate = 16000;
}
finalAudioBytes = createWavFile(audioBytes, actualSampleRate);
}
// Write the audio data to the temp file
audioState.fs.writeFileSync(tempFile, Buffer.from(finalAudioBytes));
console.log(`Audio saved to temporary file: ${tempFile}`);
// Determine which player to use based on platform
let command;
let args;
const platform = process.platform;
if (platform === "darwin") {
// macOS
command = "afplay";
args = [tempFile];
}
else if (platform === "win32") {
// Windows
command = "powershell";
args = ["-c", `(New-Object System.Media.SoundPlayer "${tempFile}").PlaySync()`];
}
else {
// Linux and others - try to use aplay
command = "aplay";
args = ["-q", tempFile];
}
console.log(`Playing audio with ${command}...`);
return new Promise((resolve, reject) => {
try {
// Spawn the process
const process = audioState.childProcess.spawn(command, args);
audioState.currentProcess = process;
audioState.isPlaying = true;
audioState.isPaused = false;
// Handle process events
process.on("close", (code) => {
console.log(`Audio playback process exited with code ${code}`);
cleanupTempFile();
audioState.currentProcess = null;
audioState.isPlaying = false;
audioState.isPaused = false;
resolve();
});
process.on("error", (err) => {
console.error("Audio playback process error:", err);
cleanupTempFile();
audioState.currentProcess = null;
audioState.isPlaying = false;
audioState.isPaused = false;
reject(err);
});
}
catch (error) {
console.error("Error starting audio playback:", error);
cleanupTempFile();
audioState.currentProcess = null;
audioState.isPlaying = false;
audioState.isPaused = false;
reject(error);
}
});
}
catch (error) {
cleanupTempFile();
throw error;
}
}
/**
* Clean up temporary audio file
*/
function cleanupTempFile() {
if (audioState.tempFile && audioState.fs) {
try {
if (audioState.fs.existsSync(audioState.tempFile)) {
audioState.fs.unlinkSync(audioState.tempFile);
}
}
catch (error) {
console.error("Error cleaning up temp file:", error);
}
audioState.tempFile = null;
}
}
/**
* Pause audio playback by stopping the current process
* @returns True if playback was paused, false otherwise
*/
function pauseAudioPlayback() {
if (audioState.currentProcess && audioState.isPlaying && !audioState.isPaused) {
try {
// We'll implement pause by killing the process
// This is a simple approach that works across platforms
console.log("Pausing audio playback...");
if (audioState.currentProcess.kill) {
audioState.currentProcess.kill();
audioState.isPaused = true;
return true;
}
}
catch (error) {
console.error("Error pausing audio playback:", error);
}
}
return false;
}
/**
* Resume audio playback is not supported in this implementation
* @returns Always false as resume is not supported
*/
function resumeAudioPlayback() {
console.log("Resume not supported in the current implementation");
return false;
}
/**
* Stop audio playback
* @returns True if playback was stopped, false otherwise
*/
function stopAudioPlayback() {
if (audioState.currentProcess && (audioState.isPlaying || audioState.isPaused)) {
try {
// Kill the audio playback process
if (audioState.currentProcess.kill) {
audioState.currentProcess.kill();
}
console.log("Stopped audio playback");
// Clean up
cleanupTempFile();
audioState.currentProcess = null;
audioState.isPlaying = false;
audioState.isPaused = false;
return true;
}
catch (error) {
console.error("Error stopping audio playback:", error);
// Reset state even if there was an error
audioState.currentProcess = null;
audioState.isPlaying = false;
audioState.isPaused = false;
}
}
return false;
}
;