diff --git a/package.json b/package.json index c6c6ca4a29..eccc18853b 100644 --- a/package.json +++ b/package.json @@ -146,6 +146,11 @@ "types": "./dist/cli/index.d.ts", "import": "./dist/cli/index.js", "default": "./dist/cli/index.js" + }, + "./adapters/tts": { + "types": "./dist/adapters/tts/index.d.ts", + "import": "./dist/adapters/tts/index.js", + "default": "./dist/adapters/tts/index.js" } }, "dependencies": { @@ -189,6 +194,7 @@ "mem0ai": "^2.1.38", "nanoid": "^5.1.5", "ollama-ai-provider": "^1.2.0", + "openai": "^6.10.0", "ora": "^7.0.1", "p-limit": "^6.2.0", "pdfjs-dist": "^5.4.296", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4e4c48b3be..ff3cb2f516 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -129,13 +129,16 @@ importers: version: 14.8.2 mem0ai: specifier: ^2.1.38 - version: 2.1.38(@anthropic-ai/sdk@0.40.1(encoding@0.1.13))(@cloudflare/workers-types@4.20251004.0)(@google/genai@1.22.0(@modelcontextprotocol/sdk@1.19.1)(encoding@0.1.13))(@langchain/core@0.3.78(@opentelemetry/api@1.9.0)(@opentelemetry/exporter-trace-otlp-proto@0.57.2(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.1.0(@opentelemetry/api@1.9.0))(openai@4.104.0(encoding@0.1.13)(ws@8.18.3)(zod@3.25.76)))(@mistralai/mistralai@1.10.0)(@qdrant/js-client-rest@1.13.0(typescript@5.9.3))(@supabase/supabase-js@2.74.0)(@types/jest@29.5.14)(@types/pg@8.11.0)(@types/sqlite3@3.1.11)(cloudflare@4.5.0(encoding@0.1.13))(encoding@0.1.13)(groq-sdk@0.3.0(encoding@0.1.13))(neo4j-driver@5.28.2)(ollama@0.5.18)(pg@8.11.3)(redis@5.8.3)(sqlite3@5.1.7)(ws@8.18.3) + version: 2.1.38(@anthropic-ai/sdk@0.40.1(encoding@0.1.13))(@cloudflare/workers-types@4.20251004.0)(@google/genai@1.22.0(@modelcontextprotocol/sdk@1.19.1)(encoding@0.1.13))(@langchain/core@0.3.78(@opentelemetry/api@1.9.0)(@opentelemetry/exporter-trace-otlp-proto@0.57.2(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.1.0(@opentelemetry/api@1.9.0))(openai@6.10.0(ws@8.18.3)(zod@3.25.76)))(@mistralai/mistralai@1.10.0)(@qdrant/js-client-rest@1.13.0(typescript@5.9.3))(@supabase/supabase-js@2.74.0)(@types/jest@29.5.14)(@types/pg@8.11.0)(@types/sqlite3@3.1.11)(cloudflare@4.5.0(encoding@0.1.13))(encoding@0.1.13)(groq-sdk@0.3.0(encoding@0.1.13))(neo4j-driver@5.28.2)(ollama@0.5.18)(pg@8.11.3)(redis@5.8.3)(sqlite3@5.1.7)(ws@8.18.3) nanoid: specifier: ^5.1.5 version: 5.1.6 ollama-ai-provider: specifier: ^1.2.0 version: 1.2.0(zod@3.25.76) + openai: + specifier: ^6.10.0 + version: 6.10.0(ws@8.18.3)(zod@3.25.76) ora: specifier: ^7.0.1 version: 7.0.1 @@ -4666,6 +4669,18 @@ packages: zod: optional: true + openai@6.10.0: + resolution: {integrity: sha512-ITxOGo7rO3XRMiKA5l7tQ43iNNu+iXGFAcf2t+aWVzzqRaS0i7m1K2BhxNdaveB+5eENhO0VY1FkiZzhBk4v3A==} + hasBin: true + peerDependencies: + ws: ^8.18.0 + zod: ^3.25 || ^4.0 + peerDependenciesMeta: + ws: + optional: true + zod: + optional: true + optionator@0.9.4: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} @@ -7131,14 +7146,14 @@ snapshots: '@js-sdsl/ordered-map@4.4.2': {} - '@langchain/core@0.3.78(@opentelemetry/api@1.9.0)(@opentelemetry/exporter-trace-otlp-proto@0.57.2(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.1.0(@opentelemetry/api@1.9.0))(openai@4.104.0(encoding@0.1.13)(ws@8.18.3)(zod@3.25.76))': + '@langchain/core@0.3.78(@opentelemetry/api@1.9.0)(@opentelemetry/exporter-trace-otlp-proto@0.57.2(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.1.0(@opentelemetry/api@1.9.0))(openai@6.10.0(ws@8.18.3)(zod@3.25.76))': dependencies: '@cfworker/json-schema': 4.1.1 ansi-styles: 5.2.0 camelcase: 6.3.0 decamelize: 1.2.0 js-tiktoken: 1.0.21 - langsmith: 0.3.79(@opentelemetry/api@1.9.0)(@opentelemetry/exporter-trace-otlp-proto@0.57.2(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.1.0(@opentelemetry/api@1.9.0))(openai@4.104.0(encoding@0.1.13)(ws@8.18.3)(zod@3.25.76)) + langsmith: 0.3.79(@opentelemetry/api@1.9.0)(@opentelemetry/exporter-trace-otlp-proto@0.57.2(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.1.0(@opentelemetry/api@1.9.0))(openai@6.10.0(ws@8.18.3)(zod@3.25.76)) mustache: 4.2.0 p-queue: 6.6.2 p-retry: 4.6.2 @@ -10903,7 +10918,7 @@ snapshots: kleur@4.1.5: {} - langsmith@0.3.79(@opentelemetry/api@1.9.0)(@opentelemetry/exporter-trace-otlp-proto@0.57.2(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.1.0(@opentelemetry/api@1.9.0))(openai@4.104.0(encoding@0.1.13)(ws@8.18.3)(zod@3.25.76)): + langsmith@0.3.79(@opentelemetry/api@1.9.0)(@opentelemetry/exporter-trace-otlp-proto@0.57.2(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.1.0(@opentelemetry/api@1.9.0))(openai@6.10.0(ws@8.18.3)(zod@3.25.76)): dependencies: '@types/uuid': 10.0.0 chalk: 4.1.2 @@ -10916,7 +10931,7 @@ snapshots: '@opentelemetry/api': 1.9.0 '@opentelemetry/exporter-trace-otlp-proto': 0.57.2(@opentelemetry/api@1.9.0) '@opentelemetry/sdk-trace-base': 2.1.0(@opentelemetry/api@1.9.0) - openai: 4.104.0(encoding@0.1.13)(ws@8.18.3)(zod@3.25.76) + openai: 6.10.0(ws@8.18.3)(zod@3.25.76) levn@0.4.1: dependencies: @@ -11091,12 +11106,12 @@ snapshots: media-typer@1.1.0: {} - mem0ai@2.1.38(@anthropic-ai/sdk@0.40.1(encoding@0.1.13))(@cloudflare/workers-types@4.20251004.0)(@google/genai@1.22.0(@modelcontextprotocol/sdk@1.19.1)(encoding@0.1.13))(@langchain/core@0.3.78(@opentelemetry/api@1.9.0)(@opentelemetry/exporter-trace-otlp-proto@0.57.2(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.1.0(@opentelemetry/api@1.9.0))(openai@4.104.0(encoding@0.1.13)(ws@8.18.3)(zod@3.25.76)))(@mistralai/mistralai@1.10.0)(@qdrant/js-client-rest@1.13.0(typescript@5.9.3))(@supabase/supabase-js@2.74.0)(@types/jest@29.5.14)(@types/pg@8.11.0)(@types/sqlite3@3.1.11)(cloudflare@4.5.0(encoding@0.1.13))(encoding@0.1.13)(groq-sdk@0.3.0(encoding@0.1.13))(neo4j-driver@5.28.2)(ollama@0.5.18)(pg@8.11.3)(redis@5.8.3)(sqlite3@5.1.7)(ws@8.18.3): + mem0ai@2.1.38(@anthropic-ai/sdk@0.40.1(encoding@0.1.13))(@cloudflare/workers-types@4.20251004.0)(@google/genai@1.22.0(@modelcontextprotocol/sdk@1.19.1)(encoding@0.1.13))(@langchain/core@0.3.78(@opentelemetry/api@1.9.0)(@opentelemetry/exporter-trace-otlp-proto@0.57.2(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.1.0(@opentelemetry/api@1.9.0))(openai@6.10.0(ws@8.18.3)(zod@3.25.76)))(@mistralai/mistralai@1.10.0)(@qdrant/js-client-rest@1.13.0(typescript@5.9.3))(@supabase/supabase-js@2.74.0)(@types/jest@29.5.14)(@types/pg@8.11.0)(@types/sqlite3@3.1.11)(cloudflare@4.5.0(encoding@0.1.13))(encoding@0.1.13)(groq-sdk@0.3.0(encoding@0.1.13))(neo4j-driver@5.28.2)(ollama@0.5.18)(pg@8.11.3)(redis@5.8.3)(sqlite3@5.1.7)(ws@8.18.3): dependencies: '@anthropic-ai/sdk': 0.40.1(encoding@0.1.13) '@cloudflare/workers-types': 4.20251004.0 '@google/genai': 1.22.0(@modelcontextprotocol/sdk@1.19.1)(encoding@0.1.13) - '@langchain/core': 0.3.78(@opentelemetry/api@1.9.0)(@opentelemetry/exporter-trace-otlp-proto@0.57.2(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.1.0(@opentelemetry/api@1.9.0))(openai@4.104.0(encoding@0.1.13)(ws@8.18.3)(zod@3.25.76)) + '@langchain/core': 0.3.78(@opentelemetry/api@1.9.0)(@opentelemetry/exporter-trace-otlp-proto@0.57.2(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.1.0(@opentelemetry/api@1.9.0))(openai@6.10.0(ws@8.18.3)(zod@3.25.76)) '@mistralai/mistralai': 1.10.0 '@qdrant/js-client-rest': 1.13.0(typescript@5.9.3) '@supabase/supabase-js': 2.74.0 @@ -11396,6 +11411,11 @@ snapshots: transitivePeerDependencies: - encoding + openai@6.10.0(ws@8.18.3)(zod@3.25.76): + optionalDependencies: + ws: 8.18.3 + zod: 3.25.76 + optionator@0.9.4: dependencies: deep-is: 0.1.4 diff --git a/src/lib/adapters/tts/README.md b/src/lib/adapters/tts/README.md new file mode 100644 index 0000000000..9ff4f44015 --- /dev/null +++ b/src/lib/adapters/tts/README.md @@ -0,0 +1,186 @@ +# OpenAI TTS Handler - Usage Examples + +This document provides examples of using the `OpenAITTSHandler` for text-to-speech synthesis. + +## Basic Usage + +```typescript +import { OpenAITTSHandler } from "@juspay/neurolink/adapters/tts"; + +// Initialize handler with API key +const ttsHandler = new OpenAITTSHandler(process.env.OPENAI_API_KEY); + +// Synthesize text with default settings +const result = await ttsHandler.synthesize({ + text: "Hello, world!", +}); + +console.log(`Audio format: ${result.format}`); +console.log(`Buffer size: ${result.size} bytes`); +console.log(`Duration: ${result.duration} seconds`); +console.log(`Voice: ${result.voice}`); + +// Save audio to file +import { writeFileSync } from "fs"; +writeFileSync("output.mp3", result.buffer); +``` + +## Supported Voices + +The handler supports all 6 OpenAI voices: + +- `alloy` (default) - Neutral, balanced voice +- `echo` - Clear and articulate +- `fable` - British accent, storytelling +- `onyx` - Deep, authoritative +- `nova` - Energetic and enthusiastic +- `shimmer` - Warm and friendly + +```typescript +// Use a specific voice +const result = await ttsHandler.synthesize({ + text: "Welcome to our application!", + voice: "nova", +}); +``` + +## Supported Formats + +The handler supports all OpenAI TTS formats: + +- `mp3` (default) - Compressed, good quality +- `opus` - High compression, web streaming +- `aac` - Advanced audio coding +- `flac` - Lossless, best quality +- `wav` - Uncompressed, best quality +- `pcm` - Raw PCM audio + +```typescript +// Generate high-quality WAV audio +const result = await ttsHandler.synthesize({ + text: "High quality audio", + format: "wav", +}); +``` + +## Quality Levels + +Choose between standard and HD quality: + +- `standard` (default) - Uses `tts-1` model +- `hd` - Uses `tts-1-hd` model (higher quality, slower) + +```typescript +// Generate HD quality audio +const result = await ttsHandler.synthesize({ + text: "High definition speech", + quality: "hd", +}); +``` + +## Speed Control + +Adjust speech speed from 0.25x to 4.0x (default: 1.0x): + +```typescript +// Slow speech for language learning +const slow = await ttsHandler.synthesize({ + text: "This is slow speech", + speed: 0.5, +}); + +// Fast speech for efficiency +const fast = await ttsHandler.synthesize({ + text: "This is fast speech", + speed: 2.0, +}); +``` + +## Complete Example + +```typescript +import { OpenAITTSHandler } from "@juspay/neurolink/adapters/tts"; +import { writeFileSync } from "fs"; + +async function generatePodcastIntro() { + const handler = new OpenAITTSHandler(); + + const result = await handler.synthesize({ + text: "Welcome to Tech Insights Podcast, episode 42. Today we're discussing the future of AI.", + voice: "onyx", + format: "mp3", + quality: "hd", + speed: 0.95, // Slightly slower for clarity + }); + + // Save to file + writeFileSync("podcast-intro.mp3", result.buffer); + + console.log(`Generated ${result.size} bytes of audio`); + console.log( + `Estimated duration: ${result.duration?.toFixed(2)} seconds`, + ); + console.log(`Voice used: ${result.voice}`); + + return result; +} + +generatePodcastIntro(); +``` + +## Error Handling + +The handler validates all inputs and provides clear error messages: + +```typescript +try { + const result = await handler.synthesize({ + text: "", // Empty text + }); +} catch (error) { + console.error(error.message); // "Text is required for TTS synthesis" +} + +try { + const result = await handler.synthesize({ + text: "test", + speed: 5.0, // Invalid speed + }); +} catch (error) { + console.error(error.message); // "Speed must be between 0.25 and 4.0" +} +``` + +## Result Structure + +The result includes the audio data and metadata: + +```typescript +const result = await handler.synthesize({ + text: "Sample text", +}); + +console.log(result); +// { +// buffer: Buffer, // Audio data +// format: "mp3", // Audio format +// size: 38400, // Buffer size in bytes +// duration: 2.4, // Estimated duration in seconds +// voice: "alloy", // Voice used +// sampleRate: undefined // Sample rate (not provided by OpenAI) +// } +``` + +## Getting Supported Options + +```typescript +const handler = new OpenAITTSHandler(); + +// Get all supported voices +const voices = handler.getSupportedVoices(); +console.log(voices); // ["alloy", "echo", "fable", "onyx", "nova", "shimmer"] + +// Get all supported formats +const formats = handler.getSupportedFormats(); +console.log(formats); // ["mp3", "opus", "aac", "flac", "wav", "pcm"] +``` diff --git a/src/lib/adapters/tts/index.ts b/src/lib/adapters/tts/index.ts new file mode 100644 index 0000000000..eed26e2a98 --- /dev/null +++ b/src/lib/adapters/tts/index.ts @@ -0,0 +1,9 @@ +/** + * TTS Adapters Index + * + * Exports TTS handler implementations + * + * @module adapters/tts + */ + +export { OpenAITTSHandler } from "./openaiTTSHandler.js"; diff --git a/src/lib/adapters/tts/openaiTTSHandler.ts b/src/lib/adapters/tts/openaiTTSHandler.ts new file mode 100644 index 0000000000..b0cf5e759b --- /dev/null +++ b/src/lib/adapters/tts/openaiTTSHandler.ts @@ -0,0 +1,206 @@ +/** + * OpenAI TTS Handler + * + * Implements text-to-speech synthesis using OpenAI's TTS API. + * Supports models: tts-1, tts-1-hd + * Supports voices: alloy, echo, fable, onyx, nova, shimmer + * Supports formats: mp3, opus, aac, flac, wav, pcm + * + * @module adapters/tts/openaiTTSHandler + */ + +import OpenAI from "openai"; +import { logger } from "../../utils/logger.js"; +import type { + ITTSHandler, + TTSOptions, + TTSResult, + AudioFormat, + OpenAITTSVoice, +} from "../../types/ttsTypes.js"; + +/** + * Audio formats supported by OpenAI's TTS API + */ +type OpenAIAudioFormat = "mp3" | "opus" | "aac" | "flac" | "wav" | "pcm"; + +/** + * OpenAI TTS Handler implementation + */ +export class OpenAITTSHandler implements ITTSHandler { + private client: OpenAI; + private readonly supportedVoices: OpenAITTSVoice[] = [ + "alloy", + "echo", + "fable", + "onyx", + "nova", + "shimmer", + ]; + private readonly supportedFormats: OpenAIAudioFormat[] = [ + "mp3", + "opus", + "aac", + "flac", + "wav", + "pcm", + ]; + + /** + * Create a new OpenAI TTS Handler + * @param apiKey - OpenAI API key (defaults to OPENAI_API_KEY env var) + */ + constructor(apiKey?: string) { + this.client = new OpenAI({ + apiKey: apiKey || process.env.OPENAI_API_KEY, + }); + } + + /** + * Synthesize text to speech using OpenAI's TTS API + * @param options - Synthesis options + * @returns Promise resolving to TTS result with audio buffer and metadata + */ + async synthesize(options: TTSOptions): Promise { + const startTime = Date.now(); + + // Validate text is provided + if (!options.text) { + throw new Error("Text is required for TTS synthesis"); + } + + // Validate and set defaults + const voice = (options.voice || "alloy") as OpenAITTSVoice; + const format = (options.format || "mp3") as OpenAIAudioFormat; + const quality = options.quality || "standard"; + const speed = options.speed ?? 1.0; + + // Validate inputs + this.validateInputs(options.text, voice, format, speed); + + // Select model based on quality + const model = quality === "hd" ? "tts-1-hd" : "tts-1"; + + try { + logger.debug("OpenAI TTS synthesis starting", { + model, + voice, + format, + speed, + textLength: options.text.length, + }); + + // Call OpenAI TTS API + const response = await this.client.audio.speech.create({ + model, + voice, + input: options.text, + response_format: format, + speed, + }); + + // Convert response to ArrayBuffer then to Buffer + const arrayBuffer = await response.arrayBuffer(); + const buffer = Buffer.from(arrayBuffer); + + const endTime = Date.now(); + const latency = endTime - startTime; + + // Estimate duration based on text length and speed + // Rough estimate: ~150 words per minute at speed 1.0 + const wordCount = options.text.split(/\s+/).length; + const estimatedDuration = ((wordCount / 150) * 60) / speed; + + const result: TTSResult = { + buffer, + format, + size: buffer.length, + duration: estimatedDuration, + voice, + sampleRate: undefined, // OpenAI doesn't provide sample rate in response + }; + + logger.debug("OpenAI TTS synthesis completed", { + latency, + bufferSize: buffer.length, + estimatedDuration, + }); + + return result; + } catch (error) { + const latency = Date.now() - startTime; + logger.error("OpenAI TTS synthesis failed", { + error, + latency, + model, + voice, + format, + }); + + // Re-throw with enhanced error information + if (error instanceof Error) { + throw new Error(`OpenAI TTS synthesis failed: ${error.message}`); + } + throw error; + } + } + + /** + * Validate synthesis inputs + * @param text - Input text + * @param voice - Voice name + * @param format - Audio format + * @param speed - Speech speed + * @throws Error if validation fails + */ + private validateInputs( + text: string, + voice: string, + format: OpenAIAudioFormat, + speed: number, + ): void { + // Validate text + if (!text || text.trim().length === 0) { + throw new Error("Text is required for TTS synthesis"); + } + + if (text.length > 4096) { + throw new Error("Text exceeds maximum length of 4096 characters"); + } + + // Validate voice + if (!this.supportedVoices.includes(voice as OpenAITTSVoice)) { + throw new Error( + `Unsupported voice: ${voice}. Supported voices: ${this.supportedVoices.join(", ")}`, + ); + } + + // Validate format + if (!this.supportedFormats.includes(format)) { + throw new Error( + `Unsupported format: ${format}. Supported formats: ${this.supportedFormats.join(", ")}`, + ); + } + + // Validate speed + if (speed < 0.25 || speed > 4.0) { + throw new Error("Speed must be between 0.25 and 4.0"); + } + } + + /** + * Get list of supported voices + * @returns Array of supported voice names + */ + getSupportedVoices(): string[] { + return [...this.supportedVoices]; + } + + /** + * Get list of supported audio formats + * @returns Array of supported format names + */ + getSupportedFormats(): AudioFormat[] { + return [...this.supportedFormats]; + } +} diff --git a/src/lib/types/ttsTypes.ts b/src/lib/types/ttsTypes.ts index e929e2124c..9dc177ac29 100644 --- a/src/lib/types/ttsTypes.ts +++ b/src/lib/types/ttsTypes.ts @@ -9,7 +9,7 @@ /** * Supported audio formats for TTS output */ -export type AudioFormat = "mp3" | "wav" | "ogg" | "opus"; +export type AudioFormat = "mp3" | "wav" | "ogg" | "opus" | "aac" | "flac" | "pcm"; /** * TTS quality settings @@ -20,6 +20,8 @@ export type TTSQuality = "standard" | "hd"; * TTS configuration options */ export type TTSOptions = { + /** Text to synthesize */ + text?: string; /** Enable TTS output */ enabled?: boolean; /** Voice identifier (e.g., "en-US-Neural2-C") */ @@ -90,6 +92,9 @@ export const VALID_AUDIO_FORMATS: readonly AudioFormat[] = [ "wav", "ogg", "opus", + "aac", + "flac", + "pcm", ]; /** Valid TTS quality levels as an array for runtime validation */ @@ -141,3 +146,38 @@ export function isValidTTSOptions(options: unknown): options is TTSOptions { } return true; } + +/** + * OpenAI TTS-specific voice names + */ +export type OpenAITTSVoice = + | "alloy" + | "echo" + | "fable" + | "onyx" + | "nova" + | "shimmer"; + +/** + * Base TTS Handler type for implementing TTS providers + */ +export type ITTSHandler = { + /** + * Synthesize text to speech + * @param options - Synthesis options + * @returns Promise resolving to TTS result + */ + synthesize(options: TTSOptions): Promise; + + /** + * Get supported voices + * @returns List of supported voice names + */ + getSupportedVoices(): string[]; + + /** + * Get supported audio formats + * @returns List of supported format names + */ + getSupportedFormats(): AudioFormat[]; +}; diff --git a/test/unit/adapters/openaiTTSHandler.test.ts b/test/unit/adapters/openaiTTSHandler.test.ts new file mode 100644 index 0000000000..2d4c858d2e --- /dev/null +++ b/test/unit/adapters/openaiTTSHandler.test.ts @@ -0,0 +1,376 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { OpenAITTSHandler } from "../../../src/lib/adapters/tts/openaiTTSHandler.js"; +import type { TTSOptions } from "../../../src/lib/types/ttsTypes.js"; + +// Mock the OpenAI client +vi.mock("openai", () => { + return { + default: vi.fn().mockImplementation(() => ({ + audio: { + speech: { + create: vi.fn(), + }, + }, + })), + }; +}); + +describe("OpenAITTSHandler", () => { + let handler: OpenAITTSHandler; + let mockCreate: ReturnType; + + beforeEach(() => { + // Reset mocks before each test + vi.clearAllMocks(); + + // Create a fresh handler instance + handler = new OpenAITTSHandler("test-api-key"); + + // Get reference to the mocked create function + mockCreate = (handler as any).client.audio.speech.create; + }); + + describe("constructor", () => { + it("should create handler with provided API key", () => { + const testHandler = new OpenAITTSHandler("custom-key"); + expect(testHandler).toBeInstanceOf(OpenAITTSHandler); + }); + + it("should create handler with env var API key", () => { + process.env.OPENAI_API_KEY = "env-key"; + const testHandler = new OpenAITTSHandler(); + expect(testHandler).toBeInstanceOf(OpenAITTSHandler); + }); + }); + + describe("getSupportedVoices", () => { + it("should return all 6 OpenAI voices", () => { + const voices = handler.getSupportedVoices(); + expect(voices).toHaveLength(6); + expect(voices).toEqual( + expect.arrayContaining([ + "alloy", + "echo", + "fable", + "onyx", + "nova", + "shimmer", + ]), + ); + }); + + it("should return a new array instance", () => { + const voices1 = handler.getSupportedVoices(); + const voices2 = handler.getSupportedVoices(); + expect(voices1).not.toBe(voices2); + expect(voices1).toEqual(voices2); + }); + }); + + describe("getSupportedFormats", () => { + it("should return all 6 supported formats", () => { + const formats = handler.getSupportedFormats(); + expect(formats).toHaveLength(6); + expect(formats).toEqual( + expect.arrayContaining(["mp3", "opus", "aac", "flac", "wav", "pcm"]), + ); + }); + + it("should return a new array instance", () => { + const formats1 = handler.getSupportedFormats(); + const formats2 = handler.getSupportedFormats(); + expect(formats1).not.toBe(formats2); + expect(formats1).toEqual(formats2); + }); + }); + + describe("synthesize - successful synthesis", () => { + beforeEach(() => { + // Mock successful API response + const mockArrayBuffer = new ArrayBuffer(1024); + mockCreate.mockResolvedValue({ + arrayBuffer: vi.fn().mockResolvedValue(mockArrayBuffer), + }); + }); + + it("should synthesize text with default options", async () => { + const result = await handler.synthesize({ text: "Hello, world!" }); + + expect(result).toHaveProperty("buffer"); + expect(result).toHaveProperty("format", "mp3"); + expect(result).toHaveProperty("size"); + expect(result).toHaveProperty("duration"); + expect(result).toHaveProperty("voice", "alloy"); + expect(result.buffer).toBeInstanceOf(Buffer); + expect(result.size).toBe(1024); + }); + + it("should use provided voice", async () => { + await handler.synthesize({ text: "Test", voice: "nova" }); + + expect(mockCreate).toHaveBeenCalledWith( + expect.objectContaining({ + voice: "nova", + }), + ); + }); + + it("should use provided format", async () => { + await handler.synthesize({ text: "Test", format: "wav" }); + + expect(mockCreate).toHaveBeenCalledWith( + expect.objectContaining({ + response_format: "wav", + }), + ); + }); + + it("should use tts-1-hd model for HD quality", async () => { + await handler.synthesize({ text: "Test", quality: "hd" }); + + expect(mockCreate).toHaveBeenCalledWith( + expect.objectContaining({ + model: "tts-1-hd", + }), + ); + }); + + it("should use tts-1 model for standard quality", async () => { + await handler.synthesize({ text: "Test", quality: "standard" }); + + expect(mockCreate).toHaveBeenCalledWith( + expect.objectContaining({ + model: "tts-1", + }), + ); + }); + + it("should use provided speed", async () => { + await handler.synthesize({ text: "Test", speed: 1.5 }); + + expect(mockCreate).toHaveBeenCalledWith( + expect.objectContaining({ + speed: 1.5, + }), + ); + }); + + it("should estimate duration based on word count", async () => { + const text = "one two three four five"; // 5 words + const result = await handler.synthesize({ text, speed: 1.0 }); + + // ~150 words per minute at speed 1.0 + // 5 words = 5/150 * 60 = 2 seconds + expect(result.duration).toBeCloseTo(2, 1); + }); + + it("should adjust duration based on speed", async () => { + const text = "one two three four five"; // 5 words + const result = await handler.synthesize({ text, speed: 2.0 }); + + // At 2x speed, duration should be half + // 5 words = 5/150 * 60 / 2 = 1 second + expect(result.duration).toBeCloseTo(1, 1); + }); + }); + + describe("validation - text", () => { + it("should reject missing text", async () => { + await expect(handler.synthesize({} as TTSOptions)).rejects.toThrow( + "Text is required", + ); + }); + + it("should reject empty text", async () => { + await expect(handler.synthesize({ text: "" })).rejects.toThrow( + "Text is required", + ); + }); + + it("should reject whitespace-only text", async () => { + await expect(handler.synthesize({ text: " " })).rejects.toThrow( + "Text is required", + ); + }); + + it("should reject text over 4096 characters", async () => { + const longText = "a".repeat(4097); + await expect(handler.synthesize({ text: longText })).rejects.toThrow( + "exceeds maximum length", + ); + }); + + it("should accept text at exactly 4096 characters", async () => { + const mockArrayBuffer = new ArrayBuffer(1024); + mockCreate.mockResolvedValue({ + arrayBuffer: vi.fn().mockResolvedValue(mockArrayBuffer), + }); + + const maxText = "a".repeat(4096); + const result = await handler.synthesize({ text: maxText }); + expect(result).toHaveProperty("buffer"); + }); + }); + + describe("validation - voice", () => { + it("should reject unsupported voice", async () => { + await expect( + handler.synthesize({ text: "test", voice: "invalid-voice" }), + ).rejects.toThrow("Unsupported voice"); + }); + + it("should accept all supported voices", async () => { + const mockArrayBuffer = new ArrayBuffer(1024); + mockCreate.mockResolvedValue({ + arrayBuffer: vi.fn().mockResolvedValue(mockArrayBuffer), + }); + + const voices = ["alloy", "echo", "fable", "onyx", "nova", "shimmer"]; + + for (const voice of voices) { + await expect( + handler.synthesize({ text: "test", voice }), + ).resolves.toBeDefined(); + } + }); + }); + + describe("validation - format", () => { + it("should reject unsupported format", async () => { + await expect( + handler.synthesize({ + text: "test", + // @ts-expect-error - testing invalid format + format: "invalid-format", + }), + ).rejects.toThrow("Unsupported format"); + }); + + it("should accept all supported formats", async () => { + const mockArrayBuffer = new ArrayBuffer(1024); + mockCreate.mockResolvedValue({ + arrayBuffer: vi.fn().mockResolvedValue(mockArrayBuffer), + }); + + const formats = ["mp3", "opus", "aac", "flac", "wav", "pcm"]; + + for (const format of formats) { + await expect( + handler.synthesize({ text: "test", format: format as any }), + ).resolves.toBeDefined(); + } + }); + }); + + describe("validation - speed", () => { + it("should reject speed below 0.25", async () => { + await expect( + handler.synthesize({ text: "test", speed: 0.24 }), + ).rejects.toThrow("Speed must be between 0.25 and 4.0"); + }); + + it("should reject speed above 4.0", async () => { + await expect( + handler.synthesize({ text: "test", speed: 4.1 }), + ).rejects.toThrow("Speed must be between 0.25 and 4.0"); + }); + + it("should accept speed of exactly 0.25", async () => { + const mockArrayBuffer = new ArrayBuffer(1024); + mockCreate.mockResolvedValue({ + arrayBuffer: vi.fn().mockResolvedValue(mockArrayBuffer), + }); + + await expect( + handler.synthesize({ text: "test", speed: 0.25 }), + ).resolves.toBeDefined(); + }); + + it("should accept speed of exactly 4.0", async () => { + const mockArrayBuffer = new ArrayBuffer(1024); + mockCreate.mockResolvedValue({ + arrayBuffer: vi.fn().mockResolvedValue(mockArrayBuffer), + }); + + await expect( + handler.synthesize({ text: "test", speed: 4.0 }), + ).resolves.toBeDefined(); + }); + + it("should accept speed within valid range", async () => { + const mockArrayBuffer = new ArrayBuffer(1024); + mockCreate.mockResolvedValue({ + arrayBuffer: vi.fn().mockResolvedValue(mockArrayBuffer), + }); + + const speeds = [0.5, 1.0, 1.5, 2.0, 3.0]; + for (const speed of speeds) { + await expect( + handler.synthesize({ text: "test", speed }), + ).resolves.toBeDefined(); + } + }); + }); + + describe("error handling", () => { + it("should handle API errors gracefully", async () => { + const apiError = new Error("API rate limit exceeded"); + mockCreate.mockRejectedValue(apiError); + + await expect(handler.synthesize({ text: "test" })).rejects.toThrow( + "API rate limit exceeded", + ); + }); + + it("should handle network errors", async () => { + const networkError = new Error("Network connection failed"); + mockCreate.mockRejectedValue(networkError); + + await expect(handler.synthesize({ text: "test" })).rejects.toThrow( + "Network connection failed", + ); + }); + }); + + describe("TTSResult structure", () => { + beforeEach(() => { + const mockArrayBuffer = new ArrayBuffer(2048); + mockCreate.mockResolvedValue({ + arrayBuffer: vi.fn().mockResolvedValue(mockArrayBuffer), + }); + }); + + it("should return correct TTSResult structure", async () => { + const result = await handler.synthesize({ + text: "Test message", + voice: "nova", + format: "wav", + }); + + // Verify structure matches TTSResult type + expect(result).toEqual({ + buffer: expect.any(Buffer), + format: "wav", + size: 2048, + duration: expect.any(Number), + voice: "nova", + sampleRate: undefined, + }); + }); + + it("should have buffer as Buffer instance", async () => { + const result = await handler.synthesize({ text: "test" }); + expect(Buffer.isBuffer(result.buffer)).toBe(true); + }); + + it("should have correct size matching buffer length", async () => { + const result = await handler.synthesize({ text: "test" }); + expect(result.size).toBe(result.buffer.length); + }); + + it("should have sampleRate as undefined for OpenAI", async () => { + const result = await handler.synthesize({ text: "test" }); + expect(result.sampleRate).toBeUndefined(); + }); + }); +});