Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .env
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,4 @@ VITE_AWS_ACCESS_KEY_ID=REPLACE_WITH_YOUR_OWN
VITE_AWS_ACCESS_KEY=REPLACE_WITH_YOUR_OWN
VITE_AZURE_REGION=REPLACE_WITH_YOUR_OWN
VITE_AZURE_KEY=REPLACE_WITH_YOUR_OWN
VITE_MINIMAX_API_KEY=REPLACE_WITH_YOUR_OWN
18 changes: 15 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,9 @@ You can utilize this app to improve your language speaking skills or simply have
- 🔒 **Privacy First**: All data is stored locally.
- 📱 **Mobile friendly**: Designed to be accessible and usable on mobile devices.
- 📚 **Support for multiple languages**: Supports over 100 languages.
- 🤖 **Multiple LLM Providers**: Supports OpenAI and [MiniMax](https://www.minimaxi.com) as chat providers.
- 🎙 **Speech Recognition**: Includes both built-in speech recognition and integration with Azure Speech Services.
- 🔊 **Speech Synthesis**: Includes built-in speech synthesis, as well as integration with Amazon Polly and Azure Speech Services.
- 🔊 **Speech Synthesis**: Includes built-in speech synthesis, as well as integration with Amazon Polly, Azure Speech Services, and MiniMax TTS.

## 📸 Screenshots
<table>
Expand All @@ -37,15 +38,26 @@ You can utilize this app to improve your language speaking skills or simply have
- Go to Settings and navigate to the Chat section.
- Set the OpenAI API Key.
- If you don't have an OpenAI API Key, follow this tutorial on [how to get an OpenAI API Key](https://www.windowscentral.com/software-apps/how-to-get-an-openai-api-key).
2. Set up Azure Speech Services (optional)
2. Use MiniMax as an alternative chat provider (optional)
- Go to Settings and navigate to the Chat section.
- Change the Chat Provider to MiniMax.
- Set the MiniMax API Key.
- Select a model (MiniMax-M2.7 or MiniMax-M2.7-highspeed).
- You can get a MiniMax API Key from [MiniMax Platform](https://www.minimaxi.com).
3. Set up Azure Speech Services (optional)
- Go to Settings and navigate to the Synthesis section.
- Change the Speech Synthesis Service to Azure TTS.
- Set the Azure Region and Azure Access Key.
3. Set up Amazon Polly (optional)
4. Set up Amazon Polly (optional)
- Go to Settings and navigate to the Synthesis section.
- Change the Speech Synthesis Service to Amazon Polly.
- Set the AWS Region, AWS Access Key ID, and Secret Access Key (the Access Key should have the AmazonPollyFullAccess policy).
- If you don't have an AWS Access Key, follow this tutorial on [how to create an IAM user in AWS](https://www.techtarget.com/searchcloudcomputing/tutorial/Step-by-step-guide-on-how-to-create-an-IAM-user-in-AWS).
5. Set up MiniMax TTS (optional)
- Go to Settings and navigate to the Synthesis section.
- Change the Speech Synthesis Service to MiniMax TTS.
- Set the MiniMax API Key (shared with chat if already configured).
- Select a TTS model (speech-2.8-hd or speech-2.8-turbo) and a voice.

## 💻 Development Guide and Changelog
- For more information on setting up your development environment, please see our [Development Guide](./docs/developer-guide.md).
Expand Down
121 changes: 121 additions & 0 deletions src/__tests__/integration.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import { describe, it, expect } from 'vitest';

describe('Provider Configuration', () => {
const defaultKeyState = {
accessCode: '',
chatProvider: 'OpenAI',
openaiApiKey: '',
openaiModel: '',
openaiHost: '',
minimaxApiKey: '',
minimaxModel: 'MiniMax-M2.7',
awsRegion: '',
awsKeyId: '',
awsKey: '',
azureRegion: '',
azureKey: '',
};

it('should default to OpenAI provider', () => {
expect(defaultKeyState.chatProvider).toBe('OpenAI');
});

it('should have MiniMax model default', () => {
expect(defaultKeyState.minimaxModel).toBe('MiniMax-M2.7');
});

it('should support both OpenAI and MiniMax providers', () => {
const providers = ['OpenAI', 'MiniMax'];
expect(providers).toContain(defaultKeyState.chatProvider);
});

it('should resolve correct API key based on provider', () => {
function getApiKey(provider: string, keys: typeof defaultKeyState): string {
return provider === 'MiniMax' ? keys.minimaxApiKey : keys.openaiApiKey;
}

const keysWithOpenAI = { ...defaultKeyState, openaiApiKey: 'sk-openai-test' };
const keysWithMiniMax = { ...defaultKeyState, minimaxApiKey: 'minimax-test' };

expect(getApiKey('OpenAI', keysWithOpenAI)).toBe('sk-openai-test');
expect(getApiKey('MiniMax', keysWithMiniMax)).toBe('minimax-test');
});

it('should resolve correct model based on provider', () => {
function getModel(provider: string, keys: typeof defaultKeyState): string {
return provider === 'MiniMax' ? keys.minimaxModel || 'MiniMax-M2.7' : keys.openaiModel;
}

expect(getModel('MiniMax', defaultKeyState)).toBe('MiniMax-M2.7');
expect(getModel('OpenAI', { ...defaultKeyState, openaiModel: 'gpt-4' })).toBe('gpt-4');
});

it('should resolve correct host based on provider', () => {
function getHost(provider: string, host: string): string {
return provider === 'MiniMax' ? 'api.minimax.io' : host || 'api.openai.com';
}

expect(getHost('MiniMax', '')).toBe('api.minimax.io');
expect(getHost('OpenAI', '')).toBe('api.openai.com');
expect(getHost('OpenAI', 'custom.host')).toBe('custom.host');
});
});

describe('MiniMax Model Options', () => {
const minimaxModels = ['MiniMax-M2.7', 'MiniMax-M2.7-highspeed'];

it('should include M2.7 model', () => {
expect(minimaxModels).toContain('MiniMax-M2.7');
});

it('should include M2.7-highspeed model', () => {
expect(minimaxModels).toContain('MiniMax-M2.7-highspeed');
});

it('should have exactly 2 model options', () => {
expect(minimaxModels.length).toBe(2);
});
});

describe('Speech Synthesis Service Options', () => {
const desktopServices = ['System', 'Azure TTS', 'Amazon Polly', 'MiniMax TTS'];
const mobileServices = ['Azure TTS', 'Amazon Polly', 'MiniMax TTS'];

it('should include MiniMax TTS in desktop services', () => {
expect(desktopServices).toContain('MiniMax TTS');
});

it('should include MiniMax TTS in mobile services', () => {
expect(mobileServices).toContain('MiniMax TTS');
});

it('should have 4 desktop services', () => {
expect(desktopServices.length).toBe(4);
});

it('should have 3 mobile services', () => {
expect(mobileServices.length).toBe(3);
});
});

describe('Default Speech State', () => {
const defaultSpeechState = {
service: 'System',
systemLanguage: 'en',
systemVoice: 'Daniel',
systemRate: 1,
systemPitch: 1,
pollyLanguage: 'en-US',
pollyVoice: '',
pollyEngine: 'Standard',
azureLanguage: 'en-US',
azureVoice: '',
minimaxModel: 'speech-2.8-hd',
minimaxVoice: 'English_Graceful_Lady',
};

it('should have MiniMax TTS defaults', () => {
expect(defaultSpeechState.minimaxModel).toBe('speech-2.8-hd');
expect(defaultSpeechState.minimaxVoice).toBe('English_Graceful_Lady');
});
});
64 changes: 64 additions & 0 deletions src/__tests__/minimaxTTS.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { describe, it, expect } from 'vitest';
import { minimaxTTSVoices, minimaxTTSModels } from '../apis/minimaxTTS';

describe('MiniMax TTS Constants', () => {
it('should export valid voice IDs', () => {
expect(minimaxTTSVoices).toBeInstanceOf(Array);
expect(minimaxTTSVoices.length).toBeGreaterThan(0);
expect(minimaxTTSVoices).toContain('English_Graceful_Lady');
expect(minimaxTTSVoices).toContain('English_Insightful_Speaker');
expect(minimaxTTSVoices).toContain('Deep_Voice_Man');
expect(minimaxTTSVoices).toContain('sweet_girl');
});

it('should not contain known invalid voice IDs', () => {
expect(minimaxTTSVoices).not.toContain('Chinese_Empress');
expect(minimaxTTSVoices).not.toContain('Chinese_Gentle_Boy');
expect(minimaxTTSVoices).not.toContain('Narrator_Man');
expect(minimaxTTSVoices).not.toContain('podcast_girl');
});

it('should export valid TTS models', () => {
expect(minimaxTTSModels).toBeInstanceOf(Array);
expect(minimaxTTSModels).toContain('speech-2.8-hd');
expect(minimaxTTSModels).toContain('speech-2.8-turbo');
expect(minimaxTTSModels.length).toBe(2);
});

it('should have 12 voice options', () => {
expect(minimaxTTSVoices.length).toBe(12);
});
});

describe('MiniMax TTS API', () => {
it('should construct correct request body', () => {
const apiKey = 'test-key';
const text = 'Hello world';
const voiceId = 'English_Graceful_Lady';
const model = 'speech-2.8-hd';

const expectedBody = {
model: model,
text: text,
voice_setting: {
voice_id: voiceId,
},
audio_setting: {
format: 'mp3',
},
};

expect(expectedBody.model).toBe('speech-2.8-hd');
expect(expectedBody.text).toBe('Hello world');
expect(expectedBody.voice_setting.voice_id).toBe('English_Graceful_Lady');
expect(expectedBody.audio_setting.format).toBe('mp3');
});

it('should use default voice and model when not specified', () => {
const defaultVoice = 'English_Graceful_Lady';
const defaultModel = 'speech-2.8-hd';

expect(minimaxTTSVoices[0]).toBe(defaultVoice);
expect(minimaxTTSModels[0]).toBe(defaultModel);
});
});
95 changes: 95 additions & 0 deletions src/__tests__/openai.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { describe, it, expect } from 'vitest';

// Import the functions we want to test - since they're in a module with default export,
// we need to test the logic directly
function clampTemperature(temperature: number, provider: string): number {
if (provider === 'MiniMax') {
return Math.max(0.01, Math.min(1.0, temperature));
}
return temperature;
}

function stripThinkTags(content: string): string {
return content.replace(/<think>[\s\S]*?<\/think>/g, '').trim();
}

function getProviderConfig(provider: string, host: string) {
if (provider === 'MiniMax') {
return { hostAddress: 'api.minimax.io' };
}
return { hostAddress: host || 'api.openai.com' };
}

describe('clampTemperature', () => {
it('should clamp temperature to (0, 1] for MiniMax provider', () => {
expect(clampTemperature(0, 'MiniMax')).toBe(0.01);
expect(clampTemperature(0.5, 'MiniMax')).toBe(0.5);
expect(clampTemperature(1, 'MiniMax')).toBe(1);
expect(clampTemperature(1.5, 'MiniMax')).toBe(1);
expect(clampTemperature(2, 'MiniMax')).toBe(1);
});

it('should not clamp temperature for OpenAI provider', () => {
expect(clampTemperature(0, 'OpenAI')).toBe(0);
expect(clampTemperature(0.5, 'OpenAI')).toBe(0.5);
expect(clampTemperature(1.5, 'OpenAI')).toBe(1.5);
expect(clampTemperature(2, 'OpenAI')).toBe(2);
});

it('should handle edge cases', () => {
expect(clampTemperature(-1, 'MiniMax')).toBe(0.01);
expect(clampTemperature(0.01, 'MiniMax')).toBe(0.01);
expect(clampTemperature(0.99, 'MiniMax')).toBe(0.99);
});
});

describe('stripThinkTags', () => {
it('should strip think tags from content', () => {
expect(stripThinkTags('<think>internal reasoning</think>Hello')).toBe('Hello');
});

it('should strip multiline think tags', () => {
const content = '<think>\nlet me think...\nstep 1\nstep 2\n</think>\nThe answer is 42.';
expect(stripThinkTags(content)).toBe('The answer is 42.');
});

it('should handle multiple think tags', () => {
const content = '<think>first</think>Hello <think>second</think>World';
expect(stripThinkTags(content)).toBe('Hello World');
});

it('should return content unchanged if no think tags', () => {
expect(stripThinkTags('Hello World')).toBe('Hello World');
});

it('should handle empty think tags', () => {
expect(stripThinkTags('<think></think>Hello')).toBe('Hello');
});

it('should trim whitespace after stripping', () => {
expect(stripThinkTags('<think>reasoning</think> Hello ')).toBe('Hello');
});
});

describe('getProviderConfig', () => {
it('should return MiniMax host for MiniMax provider', () => {
expect(getProviderConfig('MiniMax', '')).toEqual({ hostAddress: 'api.minimax.io' });
expect(getProviderConfig('MiniMax', 'custom.host.com')).toEqual({
hostAddress: 'api.minimax.io',
});
});

it('should return custom host for OpenAI provider', () => {
expect(getProviderConfig('OpenAI', 'custom.openai.com')).toEqual({
hostAddress: 'custom.openai.com',
});
});

it('should default to api.openai.com for OpenAI with empty host', () => {
expect(getProviderConfig('OpenAI', '')).toEqual({ hostAddress: 'api.openai.com' });
});

it('should handle unknown provider as OpenAI', () => {
expect(getProviderConfig('Unknown', 'some.host')).toEqual({ hostAddress: 'some.host' });
});
});
59 changes: 59 additions & 0 deletions src/apis/minimaxTTS.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
export const minimaxTTSVoices = [
'English_Graceful_Lady',
'English_Insightful_Speaker',
'English_radiant_girl',
'English_Persuasive_Man',
'English_Lucky_Robot',
'Wise_Woman',
'cute_boy',
'lovely_girl',
'Friendly_Person',
'Inspirational_girl',
'Deep_Voice_Man',
'sweet_girl',
];

export const minimaxTTSModels = ['speech-2.8-hd', 'speech-2.8-turbo'];

export default async function speechSynthesizeWithMiniMax(
apiKey: string,
text: string,
voiceId: string,
model: string
): Promise<string> {
const response = await fetch('https://api.minimax.io/v1/t2a_v2', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: 'Bearer ' + apiKey,
},
body: JSON.stringify({
model: model || 'speech-2.8-hd',
text: text,
voice_setting: {
voice_id: voiceId || 'English_Graceful_Lady',
},
audio_setting: {
format: 'mp3',
},
}),
});

const data = await response.json();

if (data?.base_resp?.status_code !== 0) {
throw new Error(data?.base_resp?.status_msg || 'MiniMax TTS request failed');
}

const audioHex = data?.data?.audio;
if (!audioHex) {
throw new Error('No audio data in MiniMax TTS response');
}

const bytes = new Uint8Array(audioHex.length / 2);
for (let i = 0; i < audioHex.length; i += 2) {
bytes[i / 2] = parseInt(audioHex.substr(i, 2), 16);
}
const blob = new Blob([bytes], { type: 'audio/mpeg' });
return URL.createObjectURL(blob);
}
Loading