diff --git a/src/domain/models/Settings.ts b/src/domain/models/Settings.ts index 0ab3c48..e54e41f 100644 --- a/src/domain/models/Settings.ts +++ b/src/domain/models/Settings.ts @@ -48,6 +48,11 @@ export interface SpeechToTextSettings { // A/B Testing abTestEnabled?: boolean; + + // Large File Handling (for Deepgram) + autoChunking?: boolean; + maxChunkSizeMB?: number; + chunkOverlapSeconds?: number; abTestSplit?: number; abTestDuration?: number; abTestMetrics?: 'all' | 'latency' | 'accuracy' | 'cost'; diff --git a/src/infrastructure/api/providers/deepgram/DeepgramAdapter.ts b/src/infrastructure/api/providers/deepgram/DeepgramAdapter.ts index 57d917e..a14becb 100644 --- a/src/infrastructure/api/providers/deepgram/DeepgramAdapter.ts +++ b/src/infrastructure/api/providers/deepgram/DeepgramAdapter.ts @@ -11,6 +11,8 @@ import { } from '../ITranscriber'; import { DeepgramService } from './DeepgramService'; import { DiarizationConfig, DEFAULT_DIARIZATION_CONFIG } from './DiarizationFormatter'; +import { AudioChunker } from './audioChunker'; +import { DEEPGRAM_API } from './constants'; /** * DeepgramService를 ITranscriber 인터페이스에 맞게 변환하는 Adapter @@ -18,6 +20,7 @@ import { DiarizationConfig, DEFAULT_DIARIZATION_CONFIG } from './DiarizationForm */ export class DeepgramAdapter implements ITranscriber { private config: ProviderConfig; + private audioChunker: AudioChunker; constructor( private deepgramService: DeepgramService, @@ -25,6 +28,7 @@ export class DeepgramAdapter implements ITranscriber { private settingsManager?: ISettingsManager, config?: Partial ) { + this.audioChunker = new AudioChunker(logger); this.config = { enabled: true, apiKey: '', @@ -47,11 +51,73 @@ export class DeepgramAdapter implements ITranscriber { options?: TranscriptionOptions ): Promise { const startTime = Date.now(); + const audioSizeMB = audio.byteLength / (1024 * 1024); + this.logger.debug('=== DeepgramAdapter.transcribe START ===', { audioSize: audio.byteLength, + audioSizeMB, options }); + try { + // Check settings for auto-chunking + const autoChunkingEnabled = this.settingsManager?.get('autoChunking') ?? true; + + // Check if chunking is needed and enabled + if (autoChunkingEnabled && this.audioChunker.needsChunking(audio.byteLength)) { + this.logger.info('Large file detected, using chunked processing', { + sizeMB: audioSizeMB, + recommendedSettings: this.audioChunker.getRecommendedSettings(audio.byteLength) + }); + + // Notify user about chunking + if (audioSizeMB > 100) { + this.logger.warn(`Very large audio file (${Math.round(audioSizeMB)}MB). Processing may take significant time. Consider reducing file size or bitrate for better performance.`); + } + + return await this.transcribeWithChunking(audio, options); + } + + // Standard processing for smaller files + return await this.transcribeStandard(audio, options); + } catch (error) { + const errorObj = error as Error; + + // Enhanced error handling for large files + if (errorObj instanceof TranscriptionError && errorObj.code === 'SERVER_TIMEOUT') { + const recommendations = this.audioChunker.getRecommendedSettings(audio.byteLength); + + this.logger.error('Timeout error - providing chunking recommendations', errorObj, { + audioSizeMB, + recommendations + }); + + const enhancedMessage = `Transcription timeout for ${Math.round(audioSizeMB)}MB file.\n\nRecommended solutions:\n• Enable automatic chunking (files will be split into ${recommendations.estimatedChunks || 'multiple'} chunks)\n• Use '${recommendations.recommendedModel}' model for faster processing\n• Reduce audio bitrate to ${recommendations.recommendedBitrate || '128 kbps'}\n• Convert to MP3 or OGG format for smaller file size`; + + throw new TranscriptionError( + enhancedMessage, + errorObj.code, + errorObj.provider, + errorObj.isRetryable, + errorObj.statusCode + ); + } + + // Handle other error types... + this.handleTranscriptionError(errorObj, audio, options); + throw error; + } + } + + /** + * Standard transcription without chunking + */ + private async transcribeStandard( + audio: ArrayBuffer, + options?: TranscriptionOptions + ): Promise { + const startTime = Date.now(); + try { // 옵션 변환 const deepgramOptions = this.convertOptions(options); @@ -100,11 +166,102 @@ export class DeepgramAdapter implements ITranscriber { }); return result; - } catch (error) { - const errorObj = error as Error; + } finally { + // Cleanup if needed + } + } + + /** + * Transcription with automatic chunking for large files + */ + private async transcribeWithChunking( + audio: ArrayBuffer, + options?: TranscriptionOptions + ): Promise { + const startTime = Date.now(); + + try { + // Split audio into chunks + const chunks = await this.audioChunker.splitAudio(audio); + this.logger.info(`Processing ${chunks.length} audio chunks`); - // 에러 타입별로 사용자 친화적 메시지 제공 - if (errorObj instanceof TranscriptionError) { + // Process each chunk + const chunkResults: string[] = []; + let totalConfidence = 0; + let detectedLanguage: string | undefined; + + for (let i = 0; i < chunks.length; i++) { + this.logger.debug(`Processing chunk ${i + 1}/${chunks.length}`, { + chunkSizeMB: Math.round(chunks[i].byteLength / (1024 * 1024)) + }); + + try { + const chunkResponse = await this.transcribeStandard(chunks[i], options); + + if (chunkResponse.text && chunkResponse.text.trim()) { + chunkResults.push(chunkResponse.text); + totalConfidence += chunkResponse.confidence || 0; + + if (!detectedLanguage && chunkResponse.language) { + detectedLanguage = chunkResponse.language; + } + } + } catch (chunkError) { + this.logger.error(`Failed to process chunk ${i + 1}`, chunkError as Error); + // Continue with other chunks even if one fails + } + } + + // Merge results + const mergedText = this.audioChunker.mergeTranscriptionResults(chunkResults); + const averageConfidence = chunkResults.length > 0 ? totalConfidence / chunkResults.length : 0; + + if (!mergedText || mergedText.trim().length === 0) { + throw new TranscriptionError( + 'All chunks failed to produce transcription', + 'CHUNKING_FAILED', + 'deepgram', + false + ); + } + + const result: TranscriptionResponse = { + text: mergedText, + language: detectedLanguage, + confidence: averageConfidence, + provider: 'deepgram', + metadata: { + processingTime: Date.now() - startTime, + chunksProcessed: chunks.length, + chunksSuccessful: chunkResults.length + } + }; + + this.logger.info('Chunked transcription completed', { + totalChunks: chunks.length, + successfulChunks: chunkResults.length, + processingTime: result.metadata.processingTime, + textLength: result.text.length + }); + + return result; + } catch (error) { + this.logger.error('Chunked transcription failed', error as Error); + throw error; + } + } + + /** + * Handle transcription errors with enhanced messages + */ + private handleTranscriptionError( + error: Error, + audio: ArrayBuffer, + options?: TranscriptionOptions + ): void { + const errorObj = error as TranscriptionError; + + if (errorObj instanceof TranscriptionError) { // 빈 transcript 에러의 경우 추가 컨텍스트 제공 if (errorObj.code === 'EMPTY_TRANSCRIPT') { this.logger.error('DeepgramAdapter: Empty transcript - providing user guidance', errorObj, { @@ -122,7 +279,8 @@ export class DeepgramAdapter implements ITranscriber { • 마이크 볼륨이 충분한지 확인 • 배경소음이 너무 크지 않은지 확인 • 지원되는 오디오 형식인지 확인 (WAV, MP3, FLAC 등) -• 언어 설정이 올바른지 확인`; +• 언어 설정이 올바른지 확인 +• 파일 크기가 너무 크면 청킹 옵션 활성화 고려`; throw new TranscriptionError( enhancedMessage, @@ -146,7 +304,8 @@ export class DeepgramAdapter implements ITranscriber { • 올바른 오디오 파일을 선택했는지 확인 • 파일이 손상되지 않았는지 확인 • 지원되는 형식 (WAV, MP3, FLAC, OGG 등)인지 확인 -• 파일 크기가 2GB를 초과하지 않는지 확인`; +• 파일 크기가 2GB를 초과하지 않는지 확인 +• 50MB 이상 파일은 자동 청킹 사용 권장`; throw new TranscriptionError( enhancedMessage, @@ -157,14 +316,15 @@ export class DeepgramAdapter implements ITranscriber { ); } } - - this.logger.error('DeepgramAdapter: Transcription failed', errorObj, { - audioSize: audio.byteLength, - options: options, - errorType: errorObj.constructor.name - }); - throw error; } + + this.logger.error('DeepgramAdapter: Transcription failed', error, { + audioSize: audio.byteLength, + audioSizeMB: Math.round(audio.byteLength / (1024 * 1024)), + options: options, + errorType: error.constructor.name, + needsChunking: this.audioChunker.needsChunking(audio.byteLength) + }); } /** diff --git a/src/infrastructure/api/providers/deepgram/DeepgramService.ts b/src/infrastructure/api/providers/deepgram/DeepgramService.ts index 45d30e3..f735cc1 100644 --- a/src/infrastructure/api/providers/deepgram/DeepgramService.ts +++ b/src/infrastructure/api/providers/deepgram/DeepgramService.ts @@ -418,6 +418,7 @@ export class DeepgramService { private readonly API_ENDPOINT = 'https://api.deepgram.com/v1/listen'; private readonly MAX_FILE_SIZE = 2 * 1024 * 1024 * 1024; // 2GB (Deepgram 지원) private timeout: number; // configurable timeout + private lastAudioSize: number = 0; // Track last audio size for error messages private abortController?: AbortController; private circuitBreaker: CircuitBreaker; @@ -470,23 +471,40 @@ export class DeepgramService { // For very small files, use base timeout if (sizeMB <= 5) return baseTimeout; - // Estimate: 30s/MB + 50% buffer - const estimatedProcessingTime = Math.max(sizeMB * 30 * 1000, baseTimeout); - let dynamicTimeout = estimatedProcessingTime * 1.5; - - // Ensure a generous floor for large compressed files (e.g., 60–100MB M4A) - if (sizeMB >= 50) { - dynamicTimeout = Math.max(dynamicTimeout, 30 * 60 * 1000); // at least 30 minutes + // More aggressive timeout calculation for large files + // Based on empirical data: ~20 minutes for large files + let dynamicTimeout; + + if (sizeMB <= 10) { + // Small files: 30s/MB + dynamicTimeout = sizeMB * 30 * 1000; + } else if (sizeMB <= 50) { + // Medium files: 40s/MB + buffer + dynamicTimeout = sizeMB * 40 * 1000 * 1.5; + } else if (sizeMB <= 100) { + // Large files: 45s/MB + larger buffer + dynamicTimeout = sizeMB * 45 * 1000 * 1.8; + } else { + // Very large files: 50s/MB + 2x buffer + dynamicTimeout = sizeMB * 50 * 1000 * 2; } - // Cap per global maximum - const MAX_CAP = DEEPGRAM_API.MAX_TIMEOUT; + // Ensure minimum timeout for large compressed files + if (sizeMB >= 50) { + dynamicTimeout = Math.max(dynamicTimeout, 40 * 60 * 1000); // at least 40 minutes + } + + if (sizeMB >= 100) { + dynamicTimeout = Math.max(dynamicTimeout, 60 * 60 * 1000); // at least 60 minutes + } + + // For extremely large files, allow up to 90 minutes + const MAX_CAP = 90 * 60 * 1000; // 90 minutes dynamicTimeout = Math.min(dynamicTimeout, MAX_CAP); this.logger.debug('Dynamic timeout calculation', { audioSizeMB: sizeMB, baseTimeout, - estimatedProcessingTime, finalTimeout: dynamicTimeout, timeoutMinutes: Math.round(dynamicTimeout / 60000) }); @@ -502,6 +520,9 @@ export class DeepgramService { this.abortController = new AbortController(); const startTime = Date.now(); + // Store audio size for error reporting + this.lastAudioSize = audio.byteLength; + // Calculate dynamic timeout based on file size const dynamicTimeout = this.calculateDynamicTimeout(audio.byteLength); @@ -781,8 +802,10 @@ export class DeepgramService { case 503: throw new ProviderUnavailableError('deepgram'); case 504: + // Extract file size info if available + const sizeInfo = this.lastAudioSize ? ` (${Math.round(this.lastAudioSize / (1024 * 1024))}MB)` : ''; throw new TranscriptionError( - `Server timeout processing large audio file. This often happens with very large files (>50MB). Try: 1) Breaking the file into smaller chunks, 2) Reducing audio quality/bitrate, or 3) Using a different model like 'enhanced' which may be faster.`, + `Server timeout processing large audio file${sizeInfo}. This often happens with very large files (>50MB). Try: 1) Breaking the file into smaller chunks (recommended: <50MB per chunk), 2) Reducing audio quality/bitrate to 64-128 kbps, 3) Using the 'enhanced' model which may be faster, or 4) Converting to a more efficient format like MP3 or OGG.`, 'SERVER_TIMEOUT', 'deepgram', true, // retryable diff --git a/src/infrastructure/api/providers/deepgram/audioChunker.ts b/src/infrastructure/api/providers/deepgram/audioChunker.ts new file mode 100644 index 0000000..4e8d675 --- /dev/null +++ b/src/infrastructure/api/providers/deepgram/audioChunker.ts @@ -0,0 +1,175 @@ +import type { ILogger } from '../../../../types'; +import { DEEPGRAM_API } from './constants'; + +/** + * Audio chunking utility for handling large files + * Splits large audio files into smaller chunks for reliable processing + */ +export class AudioChunker { + private readonly CHUNK_SIZE = DEEPGRAM_API.RECOMMENDED_MAX_SIZE; // 50MB default + private readonly WAV_HEADER_SIZE = 44; + + constructor(private logger: ILogger) {} + + /** + * Check if audio needs chunking based on size + */ + needsChunking(audioSize: number): boolean { + return audioSize > this.CHUNK_SIZE; + } + + /** + * Split audio buffer into smaller chunks + * Currently supports WAV format for precise splitting + */ + async splitAudio(audio: ArrayBuffer): Promise { + const audioSize = audio.byteLength; + + // If small enough, return as single chunk + if (!this.needsChunking(audioSize)) { + return [audio]; + } + + this.logger.info('Splitting large audio file into chunks', { + originalSizeMB: Math.round(audioSize / (1024 * 1024)), + chunkSizeMB: Math.round(this.CHUNK_SIZE / (1024 * 1024)), + estimatedChunks: Math.ceil(audioSize / this.CHUNK_SIZE) + }); + + // For WAV files, we can split more intelligently + if (this.isWavFile(audio)) { + return this.splitWavAudio(audio); + } + + // For other formats, simple byte splitting (may not work perfectly) + return this.splitByteArray(audio); + } + + /** + * Check if audio is WAV format + */ + private isWavFile(audio: ArrayBuffer): boolean { + if (audio.byteLength < 12) return false; + const view = new Uint8Array(audio, 0, 4); + return view[0] === 0x52 && view[1] === 0x49 && view[2] === 0x46 && view[3] === 0x46; + } + + /** + * Split WAV file preserving header + */ + private splitWavAudio(audio: ArrayBuffer): ArrayBuffer[] { + const chunks: ArrayBuffer[] = []; + const header = audio.slice(0, this.WAV_HEADER_SIZE); + const data = audio.slice(this.WAV_HEADER_SIZE); + + const dataSize = data.byteLength; + const chunkDataSize = this.CHUNK_SIZE - this.WAV_HEADER_SIZE; + const numChunks = Math.ceil(dataSize / chunkDataSize); + + for (let i = 0; i < numChunks; i++) { + const start = i * chunkDataSize; + const end = Math.min(start + chunkDataSize, dataSize); + const chunkData = data.slice(start, end); + + // Create new WAV with header and chunk data + const chunk = new ArrayBuffer(this.WAV_HEADER_SIZE + chunkData.byteLength); + const chunkView = new Uint8Array(chunk); + + // Copy header + chunkView.set(new Uint8Array(header), 0); + + // Copy data + chunkView.set(new Uint8Array(chunkData), this.WAV_HEADER_SIZE); + + // Update header with new data size + this.updateWavHeader(chunk, chunkData.byteLength); + + chunks.push(chunk); + + this.logger.debug(`Created chunk ${i + 1}/${numChunks}`, { + sizeMB: Math.round(chunk.byteLength / (1024 * 1024)) + }); + } + + return chunks; + } + + /** + * Update WAV header with new data size + */ + private updateWavHeader(buffer: ArrayBuffer, dataSize: number): void { + const view = new DataView(buffer); + + // Update file size (offset 4) + view.setUint32(4, dataSize + 36, true); + + // Update data chunk size (offset 40) + view.setUint32(40, dataSize, true); + } + + /** + * Simple byte array splitting for non-WAV formats + * Note: This may not work perfectly for all formats + */ + private splitByteArray(audio: ArrayBuffer): ArrayBuffer[] { + const chunks: ArrayBuffer[] = []; + const audioSize = audio.byteLength; + const numChunks = Math.ceil(audioSize / this.CHUNK_SIZE); + + this.logger.warn('Using simple byte splitting for non-WAV format. Results may vary.'); + + for (let i = 0; i < numChunks; i++) { + const start = i * this.CHUNK_SIZE; + const end = Math.min(start + this.CHUNK_SIZE, audioSize); + chunks.push(audio.slice(start, end)); + + this.logger.debug(`Created chunk ${i + 1}/${numChunks}`, { + sizeMB: Math.round((end - start) / (1024 * 1024)) + }); + } + + return chunks; + } + + /** + * Merge transcription results from multiple chunks + */ + mergeTranscriptionResults(results: string[]): string { + // Simple concatenation with space + // Could be improved with smarter merging logic + return results.filter(r => r && r.trim()).join(' '); + } + + /** + * Get recommended settings for large files + */ + getRecommendedSettings(fileSize: number): { + needsChunking: boolean; + recommendedModel: string; + recommendedBitrate?: string; + estimatedChunks?: number; + } { + const sizeMB = fileSize / (1024 * 1024); + + if (sizeMB <= 50) { + return { + needsChunking: false, + recommendedModel: 'nova-2' + }; + } else if (sizeMB <= 100) { + return { + needsChunking: true, + recommendedModel: 'enhanced', // Faster model + recommendedBitrate: '128 kbps', + estimatedChunks: Math.ceil(fileSize / this.CHUNK_SIZE) + }; + } else { + return { + needsChunking: true, + recommendedModel: 'enhanced', + recommendedBitrate: '64 kbps', // Lower bitrate for very large files + estimatedChunks: Math.ceil(fileSize / this.CHUNK_SIZE) + }; + } + } +} \ No newline at end of file diff --git a/src/infrastructure/api/providers/deepgram/constants.ts b/src/infrastructure/api/providers/deepgram/constants.ts index f3e1115..d5c672a 100644 --- a/src/infrastructure/api/providers/deepgram/constants.ts +++ b/src/infrastructure/api/providers/deepgram/constants.ts @@ -8,8 +8,9 @@ export const DEEPGRAM_API = { ENDPOINT: 'https://api.deepgram.com/v1/listen', MAX_FILE_SIZE: 2 * 1024 * 1024 * 1024, // 2GB DEFAULT_TIMEOUT: 30000, // 30초 - MAX_TIMEOUT: 60 * 60 * 1000, // 60분 (대용량 파일 대비) - REQUESTS_PER_MINUTE: 100 + MAX_TIMEOUT: 90 * 60 * 1000, // 90분 (대용량 파일 대비) + REQUESTS_PER_MINUTE: 100, + RECOMMENDED_MAX_SIZE: 50 * 1024 * 1024 // 50MB recommended limit for reliable processing } as const; // === 오디오 검증 임계값 === diff --git a/src/ui/settings/components/DeepgramSettings.ts b/src/ui/settings/components/DeepgramSettings.ts index c860950..400c182 100644 --- a/src/ui/settings/components/DeepgramSettings.ts +++ b/src/ui/settings/components/DeepgramSettings.ts @@ -468,6 +468,7 @@ export class DeepgramSettings { this.renderLanguagePreference(container); this.renderTimeoutSetting(container); this.renderRetrySetting(container); + this.renderChunkingSettings(container); } /** @@ -536,6 +537,68 @@ export class DeepgramSettings { }); } + /** + * Chunking settings for large files + */ + private renderChunkingSettings(container: HTMLElement): void { + // Auto-chunking toggle + new Setting(container) + .setName('Automatic File Chunking') + .setDesc('Automatically split large audio files (>50MB) into smaller chunks for reliable processing') + .addToggle(toggle => { + toggle + .setValue(this.plugin.settings.autoChunking ?? true) + .onChange(async (value: boolean) => { + this.plugin.settings.autoChunking = value; + await this.plugin.saveSettings(); + + // Show/hide chunk size setting based on toggle + const chunkSizeSetting = container.querySelector('.chunk-size-setting') as HTMLElement; + if (chunkSizeSetting) { + chunkSizeSetting.style.display = value ? 'flex' : 'none'; + } + }); + }); + + // Maximum chunk size + const chunkSizeSetting = new Setting(container) + .setName('Maximum Chunk Size') + .setDesc('Maximum size per chunk in MB (recommended: 50MB)') + .addSlider(slider => { + slider + .setLimits(10, 100, 10) + .setValue(this.plugin.settings.maxChunkSizeMB ?? 50) + .setDynamicTooltip() + .onChange(async (value: number) => { + this.plugin.settings.maxChunkSizeMB = value; + await this.plugin.saveSettings(); + }); + }); + + // Add class for conditional display + chunkSizeSetting.settingEl.addClass('chunk-size-setting'); + + // Initially hide if auto-chunking is disabled + if (!this.plugin.settings.autoChunking) { + chunkSizeSetting.settingEl.style.display = 'none'; + } + + // Add informational note about chunking + const noteEl = container.createDiv(); + noteEl.addClass('setting-item-description'); + noteEl.style.marginTop = '10px'; + noteEl.innerHTML = ` + Note on Large Files:
+ • Files larger than 50MB may experience timeout errors
+ • Auto-chunking splits files into manageable pieces
+ • Each chunk is processed separately and results are merged
+ • For best results with very large files (>100MB), consider:
+   - Using the 'enhanced' model for faster processing
+   - Reducing audio bitrate to 64-128 kbps
+   - Converting to efficient formats (MP3, OGG) + `; + } + /** * 비용 추정 섹션 */