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 voice Voice identifier (optional, defaults to 'alloy')
|
||||
* @param language Language code (optional)
|
||||
* @param instructions Instructions for speech generation (optional)
|
||||
* @returns Object ID and presigned URL
|
||||
*/
|
||||
async generateTTS(
|
||||
text: string,
|
||||
voice?: string,
|
||||
language?: string
|
||||
language?: string,
|
||||
instructions?: string
|
||||
): Promise<GenerateTTSResponse> {
|
||||
const response = await adminApiClient.post<GenerateTTSResponse>(
|
||||
'/api/v2/media/generate-tts',
|
||||
|
|
@ -25,6 +27,7 @@ export const voiceServiceApi = {
|
|||
text,
|
||||
voice: voice || 'alloy',
|
||||
...(language && { language }),
|
||||
...(instructions && { instructions }),
|
||||
}
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -5,7 +5,17 @@ import { voicesApi, type VoiceDto } from '@/api/voices'
|
|||
import { AudioUpload } from '@/components/ui/audio-upload'
|
||||
import { Button } from '@/components/ui/button'
|
||||
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 {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { X, Plus, Music, Play, Pause, Volume2, Loader2 } from 'lucide-react'
|
||||
import type { AxiosError } from 'axios'
|
||||
import { voiceServiceApi } from '@/api/voiceService'
|
||||
|
|
@ -25,8 +35,16 @@ export function CardVoicesManager({
|
|||
const [showAddForm, setShowAddForm] = useState(false)
|
||||
const [newAudio, setNewAudio] = useState<string | undefined>(undefined)
|
||||
const [newLanguage, setNewLanguage] = useState('es')
|
||||
const [newInstructions, setNewInstructions] = useState('')
|
||||
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
|
||||
const { data: voicesData, isLoading, refetch } = useQuery({
|
||||
queryKey: ['cardVoices', cardId],
|
||||
|
|
@ -66,6 +84,7 @@ export function CardVoicesManager({
|
|||
setShowAddForm(false)
|
||||
setNewAudio(undefined)
|
||||
setNewLanguage('es')
|
||||
setNewInstructions('')
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
console.error('❌ CardVoicesManager: addVoiceMutation.onError', error)
|
||||
|
|
@ -95,30 +114,46 @@ export function CardVoicesManager({
|
|||
}
|
||||
}
|
||||
|
||||
// Generate TTS audio for card
|
||||
const handleGenerateTTS = async () => {
|
||||
// Open TTS generation dialog
|
||||
const handleOpenTTSDialog = () => {
|
||||
if (!cardOriginal || !cardOriginal.trim()) {
|
||||
toast.error('Card text is required for TTS generation')
|
||||
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) {
|
||||
toast.error('Card ID is required')
|
||||
return
|
||||
}
|
||||
|
||||
setShowTTSDialog(false)
|
||||
setIsGeneratingTTS(true)
|
||||
try {
|
||||
const text = cardOriginal.trim()
|
||||
// Use default voice 'alloy' for TTS generation
|
||||
// Language (newLanguage) is used for both TTS generation and saving the voice to the card
|
||||
const voice = 'alloy'
|
||||
const text = dialogText.trim()
|
||||
const voice = dialogVoice || 'alloy'
|
||||
const language = dialogLanguage || 'es'
|
||||
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
|
||||
// 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', {
|
||||
objectId: ttsResponse.objectId
|
||||
})
|
||||
|
|
@ -126,7 +161,7 @@ export function CardVoicesManager({
|
|||
// Add voice to card using the objectId from backend
|
||||
await addVoiceMutation.mutateAsync({
|
||||
voiceUrl: ttsResponse.objectId,
|
||||
language: newLanguage || 'es',
|
||||
language: language,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('❌ CardVoicesManager: TTS generation failed', error)
|
||||
|
|
@ -152,7 +187,7 @@ export function CardVoicesManager({
|
|||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleGenerateTTS}
|
||||
onClick={handleOpenTTSDialog}
|
||||
disabled={disabled || isGeneratingTTS}
|
||||
>
|
||||
{isGeneratingTTS ? (
|
||||
|
|
@ -225,6 +260,7 @@ export function CardVoicesManager({
|
|||
setShowAddForm(false)
|
||||
setNewAudio(undefined)
|
||||
setNewLanguage('es')
|
||||
setNewInstructions('')
|
||||
}}
|
||||
disabled={disabled || addVoiceMutation.isPending}
|
||||
>
|
||||
|
|
@ -235,6 +271,88 @@ export function CardVoicesManager({
|
|||
</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 */}
|
||||
{isLoading ? (
|
||||
<div className="text-center py-4 text-sm text-muted-foreground">Loading voices...</div>
|
||||
|
|
|
|||
|
|
@ -272,19 +272,6 @@ export function AudioUpload({
|
|||
</>
|
||||
)}
|
||||
</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">
|
||||
{isUploading ? 'Uploading...' : 'Click to change audio file'}
|
||||
</p>
|
||||
|
|
@ -330,6 +317,21 @@ export function AudioUpload({
|
|||
</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>
|
||||
)
|
||||
}
|
||||
|
|
@ -510,13 +510,14 @@ class MediaApiV2 {
|
|||
final text = json['text'] as String?;
|
||||
final voice = json['voice'] as String?;
|
||||
final language = json['language'] as String?;
|
||||
final instructions = json['instructions'] as String?;
|
||||
|
||||
if (text == null || text.trim().isEmpty) {
|
||||
return _badRequest('Text is required');
|
||||
}
|
||||
|
||||
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
|
||||
|
|
@ -527,6 +528,7 @@ class MediaApiV2 {
|
|||
if (voice != null) 'voice': voice,
|
||||
'format': 'mp3',
|
||||
if (language != null) 'language': language,
|
||||
if (instructions != null) 'instructions': instructions,
|
||||
},
|
||||
options: dio.Options(
|
||||
responseType: dio.ResponseType.bytes,
|
||||
|
|
|
|||
|
|
@ -67,7 +67,38 @@ class MinioService {
|
|||
required String contentType,
|
||||
String? objectId,
|
||||
}) 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 {
|
||||
// Определяем Cache-Control в зависимости от типа файла
|
||||
|
|
|
|||
Loading…
Reference in a new issue