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
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,12 +28,17 @@ If you need to obtain an OpenAI API key, follow the steps below:
- Visit the [OpenAI website](https://platform.openai.com) and sign up for an account.
- Visit the [Groq website](https://console.groq.com/) to get an account there.
- Visit the [Deepgram website](https://console.deepgram.com/signup) to create an account.
- Visit the [Salad Portal](https://portal.salad.com/) to create an account.

- Visit [Perplexity](https://www.perplexity.ai/) to create an account.

2. **Access API Keys**:
- Log in to your account.
- **OpenAI**: Click on the ⚙️ in the top right corner and select "API Keys" from the dropdown menu.
- **Groq**: Click "API Keys" on the left sidebar.
- **Deepgram**: Click the "Free API key button in the top right.
- **Salad**: go to "API Access" in menu
- **Perplexity**: click on profile icon and select API

3. **Create a New Key**:
- On the API Keys page, click "Create new secret key."
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 10 additions & 0 deletions src/adapters/AIAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ export enum AIProvider {
OpenAI = 'openai',
Groq = 'groq',
Deepgram = 'deepgram',
Salad = 'salad',
Perplexity = 'perplexity',
}

export interface AIModel {
Expand Down Expand Up @@ -41,6 +43,14 @@ export const AIModels: Record<AIProvider, AIModel[]> = {
{ id: 'nova-2', name: 'Nova-2', category: 'transcription' },
{ id: 'nova-3', name: 'Nova-3', category: 'transcription' },
],
[AIProvider.Salad]: [
{ id: 'transcribe', name: 'Salad Transcription', category: 'transcription' },
{ id: 'transcription-lite', name: 'Transcription Lite', category: 'transcription' },
],
[AIProvider.Perplexity]: [
{ id: 'sonar', name: 'Sonar', category: 'language', maxTokens: 127072 },
{ id: 'sonar-pro', name: 'Sonar Pro', category: 'language', maxTokens: 127072 },
],
};

export function getModelInfo(modelId: string): AIModel | undefined {
Expand Down
65 changes: 65 additions & 0 deletions src/adapters/PerplexityAdapter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { AIAdapter, AIProvider } from './AIAdapter';
import { NeuroVoxSettings } from '../settings/Settings';

export class PerplexityAdapter extends AIAdapter {
private apiKey: string = '';

constructor(settings: NeuroVoxSettings) {
super(settings, AIProvider.Perplexity);
}

getApiKey(): string {
return this.apiKey;
}

protected setApiKeyInternal(key: string): void {
this.apiKey = key;
}

protected getApiBaseUrl(): string {
return 'https://api.perplexity.ai';
}

protected getTextGenerationEndpoint(): string {
return '/chat/completions';
}

protected getTranscriptionEndpoint(): string {
throw new Error('Transcription not supported by Perplexity');
}

protected async validateApiKeyImpl(): Promise<boolean> {
if (!this.apiKey) {
return false;
}

try {
await this.makeAPIRequest(
`${this.getApiBaseUrl()}/chat/completions`,
'POST',
{
'Content-Type': 'application/json'
},
JSON.stringify({
model: 'sonar',
messages: [{ role: 'user', content: 'test' }],
max_tokens: 1
})
);
return true;
} catch (error) {
return false;
}
}

protected parseTextGenerationResponse(response: any): string {
if (response?.choices?.[0]?.message?.content) {
return response.choices[0].message.content;
}
throw new Error('Invalid response format from Perplexity');
}

protected parseTranscriptionResponse(response: any): string {
throw new Error('Transcription not supported by Perplexity');
}
}
246 changes: 246 additions & 0 deletions src/adapters/SaladAdapter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,246 @@
import { requestUrl } from 'obsidian';
import { AIAdapter, AIProvider } from './AIAdapter';
import { NeuroVoxSettings } from '../settings/Settings';

export class SaladAdapter extends AIAdapter {
private apiKey: string = '';
private organization: string = '';

constructor(settings: NeuroVoxSettings) {
super(settings, AIProvider.Salad);
}

getApiKey(): string {
return this.apiKey;
}

getOrganization(): string {
return this.organization;
}

setOrganization(org: string): void {
this.organization = org;
}

protected setApiKeyInternal(key: string): void {
this.apiKey = key;
}

protected getApiBaseUrl(): string {
return 'https://api.salad.com/api/public';
}

protected getStorageBaseUrl(): string {
return 'https://storage-api.salad.com';
}

protected getTextGenerationEndpoint(): string {
return '';
}

protected getTranscriptionEndpoint(): string {
return `/organizations/${this.organization}/inference-endpoints`;
}

protected async validateApiKeyImpl(): Promise<boolean> {
if (!this.apiKey || !this.organization) {
return false;
}

try {
const response = await this.makeAPIRequest(
`${this.getStorageBaseUrl()}/organizations/${this.organization}/files`,
'GET',
{},
null
);
return response && Array.isArray(response.files);
} catch (error) {
return false;
}
}

protected parseTextGenerationResponse(response: any): string {
throw new Error('Text generation not supported by Salad');
}

protected parseTranscriptionResponse(response: any): string {
if (response?.output?.text) {
return response.output.text;
}
throw new Error('Invalid transcription response format from Salad');
}

public async transcribeAudio(audioArrayBuffer: ArrayBuffer, model: string): Promise<string> {
try {
if (!this.organization) {
throw new Error('Salad organization name is not configured');
}

const audioUrl = await this.uploadToS4Storage(audioArrayBuffer);

const jobId = await this.submitTranscriptionJob(audioUrl, model);

const result = await this.pollForResult(jobId, model);

await this.deleteFromS4Storage(audioUrl);

return this.parseTranscriptionResponse(result);
} catch (error) {
const message = this.getErrorMessage(error);
throw new Error(`Failed to transcribe audio with Salad: ${message}`);
}
}

private async uploadToS4Storage(audioArrayBuffer: ArrayBuffer): Promise<string> {
const fileName = `audio/neurovox_${Date.now()}.wav`;
const uploadUrl = `${this.getStorageBaseUrl()}/organizations/${this.organization}/files/${fileName}`;

const boundary = 'saladuploadboundary';
const encoder = new TextEncoder();

const parts: Uint8Array[] = [];

parts.push(encoder.encode(`--${boundary}\r\n`));
parts.push(encoder.encode('Content-Disposition: form-data; name="mimeType"\r\n\r\n'));
parts.push(encoder.encode('audio/wav'));
parts.push(encoder.encode('\r\n'));

parts.push(encoder.encode(`--${boundary}\r\n`));
parts.push(encoder.encode('Content-Disposition: form-data; name="sign"\r\n\r\n'));
parts.push(encoder.encode('true'));
parts.push(encoder.encode('\r\n'));

parts.push(encoder.encode(`--${boundary}\r\n`));
parts.push(encoder.encode('Content-Disposition: form-data; name="signatureExp"\r\n\r\n'));
parts.push(encoder.encode('86400'));
parts.push(encoder.encode('\r\n'));

parts.push(encoder.encode(`--${boundary}\r\n`));
parts.push(encoder.encode(`Content-Disposition: form-data; name="file"; filename="audio.wav"\r\n`));
parts.push(encoder.encode('Content-Type: audio/wav\r\n\r\n'));
parts.push(new Uint8Array(audioArrayBuffer));
parts.push(encoder.encode('\r\n'));

parts.push(encoder.encode(`--${boundary}--\r\n`));

const totalLength = parts.reduce((acc, part) => acc + part.length, 0);
const finalBuffer = new Uint8Array(totalLength);
let offset = 0;

for (const part of parts) {
finalBuffer.set(part, offset);
offset += part.length;
}

const response = await requestUrl({
url: uploadUrl,
method: 'PUT',
headers: {
'Salad-Api-Key': this.apiKey,
'Content-Type': `multipart/form-data; boundary=${boundary}`
},
body: finalBuffer.buffer,
throw: true
});

if (!response.json?.url) {
throw new Error('Failed to get signed URL from S4 storage');
}

return response.json.url;
}

private async submitTranscriptionJob(audioUrl: string, model: string): Promise<string> {
const endpoint = `${this.getApiBaseUrl()}/organizations/${this.organization}/inference-endpoints/${model}/jobs`;

const body = {
input: {
url: audioUrl,
language_code: 'auto',
return_as_file: false,
sentence_level_timestamps: false,
word_level_timestamps: false,
diarization: false,
srt: false
}
};

const response = await this.makeAPIRequest(
endpoint,
'POST',
{ 'Content-Type': 'application/json' },
JSON.stringify(body)
);

if (!response?.id) {
throw new Error('Failed to submit transcription job');
}

return response.id;
}

private async pollForResult(jobId: string, model: string, maxAttempts: number = 120, intervalMs: number = 2000): Promise<any> {
const endpoint = `${this.getApiBaseUrl()}/organizations/${this.organization}/inference-endpoints/${model}/jobs/${jobId}`;

for (let attempt = 0; attempt < maxAttempts; attempt++) {
const response = await this.makeAPIRequest(endpoint, 'GET', {}, null);

if (response?.status === 'succeeded') {
return response;
} else if (response?.status === 'failed') {
throw new Error(`Transcription job failed: ${response?.error || 'Unknown error'}`);
}

await new Promise(resolve => setTimeout(resolve, intervalMs));
}

throw new Error('Transcription job timed out');
}

private async deleteFromS4Storage(signedUrl: string): Promise<void> {
try {
const urlWithoutToken = signedUrl.split('?')[0];

await requestUrl({
url: urlWithoutToken,
method: 'DELETE',
headers: {
'Salad-Api-Key': this.apiKey
},
throw: false
});
} catch (error) {
}
}

protected async makeAPIRequest(
endpoint: string,
method: string,
headers: Record<string, string>,
body: string | ArrayBuffer | null
): Promise<any> {
try {
const requestHeaders: Record<string, string> = {
'Salad-Api-Key': this.apiKey,
...headers
};

const response = await requestUrl({
url: endpoint,
method,
headers: requestHeaders,
body: body || undefined,
throw: true
});

if (!response.json) {
throw new Error('Invalid response format');
}

return response.json;
} catch (error: any) {
throw error;
}
}
}
Loading