asyouplz_SpeechNote/src/infrastructure/api/providers/deepgram/DeepgramServiceRefactored.ts
asyouplz 2d8bc68615
fix: address review feedback and stabilize tests (#60)
* fix(review): resolve obsidian bot validation errors (metadata, lint, ui)

* feat(release): prepare version 4.1.0

* fix(ci): add semantic-release configuration

* fix(review): address pr feedback and stabilize tests

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* fix(types): resolve typecheck regressions

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-29 23:33:20 +09:00

507 lines
15 KiB
TypeScript

import { requestUrl, RequestUrlParam } from 'obsidian';
import type { ILogger } from '../../../../types';
import { isPlainRecord } from '../../../../types/guards';
import {
DeepgramSpecificOptions,
TranscriptionResponse,
TranscriptionSegment,
ProviderAuthenticationError,
ProviderRateLimitError,
ProviderUnavailableError,
TranscriptionError,
} from '../ITranscriber';
import { CircuitBreaker } from '../common/CircuitBreaker';
import { RateLimiter } from '../common/RateLimiter';
import { RetryHandler, RetryStrategy } from '../common/RetryStrategy';
// Constants
const DEEPGRAM_CONFIG = {
API_ENDPOINT: 'https://api.deepgram.com/v1/listen',
MAX_FILE_SIZE: 2 * 1024 * 1024 * 1024, // 2GB
DEFAULT_TIMEOUT: 120000, // 2 minutes base; overall cap handled by outer service
DEFAULT_MODEL: 'nova-3',
WORDS_PER_SEGMENT: 10,
} as const;
// Type definitions
interface DeepgramAPIResponse {
metadata: {
transaction_key: string;
request_id: string;
sha256: string;
created: string;
duration: number;
channels: number;
models: string[];
model_info: Record<
string,
{
name: string;
version: string;
tier: string;
}
>;
};
results: {
channels: Array<{
alternatives: Array<{
transcript: string;
confidence: number;
words?: Array<{
word: string;
start: number;
end: number;
confidence: number;
speaker?: number;
}>;
}>;
detected_language?: string;
}>;
};
}
interface DeepgramServiceConfig {
apiKey: string;
logger: ILogger;
requestsPerMinute?: number;
timeout?: number;
circuitBreakerConfig?: {
failureThreshold?: number;
successThreshold?: number;
timeout?: number;
};
retryConfig?: {
maxRetries?: number;
baseDelayMs?: number;
maxDelayMs?: number;
};
}
/**
* Refactored Deepgram API Service
* Clean architecture with separated concerns and improved testability
*/
export class DeepgramServiceRefactored {
private readonly circuitBreaker: CircuitBreaker;
private readonly retryHandler: RetryHandler;
private readonly rateLimiter: RateLimiter;
private readonly timeout: number;
private abortController?: AbortController;
constructor(private readonly config: DeepgramServiceConfig) {
this.timeout = config.timeout ?? DEEPGRAM_CONFIG.DEFAULT_TIMEOUT;
// Initialize resilience components
this.circuitBreaker = new CircuitBreaker(
'Deepgram',
config.logger,
config.circuitBreakerConfig
);
this.retryHandler = new RetryHandler('Deepgram', config.logger, RetryStrategy.EXPONENTIAL, {
...config.retryConfig,
retryCondition: this.isRetryableError,
});
this.rateLimiter = new RateLimiter('Deepgram', config.logger, {
requestsPerWindow: config.requestsPerMinute ?? 100,
windowMs: 60000,
queueEnabled: true,
maxQueueSize: 20,
});
}
/**
* Main transcription method with all resilience patterns applied
*/
async transcribe(
audio: ArrayBuffer,
options?: DeepgramSpecificOptions,
language?: string
): Promise<DeepgramAPIResponse> {
// Validate input
this.validateInput(audio);
// Apply rate limiting
await this.rateLimiter.acquire();
// Execute with circuit breaker and retry logic
return this.circuitBreaker.execute(() =>
this.retryHandler.execute(
() => this.performTranscription(audio, options, language),
'transcription'
)
);
}
/**
* Validate API key
*/
async validateApiKey(apiKey: string): Promise<boolean> {
const testAudio = new ArrayBuffer(1024);
const originalKey = this.config.apiKey;
try {
this.config.apiKey = apiKey;
await this.performTranscription(testAudio);
return true;
} catch (error) {
return !(error instanceof ProviderAuthenticationError);
} finally {
this.config.apiKey = originalKey;
}
}
/**
* Parse API response to unified format
*/
parseResponse(response: DeepgramAPIResponse): TranscriptionResponse {
const channel = response.results.channels[0];
const alternative = channel?.alternatives[0];
if (!alternative) {
throw new Error('Invalid Deepgram response: missing transcription data');
}
return {
text: alternative.transcript,
language: channel.detected_language,
confidence: alternative.confidence,
duration: response.metadata.duration,
segments: this.createSegments(alternative.words),
provider: 'deepgram',
metadata: {
model: response.metadata.models[0],
processingTime: response.metadata.duration,
wordCount: this.countWords(alternative.transcript),
},
};
}
/**
* Cancel ongoing transcription
*/
cancel(): void {
if (this.abortController) {
this.abortController.abort();
this.config.logger.debug('Deepgram transcription cancelled');
}
}
/**
* Reset circuit breaker
*/
resetCircuitBreaker(): void {
this.circuitBreaker.reset();
}
/**
* Get service statistics
*/
getStats() {
return {
circuitBreaker: this.circuitBreaker.getStats(),
rateLimiter: this.rateLimiter.getStats(),
};
}
/**
* Perform the actual API call
*/
private async performTranscription(
audio: ArrayBuffer,
options?: DeepgramSpecificOptions,
language?: string
): Promise<DeepgramAPIResponse> {
this.abortController = new AbortController();
const startTime = Date.now();
try {
const request = this.buildRequest(audio, options, language);
const response = await requestUrl(request);
this.logRequestMetrics(startTime, audio.byteLength, response.status);
if (response.status === 200) {
return response.json as DeepgramAPIResponse;
}
throw this.handleApiError(response);
} catch (error) {
if ((error as Error).name === 'AbortError') {
throw new TranscriptionError(
'Transcription cancelled',
'CANCELLED',
'deepgram',
false
);
}
throw error;
} finally {
this.abortController = undefined;
}
}
/**
* Build API request
*/
private buildRequest(
audio: ArrayBuffer,
options?: DeepgramSpecificOptions,
language?: string
): RequestUrlParam {
return {
url: this.buildUrl(options, language),
method: 'POST',
headers: this.buildHeaders(),
body: audio,
throw: false,
};
}
/**
* Build API URL with query parameters
*/
private buildUrl(options?: DeepgramSpecificOptions, language?: string): string {
const params = new URLSearchParams();
const userTier = (options?.tier || '').toString().toLowerCase();
const isNovaFamily = userTier.includes('nova');
// Required parameters
// Required parameters
// Nova-3 and newer models
if (userTier === 'nova-3') {
params.append('model', 'nova-3');
}
// Legacy Nova-2 handling
else if (isNovaFamily || (language && language.startsWith('ko'))) {
params.append('model', '2-general');
params.append('tier', 'nova');
} else {
const model = options?.tier ?? DEEPGRAM_CONFIG.DEFAULT_MODEL;
params.append('model', model);
}
// Language configuration
if (language && language !== 'auto') {
params.append('language', language);
} else if (options?.detectLanguage) {
params.append('detect_language', 'true');
}
// Feature flags
this.appendFeatureFlags(params, options);
return `${DEEPGRAM_CONFIG.API_ENDPOINT}?${params.toString()}`;
}
/**
* Append feature flags to URL parameters
*/
private appendFeatureFlags(params: URLSearchParams, options?: DeepgramSpecificOptions): void {
const features = {
punctuate: options?.punctuate !== false,
smart_format: options?.smartFormat,
diarize: options?.diarize,
utterances: options?.utterances,
numerals: options?.numerals,
profanity_filter: options?.profanityFilter,
};
Object.entries(features).forEach(([key, value]) => {
if (value) {
params.append(key, 'true');
}
});
// Array-based features
if (options?.redact?.length) {
params.append('redact', options.redact.join(','));
}
if (options?.keywords?.length) {
params.append('keywords', options.keywords.join(','));
}
}
/**
* Build request headers
*/
private buildHeaders(): Record<string, string> {
return {
Authorization: `Token ${this.config.apiKey}`,
'Content-Type': 'audio/wav',
};
}
/**
* Handle API errors
*/
private handleApiError(response: {
status: number;
json?: unknown;
headers?: Record<string, string>;
}): never {
const errorBody = isPlainRecord(response.json) ? response.json : undefined;
const errorMessage =
(typeof errorBody?.message === 'string' ? errorBody.message : undefined) ??
(typeof errorBody?.error === 'string' ? errorBody.error : undefined) ??
'Unknown error';
this.config.logger.error(`Deepgram API Error: ${response.status}`, undefined, {
status: response.status,
errorBody,
});
const errorMap: Record<number, () => Error> = {
400: () => new TranscriptionError(errorMessage, 'BAD_REQUEST', 'deepgram', false, 400),
401: () => new ProviderAuthenticationError('deepgram'),
402: () =>
new TranscriptionError(
'Insufficient credits',
'INSUFFICIENT_CREDITS',
'deepgram',
false,
402
),
429: () => {
const retryAfter = response.headers?.['retry-after'];
return new ProviderRateLimitError(
'deepgram',
retryAfter ? parseInt(retryAfter, 10) : undefined
);
},
500: () => new ProviderUnavailableError('deepgram'),
502: () => new ProviderUnavailableError('deepgram'),
503: () => new ProviderUnavailableError('deepgram'),
};
const errorFactory = errorMap[response.status];
if (errorFactory) {
throw errorFactory();
}
throw new TranscriptionError(
`API error: ${errorMessage}`,
'UNKNOWN_ERROR',
'deepgram',
false,
response.status
);
}
/**
* Create segments from words
*/
private createSegments(
words?: Array<{
word: string;
confidence: number;
start: number;
end: number;
speaker?: number;
}>
): TranscriptionSegment[] {
if (!words?.length) {
return [];
}
const segments: TranscriptionSegment[] = [];
const wordsPerSegment = DEEPGRAM_CONFIG.WORDS_PER_SEGMENT;
for (let i = 0; i < words.length; i += wordsPerSegment) {
const segmentWords = words.slice(i, Math.min(i + wordsPerSegment, words.length));
if (segmentWords.length > 0) {
segments.push(this.createSegment(segmentWords, Math.floor(i / wordsPerSegment)));
}
}
return segments;
}
/**
* Create a single segment from words
*/
private createSegment(
words: Array<{
word: string;
confidence: number;
start: number;
end: number;
speaker?: number;
}>,
id: number
): TranscriptionSegment {
const text = words.map((w) => w.word).join(' ');
const totalConfidence = words.reduce((acc, w) => acc + w.confidence, 0);
return {
id,
start: words[0].start,
end: words[words.length - 1].end,
text,
confidence: totalConfidence / words.length,
speaker: words[0].speaker !== undefined ? String(words[0].speaker) : undefined,
};
}
/**
* Check if error is retryable
*/
private isRetryableError = (error: unknown): boolean => {
if (error instanceof TranscriptionError) {
return error.isRetryable;
}
if (!(error instanceof Error)) {
return false;
}
const retryableMessages = ['network', 'timeout', 'econnreset', 'socket'];
const message = error.message.toLowerCase();
return retryableMessages.some((msg) => message.includes(msg));
};
/**
* Validate input audio
*/
private validateInput(audio: ArrayBuffer): void {
if (!audio || audio.byteLength === 0) {
throw new TranscriptionError(
'Invalid audio buffer',
'INVALID_INPUT',
'deepgram',
false
);
}
if (audio.byteLength > DEEPGRAM_CONFIG.MAX_FILE_SIZE) {
throw new TranscriptionError(
`Audio file too large: ${audio.byteLength} bytes`,
'FILE_TOO_LARGE',
'deepgram',
false
);
}
}
/**
* Count words in text
*/
private countWords(text: string): number {
return text.split(/\s+/).filter((word) => word.length > 0).length;
}
/**
* Log request metrics
*/
private logRequestMetrics(startTime: number, fileSize: number, status: number): void {
const duration = Date.now() - startTime;
this.config.logger.info('Deepgram request completed', {
duration,
fileSize,
status,
});
}
}