stiff
Some checks are pending
Backend CI / test (push) Waiting to run
Backend CI / build (push) Blocked by required conditions
Deploy Admin Panel / Deploy Admin Panel (push) Waiting to run
Deploy Admin Panel / Admin Panel Verification (push) Blocked by required conditions
Deploy Mnemo Cards / Deploy Backend (push) Waiting to run
Deploy Mnemo Cards / Deploy Web App (push) Blocked by required conditions
Deploy Mnemo Cards / Final Verification (push) Blocked by required conditions
Some checks are pending
Backend CI / test (push) Waiting to run
Backend CI / build (push) Blocked by required conditions
Deploy Admin Panel / Deploy Admin Panel (push) Waiting to run
Deploy Admin Panel / Admin Panel Verification (push) Blocked by required conditions
Deploy Mnemo Cards / Deploy Backend (push) Waiting to run
Deploy Mnemo Cards / Deploy Web App (push) Blocked by required conditions
Deploy Mnemo Cards / Final Verification (push) Blocked by required conditions
This commit is contained in:
parent
6187db39bb
commit
2b65fb00ee
5 changed files with 182 additions and 26 deletions
|
|
@ -12,12 +12,14 @@ export const voiceServiceApi = {
|
||||||
* @param text Text to convert to speech
|
* @param text Text to convert to speech
|
||||||
* @param voice Voice identifier (optional, defaults to 'alloy')
|
* @param voice Voice identifier (optional, defaults to 'alloy')
|
||||||
* @param language Language code (optional)
|
* @param language Language code (optional)
|
||||||
|
* @param instructions Instructions for speech generation (optional)
|
||||||
* @returns Object ID and presigned URL
|
* @returns Object ID and presigned URL
|
||||||
*/
|
*/
|
||||||
async generateTTS(
|
async generateTTS(
|
||||||
text: string,
|
text: string,
|
||||||
voice?: string,
|
voice?: string,
|
||||||
language?: string
|
language?: string,
|
||||||
|
instructions?: string
|
||||||
): Promise<GenerateTTSResponse> {
|
): Promise<GenerateTTSResponse> {
|
||||||
const response = await adminApiClient.post<GenerateTTSResponse>(
|
const response = await adminApiClient.post<GenerateTTSResponse>(
|
||||||
'/api/v2/media/generate-tts',
|
'/api/v2/media/generate-tts',
|
||||||
|
|
@ -25,6 +27,7 @@ export const voiceServiceApi = {
|
||||||
text,
|
text,
|
||||||
voice: voice || 'alloy',
|
voice: voice || 'alloy',
|
||||||
...(language && { language }),
|
...(language && { language }),
|
||||||
|
...(instructions && { instructions }),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,17 @@ import { voicesApi, type VoiceDto } from '@/api/voices'
|
||||||
import { AudioUpload } from '@/components/ui/audio-upload'
|
import { AudioUpload } from '@/components/ui/audio-upload'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog'
|
||||||
import { X, Plus, Music, Play, Pause, Volume2, Loader2 } from 'lucide-react'
|
import { X, Plus, Music, Play, Pause, Volume2, Loader2 } from 'lucide-react'
|
||||||
import type { AxiosError } from 'axios'
|
import type { AxiosError } from 'axios'
|
||||||
import { voiceServiceApi } from '@/api/voiceService'
|
import { voiceServiceApi } from '@/api/voiceService'
|
||||||
|
|
@ -25,8 +35,16 @@ export function CardVoicesManager({
|
||||||
const [showAddForm, setShowAddForm] = useState(false)
|
const [showAddForm, setShowAddForm] = useState(false)
|
||||||
const [newAudio, setNewAudio] = useState<string | undefined>(undefined)
|
const [newAudio, setNewAudio] = useState<string | undefined>(undefined)
|
||||||
const [newLanguage, setNewLanguage] = useState('es')
|
const [newLanguage, setNewLanguage] = useState('es')
|
||||||
|
const [newInstructions, setNewInstructions] = useState('')
|
||||||
const [isGeneratingTTS, setIsGeneratingTTS] = useState(false)
|
const [isGeneratingTTS, setIsGeneratingTTS] = useState(false)
|
||||||
|
|
||||||
|
// Dialog state for TTS generation
|
||||||
|
const [showTTSDialog, setShowTTSDialog] = useState(false)
|
||||||
|
const [dialogText, setDialogText] = useState('')
|
||||||
|
const [dialogVoice, setDialogVoice] = useState('alloy')
|
||||||
|
const [dialogLanguage, setDialogLanguage] = useState('es')
|
||||||
|
const [dialogInstructions, setDialogInstructions] = useState('pronunciar la frase claramente')
|
||||||
|
|
||||||
// Load voices for the card
|
// Load voices for the card
|
||||||
const { data: voicesData, isLoading, refetch } = useQuery({
|
const { data: voicesData, isLoading, refetch } = useQuery({
|
||||||
queryKey: ['cardVoices', cardId],
|
queryKey: ['cardVoices', cardId],
|
||||||
|
|
@ -66,6 +84,7 @@ export function CardVoicesManager({
|
||||||
setShowAddForm(false)
|
setShowAddForm(false)
|
||||||
setNewAudio(undefined)
|
setNewAudio(undefined)
|
||||||
setNewLanguage('es')
|
setNewLanguage('es')
|
||||||
|
setNewInstructions('')
|
||||||
},
|
},
|
||||||
onError: (error: unknown) => {
|
onError: (error: unknown) => {
|
||||||
console.error('❌ CardVoicesManager: addVoiceMutation.onError', error)
|
console.error('❌ CardVoicesManager: addVoiceMutation.onError', error)
|
||||||
|
|
@ -95,30 +114,46 @@ export function CardVoicesManager({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate TTS audio for card
|
// Open TTS generation dialog
|
||||||
const handleGenerateTTS = async () => {
|
const handleOpenTTSDialog = () => {
|
||||||
if (!cardOriginal || !cardOriginal.trim()) {
|
if (!cardOriginal || !cardOriginal.trim()) {
|
||||||
toast.error('Card text is required for TTS generation')
|
toast.error('Card text is required for TTS generation')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Initialize dialog with default values
|
||||||
|
setDialogText(cardOriginal.trim())
|
||||||
|
setDialogVoice('alloy')
|
||||||
|
setDialogLanguage('es')
|
||||||
|
setDialogInstructions('pronunciar la frase claramente')
|
||||||
|
setShowTTSDialog(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate TTS audio for card (called from dialog)
|
||||||
|
const handleConfirmGenerateTTS = async () => {
|
||||||
|
if (!dialogText || !dialogText.trim()) {
|
||||||
|
toast.error('Text is required for TTS generation')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (!cardId) {
|
if (!cardId) {
|
||||||
toast.error('Card ID is required')
|
toast.error('Card ID is required')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setShowTTSDialog(false)
|
||||||
setIsGeneratingTTS(true)
|
setIsGeneratingTTS(true)
|
||||||
try {
|
try {
|
||||||
const text = cardOriginal.trim()
|
const text = dialogText.trim()
|
||||||
// Use default voice 'alloy' for TTS generation
|
const voice = dialogVoice || 'alloy'
|
||||||
// Language (newLanguage) is used for both TTS generation and saving the voice to the card
|
const language = dialogLanguage || 'es'
|
||||||
const voice = 'alloy'
|
const instructions = dialogInstructions || undefined
|
||||||
|
|
||||||
console.log('🔄 CardVoicesManager: Requesting TTS generation', { text, voice, language: newLanguage })
|
console.log('🔄 CardVoicesManager: Requesting TTS generation', { text, voice, language, instructions })
|
||||||
|
|
||||||
// Generate TTS via backend API
|
// Generate TTS via backend API
|
||||||
// Backend calls voice-service (which handles caching) and saves to MinIO
|
// Backend calls voice-service (which handles caching) and saves to MinIO
|
||||||
const ttsResponse = await voiceServiceApi.generateTTS(text, voice, newLanguage)
|
const ttsResponse = await voiceServiceApi.generateTTS(text, voice, language, instructions)
|
||||||
console.log('✅ CardVoicesManager: TTS generated and saved to MinIO', {
|
console.log('✅ CardVoicesManager: TTS generated and saved to MinIO', {
|
||||||
objectId: ttsResponse.objectId
|
objectId: ttsResponse.objectId
|
||||||
})
|
})
|
||||||
|
|
@ -126,7 +161,7 @@ export function CardVoicesManager({
|
||||||
// Add voice to card using the objectId from backend
|
// Add voice to card using the objectId from backend
|
||||||
await addVoiceMutation.mutateAsync({
|
await addVoiceMutation.mutateAsync({
|
||||||
voiceUrl: ttsResponse.objectId,
|
voiceUrl: ttsResponse.objectId,
|
||||||
language: newLanguage || 'es',
|
language: language,
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('❌ CardVoicesManager: TTS generation failed', error)
|
console.error('❌ CardVoicesManager: TTS generation failed', error)
|
||||||
|
|
@ -152,7 +187,7 @@ export function CardVoicesManager({
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={handleGenerateTTS}
|
onClick={handleOpenTTSDialog}
|
||||||
disabled={disabled || isGeneratingTTS}
|
disabled={disabled || isGeneratingTTS}
|
||||||
>
|
>
|
||||||
{isGeneratingTTS ? (
|
{isGeneratingTTS ? (
|
||||||
|
|
@ -225,6 +260,7 @@ export function CardVoicesManager({
|
||||||
setShowAddForm(false)
|
setShowAddForm(false)
|
||||||
setNewAudio(undefined)
|
setNewAudio(undefined)
|
||||||
setNewLanguage('es')
|
setNewLanguage('es')
|
||||||
|
setNewInstructions('')
|
||||||
}}
|
}}
|
||||||
disabled={disabled || addVoiceMutation.isPending}
|
disabled={disabled || addVoiceMutation.isPending}
|
||||||
>
|
>
|
||||||
|
|
@ -235,6 +271,88 @@ export function CardVoicesManager({
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* TTS Generation Dialog */}
|
||||||
|
<Dialog open={showTTSDialog} onOpenChange={setShowTTSDialog}>
|
||||||
|
<DialogContent className="sm:max-w-[600px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Generate TTS Audio</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Configure the text-to-speech generation parameters
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4 py-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="dialog-text">Text</Label>
|
||||||
|
<Textarea
|
||||||
|
id="dialog-text"
|
||||||
|
value={dialogText}
|
||||||
|
onChange={(e) => setDialogText(e.target.value)}
|
||||||
|
placeholder="Enter text to convert to speech"
|
||||||
|
className="min-h-[100px]"
|
||||||
|
disabled={isGeneratingTTS}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="dialog-voice">Voice</Label>
|
||||||
|
<Input
|
||||||
|
id="dialog-voice"
|
||||||
|
value={dialogVoice}
|
||||||
|
onChange={(e) => setDialogVoice(e.target.value)}
|
||||||
|
placeholder="alloy"
|
||||||
|
disabled={isGeneratingTTS}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="dialog-language">Language</Label>
|
||||||
|
<Input
|
||||||
|
id="dialog-language"
|
||||||
|
value={dialogLanguage}
|
||||||
|
onChange={(e) => setDialogLanguage(e.target.value)}
|
||||||
|
placeholder="es"
|
||||||
|
disabled={isGeneratingTTS}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="dialog-instructions">Instructions</Label>
|
||||||
|
<Textarea
|
||||||
|
id="dialog-instructions"
|
||||||
|
value={dialogInstructions}
|
||||||
|
onChange={(e) => setDialogInstructions(e.target.value)}
|
||||||
|
placeholder="pronunciar la frase claramente"
|
||||||
|
className="min-h-[80px]"
|
||||||
|
disabled={isGeneratingTTS}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setShowTTSDialog(false)}
|
||||||
|
disabled={isGeneratingTTS}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={handleConfirmGenerateTTS}
|
||||||
|
disabled={isGeneratingTTS || !dialogText.trim()}
|
||||||
|
>
|
||||||
|
{isGeneratingTTS ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||||
|
Generating...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'Generate'
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
{/* Voices list */}
|
{/* Voices list */}
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="text-center py-4 text-sm text-muted-foreground">Loading voices...</div>
|
<div className="text-center py-4 text-sm text-muted-foreground">Loading voices...</div>
|
||||||
|
|
|
||||||
|
|
@ -272,19 +272,6 @@ export function AudioUpload({
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{onLanguageChange && (
|
|
||||||
<div className="mt-2">
|
|
||||||
<Label htmlFor="language" className="text-xs">Language</Label>
|
|
||||||
<Input
|
|
||||||
id="language"
|
|
||||||
value={language}
|
|
||||||
onChange={(e) => onLanguageChange(e.target.value)}
|
|
||||||
placeholder="en"
|
|
||||||
className="mt-1"
|
|
||||||
disabled={disabled}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<p className="text-sm text-muted-foreground mt-1">
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
{isUploading ? 'Uploading...' : 'Click to change audio file'}
|
{isUploading ? 'Uploading...' : 'Click to change audio file'}
|
||||||
</p>
|
</p>
|
||||||
|
|
@ -330,6 +317,21 @@ export function AudioUpload({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Language selector - show always when onLanguageChange is provided */}
|
||||||
|
{onLanguageChange && (
|
||||||
|
<div className="mt-2">
|
||||||
|
<Label htmlFor="language" className="text-xs">Language</Label>
|
||||||
|
<Input
|
||||||
|
id="language"
|
||||||
|
value={language}
|
||||||
|
onChange={(e) => onLanguageChange(e.target.value)}
|
||||||
|
placeholder="es"
|
||||||
|
className="mt-1"
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -510,13 +510,14 @@ class MediaApiV2 {
|
||||||
final text = json['text'] as String?;
|
final text = json['text'] as String?;
|
||||||
final voice = json['voice'] as String?;
|
final voice = json['voice'] as String?;
|
||||||
final language = json['language'] as String?;
|
final language = json['language'] as String?;
|
||||||
|
final instructions = json['instructions'] as String?;
|
||||||
|
|
||||||
if (text == null || text.trim().isEmpty) {
|
if (text == null || text.trim().isEmpty) {
|
||||||
return _badRequest('Text is required');
|
return _badRequest('Text is required');
|
||||||
}
|
}
|
||||||
|
|
||||||
print(
|
print(
|
||||||
'🔄 MediaApiV2: Requesting TTS from voice-service: text="${text.substring(0, text.length > 50 ? 50 : text.length)}", voice="${voice ?? 'default'}", language="${language ?? 'not provided'}"',
|
'🔄 MediaApiV2: Requesting TTS from voice-service: text="${text.substring(0, text.length > 50 ? 50 : text.length)}", voice="${voice ?? 'default'}", language="${language ?? 'not provided'}", instructions="${instructions != null ? instructions.substring(0, instructions.length > 50 ? 50 : instructions.length) : 'not provided'}"',
|
||||||
);
|
);
|
||||||
|
|
||||||
// Call voice-service
|
// Call voice-service
|
||||||
|
|
@ -527,6 +528,7 @@ class MediaApiV2 {
|
||||||
if (voice != null) 'voice': voice,
|
if (voice != null) 'voice': voice,
|
||||||
'format': 'mp3',
|
'format': 'mp3',
|
||||||
if (language != null) 'language': language,
|
if (language != null) 'language': language,
|
||||||
|
if (instructions != null) 'instructions': instructions,
|
||||||
},
|
},
|
||||||
options: dio.Options(
|
options: dio.Options(
|
||||||
responseType: dio.ResponseType.bytes,
|
responseType: dio.ResponseType.bytes,
|
||||||
|
|
|
||||||
|
|
@ -67,7 +67,38 @@ class MinioService {
|
||||||
required String contentType,
|
required String contentType,
|
||||||
String? objectId,
|
String? objectId,
|
||||||
}) async {
|
}) async {
|
||||||
final id = objectId ?? _uuid.v4();
|
var id = objectId ?? _uuid.v4();
|
||||||
|
|
||||||
|
// Add file extension based on content type if not already present
|
||||||
|
if (!id.contains('.')) {
|
||||||
|
if (contentType.startsWith('audio/')) {
|
||||||
|
// Determine extension from content type
|
||||||
|
String extension = '.mp3'; // default for audio
|
||||||
|
if (contentType == 'audio/mpeg' || contentType == 'audio/mp3') {
|
||||||
|
extension = '.mp3';
|
||||||
|
} else if (contentType == 'audio/wav') {
|
||||||
|
extension = '.wav';
|
||||||
|
} else if (contentType == 'audio/ogg') {
|
||||||
|
extension = '.ogg';
|
||||||
|
} else if (contentType == 'audio/flac') {
|
||||||
|
extension = '.flac';
|
||||||
|
}
|
||||||
|
id = '$id$extension';
|
||||||
|
} else if (contentType.startsWith('image/')) {
|
||||||
|
// Determine extension from content type
|
||||||
|
String extension = '.png'; // default for images
|
||||||
|
if (contentType == 'image/jpeg' || contentType == 'image/jpg') {
|
||||||
|
extension = '.jpg';
|
||||||
|
} else if (contentType == 'image/png') {
|
||||||
|
extension = '.png';
|
||||||
|
} else if (contentType == 'image/webp') {
|
||||||
|
extension = '.webp';
|
||||||
|
} else if (contentType == 'image/gif') {
|
||||||
|
extension = '.gif';
|
||||||
|
}
|
||||||
|
id = '$id$extension';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Определяем Cache-Control в зависимости от типа файла
|
// Определяем Cache-Control в зависимости от типа файла
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue