diff --git a/package-lock.json b/package-lock.json index 6f47e8c..5698b3d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,6 +21,7 @@ "@playwright/test": "^1.59.1", "@types/node": "^25.5.0", "playwright": "^1.59.1", + "sarvamai": "^1.1.8", "typescript": "^5.5.0", "vitest": "^3.2.6" }, @@ -2994,10 +2995,10 @@ "optional": true }, "node_modules/sarvamai": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/sarvamai/-/sarvamai-1.1.6.tgz", - "integrity": "sha512-kfrKQIk8xlUwvWeuhMF4ljwjuMWPelzuBe+4BlUD3qeoEisCfKLTq/MX2FfkcJ6spnMrwPDix96ngmhyLj2Q9w==", - "optional": true, + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/sarvamai/-/sarvamai-1.1.8.tgz", + "integrity": "sha512-u5qhfTfTy2IIpyXwsH6cJPJcM0a8PUdo5ZRObxRKZflU8lpGTi+VKXMq3hHzO07/9bSqWD2Rrt07ElX3YurjjQ==", + "dev": true, "dependencies": { "ws": "^8.16.0" }, @@ -3474,8 +3475,8 @@ "version": "8.21.0", "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "devOptional": true, "license": "MIT", - "optional": true, "engines": { "node": ">=10.0.0" }, diff --git a/package.json b/package.json index 049d2dd..4240055 100644 --- a/package.json +++ b/package.json @@ -64,6 +64,7 @@ "@playwright/test": "^1.59.1", "@types/node": "^25.5.0", "playwright": "^1.59.1", + "sarvamai": "^1.1.8", "typescript": "^5.5.0", "vitest": "^3.2.6" } diff --git a/src/tts/engines/sarvam.ts b/src/tts/engines/sarvam.ts index 0e8f1d1..1fd8219 100644 --- a/src/tts/engines/sarvam.ts +++ b/src/tts/engines/sarvam.ts @@ -33,17 +33,30 @@ export class SarvamEngine implements TTSEngine { async generate(text: string, options: TTSEngineOptions): Promise { if (!text?.trim()) throw new Error('TTS text must not be empty'); - let SarvamAI: any; + // `SarvamAIClient` is the client class. The package has no default export + // and its `SarvamAI` export is a namespace object, so destructuring either + // yields undefined and fails at `new` with an unrelated-looking TypeError. + let SarvamAIClient: any; try { // @ts-ignore — sarvamai is an optional dependency - ({ default: SarvamAI } = await import('sarvamai')); + ({ SarvamAIClient } = await import('sarvamai')); } catch { throw new Error( "Sarvam TTS engine requires the 'sarvamai' package. Install it with: npm i sarvamai" ); } - const client = new SarvamAI({ apiSubscriptionKey: this.resolveApiKey() }); + // The package resolved but does not expose the client — a version skew or + // a rename in a future major. Say so, rather than letting `new undefined()` + // surface as a TypeError that looks unrelated to the SDK. + if (typeof SarvamAIClient !== 'function') { + throw new Error( + "The installed 'sarvamai' package does not export SarvamAIClient. " + + 'Argo expects sarvamai >= 1.1. Upgrade with: npm i sarvamai@latest' + ); + } + + const client = new SarvamAIClient({ apiSubscriptionKey: this.resolveApiKey() }); const response = await client.textToSpeech.convert({ inputs: [text], target_language_code: options.lang ?? 'hi-IN', diff --git a/tests/tts/sarvam.test.ts b/tests/tts/sarvam.test.ts new file mode 100644 index 0000000..4ea984f --- /dev/null +++ b/tests/tts/sarvam.test.ts @@ -0,0 +1,91 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { SarvamEngine } from '../../src/tts/engines/sarvam.js'; + +/** + * Regression coverage for #40: the engine destructured the `default` export of + * `sarvamai`, which does not exist. `SarvamAI` is a namespace object and the + * client class is `SarvamAIClient`, so `new SarvamAI(...)` threw + * `SarvamAI is not a constructor` for every user who had the package installed + * correctly — the engine had never worked. + * + * The mock mirrors the real module shape exactly, `default: undefined` and all, + * so a regression back to the default import fails here rather than only in + * production. + */ +// `vi.hoisted` because `vi.mock` is lifted above every const in the file; a +// plain const here is still in its TDZ when the factory runs, and sarvam.ts's +// catch would report the resulting ReferenceError as "package not installed". +const { convertMock, clientCtor, sdk } = vi.hoisted(() => ({ + convertMock: vi.fn(async () => ({ audios: [Buffer.from('pcm').toString('base64')] })), + clientCtor: vi.fn(), + // Mutable so one test can simulate an SDK that no longer exports the client. + sdk: { client: undefined as unknown }, +})); + +vi.mock('sarvamai', () => ({ + get SarvamAIClient() { + return sdk.client; + }, + // The real package exports a namespace under `SarvamAI` and no default. + SarvamAI: {}, +})); + +vi.mock('node:child_process', async importOriginal => ({ + ...(await importOriginal()), + execFileSync: vi.fn(() => Buffer.from('fake-wav')), +})); + +describe('SarvamEngine', () => { + beforeEach(() => { + convertMock.mockClear(); + clientCtor.mockClear(); + sdk.client = class SarvamAIClient { + textToSpeech = { convert: convertMock }; + constructor(opts: unknown) { + clientCtor(opts); + } + }; + }); + + // A bare `new undefined()` already mentions SarvamAIClient, so this asserts + // the actionable half: which package is wrong and what to do about it. + it('explains the SDK is incompatible when the client export is missing', async () => { + sdk.client = undefined; + const engine = new SarvamEngine({ apiKey: 'test-key' }); + await expect(engine.generate('hello', {})).rejects.toThrow( + /installed 'sarvamai' package does not export SarvamAIClient/, + ); + }); + + it('constructs SarvamAIClient rather than a non-existent default export', async () => { + const engine = new SarvamEngine({ apiKey: 'test-key' }); + await engine.generate('नमस्ते', {}); + expect(clientCtor).toHaveBeenCalledWith({ apiSubscriptionKey: 'test-key' }); + }); + + it('sends the scene text and voice options to the API', async () => { + const engine = new SarvamEngine({ apiKey: 'test-key' }); + await engine.generate('hello', { lang: 'en-IN', voice: 'anushka', speed: 1.2 }); + + expect(convertMock).toHaveBeenCalledOnce(); + const payload = convertMock.mock.calls[0][0] as Record; + expect(payload.inputs).toEqual(['hello']); + expect(payload.target_language_code).toBe('en-IN'); + expect(payload.speaker).toBe('anushka'); + // Sarvam has native rate control, so speed rides on `pace` and must NOT be + // re-applied by convertToWav — that would compound the two. + expect(payload.pace).toBe(1.2); + }); + + it('throws a clear error when the API returns no audio', async () => { + convertMock.mockResolvedValueOnce({ audios: [] } as never); + const engine = new SarvamEngine({ apiKey: 'test-key' }); + await expect(engine.generate('hello', {})).rejects.toThrow('returned no audio data'); + }); + + it('still rejects empty text before touching the SDK', async () => { + const engine = new SarvamEngine({ apiKey: 'test-key' }); + await expect(engine.generate(' ', {})).rejects.toThrow('TTS text must not be empty'); + expect(clientCtor).not.toHaveBeenCalled(); + }); +});