269 lines
8.7 KiB
TypeScript
269 lines
8.7 KiB
TypeScript
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<T>(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<string, unknown>)[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<AudioGenerationResponse> {
|
|
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<AudioGenerationResponse>(
|
|
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<AudioRecord[]> {
|
|
const email = this.resolveEmail(userEmail);
|
|
|
|
if (!email) {
|
|
throw { success: false, message: 'Email do usuário não fornecido' };
|
|
}
|
|
|
|
try {
|
|
const response = await apiService.get<unknown>(
|
|
`${this.GET_AUDIOS_ENDPOINT}/${email}`,
|
|
{ params: { page: page.toString(), per_page: perPage.toString() } }
|
|
);
|
|
return this.extractArray<AudioRecord>(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 + <a download> — necessário porque links cross-origin ignoram `download` e abrem a URL.
|
|
*/
|
|
async downloadAudioFile(audioUrl: string, filename?: string): Promise<void> {
|
|
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<Blob> => {
|
|
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<Blob> =>
|
|
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();
|