import { GlobalFunctions, TransferAreaProperties } from '@/GlobalFunctions'; import { apiService } from './api'; export type VoiceType = 'alloy' | 'echo' | 'fable' | 'nova' | 'onyx' | 'shimmer'; export interface AudioGenerationResponse { success: boolean; audio_url: string; audio_generation_id: string; message: string; } export interface AudioRecord { id: string; user_email: string; estabelecimento_id: number; input_text: string; model: string; voice: VoiceType; audio_url: string; duration_seconds: number | null; file_size: number; cost_usd: string; created_at: string; } export interface AudioGenerationRequest { message: string; voice: VoiceType; userEmail?: string; estabelecimentoId?: number; } export const VOICE_OPTIONS = { alloy: { label: 'Alloy', gender: 'Masculina', style: 'Neutra, equilibrada, tom corporativo', description: 'Boa para tutoriais e comunicações institucionais.', }, echo: { label: 'Echo', gender: 'Masculina', style: 'Forte e profissional, mais grave', description: 'Ideal para voz de autoridade ou locução firme.', }, fable: { label: 'Fable', gender: 'Feminina', style: 'Narrativa, calorosa e envolvente', description: 'Ótima para storytelling e áudios empáticos.', }, onyx: { label: 'Onyx', gender: 'Masculina', style: 'Grave, autoritária, impactante', description: 'Excelente para trailers, mensagens sérias ou institucionais.', }, nova: { label: 'Nova', gender: 'Feminina', style: 'Brilhante, animada, energética', description: 'Boa para vídeos curtos, marketing ou conteúdos leves.', }, shimmer: { label: 'Shimmer', gender: 'Feminina', style: 'Suave, otimista, clara', description: 'Boa para mensagens acolhedoras, explicações e IA conversacional.', }, } as const; type ApiErrorShape = { message?: string; status?: number }; class AudioGenerationService { private readonly AUDIO_GENERATION_ENDPOINT = '/webhook/codex/gerar_audio'; private readonly GET_AUDIOS_ENDPOINT = '/webhook/fd073ec0-81ab-4681-8acd-b0da71f6ad30/codex/get_gerar_audios'; private readonly DELETE_AUDIO_ENDPOINT = '/webhook/fd073ec0-81ab-4681-8acd-b0da71f6ad30/codex/delete_gerar_audio'; private resolveEmail(userEmail?: string): string { return userEmail || GlobalFunctions.getTransferProperty(TransferAreaProperties.UsuarioEmail); } private toApiError(error: unknown, fallback: string): never { const e = error as ApiErrorShape; throw { success: false, message: e?.message || fallback, status: e?.status }; } private extractArray(data: unknown, keys: string[]): T[] { if (Array.isArray(data)) return data as T[]; if (data && typeof data === 'object') { for (const key of keys) { const candidate = (data as Record)[key]; if (Array.isArray(candidate)) return candidate as T[]; } } return []; } isValidVoice(voice: string): voice is VoiceType { return Object.keys(VOICE_OPTIONS).includes(voice); } getVoiceInfo(voice: VoiceType) { return VOICE_OPTIONS[voice]; } getAllVoices() { return Object.entries(VOICE_OPTIONS).map(([key, info]) => ({ value: key as VoiceType, ...info, })); } validateText(text: string, maxLength = 4096): { valid: boolean; error?: string } { if (!text?.trim()) return { valid: false, error: 'O texto não pode estar vazio' }; if (text.length > maxLength) return { valid: false, error: `O texto é muito longo. Máximo: ${maxLength} caracteres` }; return { valid: true }; } async generateAudio(request: AudioGenerationRequest): Promise { const { message, voice } = request; if (!message?.trim()) { throw { success: false, message: 'O texto não pode estar vazio' }; } if (!this.isValidVoice(voice)) { throw { success: false, message: `Voz inválida. Opções disponíveis: ${Object.keys(VOICE_OPTIONS).join(', ')}` }; } const email = GlobalFunctions.getTransferProperty(TransferAreaProperties.UsuarioEmail); const estabId = GlobalFunctions.getTransferProperty(TransferAreaProperties.EstabelecimentoCodigo); try { const response = await apiService.post( this.AUDIO_GENERATION_ENDPOINT, { estabelecimento_id: estabId, user_email: email, message, voice } ); return response.data; } catch (error: unknown) { console.error('Erro na geração de áudio:', error); this.toApiError(error, 'Erro ao gerar áudio'); } } async getAudios(userEmail?: string, page = 1, perPage = 10): Promise { const email = this.resolveEmail(userEmail); if (!email) { throw { success: false, message: 'Email do usuário não fornecido' }; } try { const response = await apiService.get( `${this.GET_AUDIOS_ENDPOINT}/${email}`, { params: { page: page.toString(), per_page: perPage.toString() } } ); return this.extractArray(response.data, ['audios', 'data']); } catch (error: unknown) { console.error('Erro ao buscar áudios:', error); this.toApiError(error, 'Erro ao buscar áudios'); } } async deleteAudio(audioId: string, userEmail?: string): Promise<{ success: boolean; message?: string }> { const email = this.resolveEmail(userEmail); if (!email) throw { success: false, message: 'Email do usuário não fornecido' }; if (!audioId) throw { success: false, message: 'ID do áudio não fornecido' }; try { const response = await apiService.delete<{ success: boolean; message?: string } | Array<{ success: boolean; message?: string }>>( `${this.DELETE_AUDIO_ENDPOINT}/${email}/${audioId}` ); const result = Array.isArray(response.data) ? response.data[0] : response.data; return { success: result.success ?? true, message: result.message || 'Áudio deletado com sucesso' }; } catch (error: unknown) { console.error('Erro ao deletar áudio:', error); this.toApiError(error, 'Erro ao deletar áudio'); } } /** * Baixa o arquivo no navegador (sem navegar para a URL). * Usa blob local + — necessário porque links cross-origin ignoram `download` e abrem a URL. */ async downloadAudioFile(audioUrl: string, filename?: string): Promise { const urlTrim = audioUrl?.trim(); if (!urlTrim) { throw new Error('URL do áudio inválida'); } const safeName = (filename || `audio_${Date.now()}.mp3`).replace(/[/\\?%*:|"<>]/g, '_'); let sameOrigin = false; try { const u = new URL(urlTrim, typeof window !== 'undefined' ? window.location.href : undefined); sameOrigin = typeof window !== 'undefined' && u.origin === window.location.origin; } catch { sameOrigin = false; } const fetchBlob = async (): Promise => { const response = await fetch(urlTrim, { mode: 'cors', credentials: sameOrigin ? 'include' : 'omit', cache: 'no-store', }); if (!response.ok) { throw new Error(`Falha ao baixar (HTTP ${response.status})`); } return response.blob(); }; const xhrBlob = (): Promise => new Promise((resolve, reject) => { const xhr = new XMLHttpRequest(); xhr.open('GET', urlTrim, true); xhr.responseType = 'blob'; xhr.onload = () => { if (xhr.status >= 200 && xhr.status < 300) { resolve(xhr.response); } else { reject(new Error(`Falha ao baixar (HTTP ${xhr.status})`)); } }; xhr.onerror = () => reject(new Error('Falha de rede ao baixar o áudio')); xhr.send(); }); let blob: Blob; try { blob = await fetchBlob(); } catch (e1) { try { blob = await xhrBlob(); } catch (e2) { console.error('downloadAudioFile:', e1, e2); throw new Error( 'Não foi possível baixar o áudio. Se o arquivo estiver em outro domínio, é preciso CORS liberando GET para esta origem.' ); } } const needsMimeFix = !blob.type || blob.type === 'application/octet-stream' || blob.type === 'text/html'; const typedBlob = needsMimeFix ? new Blob([blob], { type: 'audio/mpeg' }) : blob; const objectUrl = URL.createObjectURL(typedBlob); try { const a = document.createElement('a'); a.href = objectUrl; a.download = safeName.endsWith('.mp3') ? safeName : `${safeName}.mp3`; a.style.display = 'none'; a.rel = 'noopener'; document.body.appendChild(a); a.click(); document.body.removeChild(a); } finally { URL.revokeObjectURL(objectUrl); } } } export const audioGenerationService = new AudioGenerationService();