diff --git a/mnemo_cards_admin/README.md b/mnemo_cards_admin/README.md index d2e7761..b821111 100644 --- a/mnemo_cards_admin/README.md +++ b/mnemo_cards_admin/README.md @@ -2,6 +2,20 @@ This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. +## Environment Variables + +The following environment variables can be configured: + +- `VITE_API_BASE_URL` - Base URL for the API (default: `https://api.mnemo-cards.online`) +- `VITE_VOICE_SERVICE_URL` - Base URL for the voice-service TTS API (default: `https://voice-service.mnemo-cards.online`) + +Create a `.env` file in the root directory to override these values: + +```env +VITE_API_BASE_URL=https://api.mnemo-cards.online +VITE_VOICE_SERVICE_URL=https://voice-service.mnemo-cards.online +``` + Currently, two official plugins are available: - [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh diff --git a/mnemo_cards_admin/src/api/media.ts b/mnemo_cards_admin/src/api/media.ts index 807aa7e..82bc1f6 100644 --- a/mnemo_cards_admin/src/api/media.ts +++ b/mnemo_cards_admin/src/api/media.ts @@ -58,11 +58,15 @@ export const mediaApi = { /** * Upload voice audio file to MinIO * @param file Audio file to upload + * @param objectId Optional object ID (hash) for caching. If provided, will use this as the object ID in MinIO * @returns Object ID and presigned URL */ - async uploadVoice(file: File): Promise { + async uploadVoice(file: File, objectId?: string): Promise { const formData = new FormData() formData.append('file', file) + if (objectId) { + formData.append('objectId', objectId) + } const response = await adminApiClient.post( '/api/v2/media/upload/voice', diff --git a/mnemo_cards_admin/src/api/voiceService.ts b/mnemo_cards_admin/src/api/voiceService.ts new file mode 100644 index 0000000..64cbf17 --- /dev/null +++ b/mnemo_cards_admin/src/api/voiceService.ts @@ -0,0 +1,30 @@ +import { adminApiClient } from './client' + +export interface GenerateTTSResponse { + objectId: string + url: string // Presigned URL for preview +} + +export const voiceServiceApi = { + /** + * Generate TTS audio from text via backend API + * Backend calls voice-service, which handles caching, and saves to MinIO + * @param text Text to convert to speech + * @param voice Voice identifier (optional, defaults to 'alloy') + * @returns Object ID and presigned URL + */ + async generateTTS( + text: string, + voice?: string + ): Promise { + const response = await adminApiClient.post( + '/api/v2/media/generate-tts', + { + text, + voice: voice || 'alloy', + } + ) + + return response.data + }, +} \ No newline at end of file diff --git a/mnemo_cards_admin/src/components/CardEditorPreview.tsx b/mnemo_cards_admin/src/components/CardEditorPreview.tsx index 34b38eb..8f76cfe 100644 --- a/mnemo_cards_admin/src/components/CardEditorPreview.tsx +++ b/mnemo_cards_admin/src/components/CardEditorPreview.tsx @@ -349,7 +349,11 @@ export function CardEditorPreview({ {/* Voice Controls - only show if cardId exists */} {cardId && (
- +
)} diff --git a/mnemo_cards_admin/src/components/CardVoicesManager.tsx b/mnemo_cards_admin/src/components/CardVoicesManager.tsx index f36a644..98a7a09 100644 --- a/mnemo_cards_admin/src/components/CardVoicesManager.tsx +++ b/mnemo_cards_admin/src/components/CardVoicesManager.tsx @@ -6,19 +6,26 @@ import { AudioUpload } from '@/components/ui/audio-upload' import { Button } from '@/components/ui/button' import { Label } from '@/components/ui/label' import { Badge } from '@/components/ui/badge' -import { X, Plus, Music, Play, Pause } from 'lucide-react' +import { X, Plus, Music, Play, Pause, Volume2, Loader2 } from 'lucide-react' import type { AxiosError } from 'axios' +import { voiceServiceApi } from '@/api/voiceService' interface CardVoicesManagerProps { cardId: string + cardOriginal?: string // Text from card's original field for TTS generation disabled?: boolean } -export function CardVoicesManager({ cardId, disabled = false }: CardVoicesManagerProps) { +export function CardVoicesManager({ + cardId, + cardOriginal, + disabled = false +}: CardVoicesManagerProps) { const queryClient = useQueryClient() const [showAddForm, setShowAddForm] = useState(false) const [newAudio, setNewAudio] = useState(undefined) const [newLanguage, setNewLanguage] = useState('en') + const [isGeneratingTTS, setIsGeneratingTTS] = useState(false) // Load voices for the card const { data: voicesData, isLoading, refetch } = useQuery({ @@ -88,6 +95,50 @@ export function CardVoicesManager({ cardId, disabled = false }: CardVoicesManage } } + // Generate TTS audio for card + const handleGenerateTTS = async () => { + if (!cardOriginal || !cardOriginal.trim()) { + toast.error('Card text is required for TTS generation') + return + } + + if (!cardId) { + toast.error('Card ID is required') + return + } + + setIsGeneratingTTS(true) + try { + const text = cardOriginal.trim() + // Use default voice 'alloy' for TTS generation + // Language (newLanguage) is used only when saving the voice to the card + const voice = 'alloy' + + console.log('🔄 CardVoicesManager: Requesting TTS generation', { text, voice }) + + // Generate TTS via backend API + // Backend calls voice-service (which handles caching) and saves to MinIO + const ttsResponse = await voiceServiceApi.generateTTS(text, voice) + console.log('✅ CardVoicesManager: TTS generated and saved to MinIO', { + objectId: ttsResponse.objectId + }) + + // Add voice to card using the objectId from backend + await addVoiceMutation.mutateAsync({ + voiceUrl: ttsResponse.objectId, + language: newLanguage || 'en', + }) + } catch (error) { + console.error('❌ CardVoicesManager: TTS generation failed', error) + const errorMessage = error instanceof Error + ? error.message + : 'Failed to generate TTS audio' + toast.error(`TTS generation failed: ${errorMessage}`) + } finally { + setIsGeneratingTTS(false) + } + } + const voices = voicesData?.items || [] return ( @@ -95,16 +146,39 @@ export function CardVoicesManager({ cardId, disabled = false }: CardVoicesManage
{!showAddForm && ( - +
+ {cardOriginal && cardOriginal.trim() && ( + + )} + +
)}
diff --git a/mnemo_cards_backend/lib/api/v2/media_api_v2.dart b/mnemo_cards_backend/lib/api/v2/media_api_v2.dart index b03de28..dd84f2e 100644 --- a/mnemo_cards_backend/lib/api/v2/media_api_v2.dart +++ b/mnemo_cards_backend/lib/api/v2/media_api_v2.dart @@ -1,6 +1,7 @@ import 'dart:convert'; import 'dart:typed_data'; +import 'package:dio/dio.dart' as dio; import 'package:injectable/injectable.dart'; import 'package:mnemo_cards_backend/api/authorize/acl_types.dart'; import 'package:mnemo_cards_backend/api/authorize/access_service.dart'; @@ -18,6 +19,12 @@ part 'media_api_v2.g.dart'; @lazySingleton class MediaApiV2 { final MinioService _minioService; + final dio.Dio _dio = dio.Dio(); + // Voice service URL from environment or default + final String _voiceServiceUrl = const String.fromEnvironment( + 'VOICE_SERVICE_URL', + defaultValue: 'https://voice-service.mnemo-cards.online', + ); MediaApiV2(this._minioService); @@ -117,6 +124,83 @@ class MediaApiV2 { } } + /// Parses multipart/form-data request and extracts file and optional objectId + Future<({ + Uint8List bytes, + String contentType, + String? filename, + String? objectId, + })?> _parseMultipartFileWithObjectId(Request request) async { + try { + final multipart = request.multipart(); + if (multipart == null) { + return null; + } + + Uint8List? fileBytes; + String? fileContentType; + String? filename; + String? objectId; + + await for (final part in multipart.parts) { + final contentDisposition = part.headers['content-disposition'] ?? ''; + + if (contentDisposition.contains('name="file"')) { + // Read file bytes + final chunks = >[]; + await for (final chunk in part) { + chunks.add(chunk); + } + fileBytes = Uint8List.fromList( + chunks.expand((chunk) => chunk).toList(), + ); + + // Get content type from part headers + fileContentType = + part.headers['content-type'] ?? 'application/octet-stream'; + + // Extract filename from content-disposition header + final filenameMatch = RegExp( + r'filename="?([^"]+)"?', + ).firstMatch(contentDisposition); + filename = filenameMatch?.group(1); + } else if (contentDisposition.contains('name="objectId"')) { + // Read objectId field + final chunks = >[]; + await for (final chunk in part) { + chunks.add(chunk); + } + final objectIdBytes = Uint8List.fromList( + chunks.expand((chunk) => chunk).toList(), + ); + objectId = utf8.decode(objectIdBytes).trim(); + if (objectId.isEmpty) { + objectId = null; + } + } else { + // Read and discard other parts to ensure full request is consumed + await for (final _ in part) { + // Discard chunks from other parts + } + } + } + + if (fileBytes == null) { + return null; + } + + return ( + bytes: fileBytes, + contentType: fileContentType!, + filename: filename, + objectId: objectId, + ); + } catch (e) { + print('Error parsing multipart with objectId: $e'); + return null; + } + } + /// POST /api/v2/media/upload/card-image /// Upload card image to MinIO @Route.post('/media/upload/card-image') @@ -242,6 +326,7 @@ class MediaApiV2 { /// POST /api/v2/media/upload/voice /// Upload voice audio file to MinIO + /// Supports optional objectId parameter for caching (hash-based) @Route.post('/media/upload/voice') @OpenApiRouteHttp() Future uploadVoice(Request request) async { @@ -252,8 +337,8 @@ class MediaApiV2 { return authResponse; } - // Parse multipart file - final fileData = await _parseMultipartFile(request); + // Parse multipart file with optional objectId + final fileData = await _parseMultipartFileWithObjectId(request); if (fileData == null) { return _badRequest('No file provided or invalid multipart format'); } @@ -278,20 +363,41 @@ class MediaApiV2 { return _badRequest('File size exceeds maximum allowed size of 20MB'); } - // Upload to MinIO - final objectId = await _minioService.uploadFile( + // Validate objectId if provided (should be UUID or MD5 hash - 32 hex chars) + String? objectId = fileData.objectId; + if (objectId != null) { + // Validate: UUID format (8-4-4-4-12) or MD5 hash (32 hex chars) + final isValidUuid = RegExp( + r'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$', + caseSensitive: false, + ).hasMatch(objectId); + final isValidHash = RegExp( + r'^[0-9a-f]{32}$', + caseSensitive: false, + ).hasMatch(objectId); + + if (!isValidUuid && !isValidHash) { + return _badRequest( + 'Invalid objectId format. Must be UUID or MD5 hash (32 hex characters)', + ); + } + } + + // Upload to MinIO with optional objectId + final finalObjectId = await _minioService.uploadFile( bucket: MinioConfig.voiceAudioBucket, bytes: fileData.bytes, contentType: fileData.contentType, + objectId: objectId, ); // Generate presigned URL for preview final presignedUrl = await _minioService.getPresignedUrl( bucket: MinioConfig.voiceAudioBucket, - objectId: objectId, + objectId: finalObjectId, ); - return _ok({'objectId': objectId, 'url': presignedUrl}); + return _ok({'objectId': finalObjectId, 'url': presignedUrl}); } catch (e, s) { print('Error uploading voice: $e\n$s'); return _internalServerError('Failed to upload audio'); @@ -386,4 +492,78 @@ class MediaApiV2 { return _internalServerError('Failed to delete file'); } } + + /// POST /api/v2/media/generate-tts + /// Generate TTS audio via voice-service and save to MinIO + /// Voice-service handles its own caching, we save the result to our MinIO + @Route.post('/media/generate-tts') + @OpenApiRouteHttp() + Future generateTTS(Request request) async { + try { + // Check admin rights + final authResponse = await _ensureAdmin(request); + if (authResponse.statusCode != 200) { + return authResponse; + } + + // Parse request body + final body = await request.readAsString(); + final json = jsonDecode(body) as Map; + final text = json['text'] as String?; + final voice = json['voice'] 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'}"', + ); + + // Call voice-service + final response = await _dio.post( + '$_voiceServiceUrl/api/voice/tts', + data: { + 'text': text, + if (voice != null) 'voice': voice, + 'format': 'mp3', + }, + options: dio.Options( + responseType: dio.ResponseType.bytes, + headers: {'Content-Type': 'application/json'}, + ), + ); + + if (response.statusCode != 200 || response.data == null) { + return _internalServerError( + 'Voice-service returned error: ${response.statusCode}', + ); + } + + final audioBytes = response.data!; + print( + '✅ MediaApiV2: Received TTS audio from voice-service: size=${audioBytes.length}', + ); + + // Save to MinIO (voice-service already cached it in its own storage) + final objectId = await _minioService.uploadFile( + bucket: MinioConfig.voiceAudioBucket, + bytes: audioBytes, + contentType: 'audio/mpeg', + ); + + // Generate presigned URL for preview + final presignedUrl = await _minioService.getPresignedUrl( + bucket: MinioConfig.voiceAudioBucket, + objectId: objectId, + ); + + print('✅ MediaApiV2: Saved TTS audio to MinIO: objectId=$objectId'); + + return _ok({'objectId': objectId, 'url': presignedUrl}); + } catch (e, s) { + print('❌ MediaApiV2: Error generating TTS: $e\n$s'); + return _internalServerError('Failed to generate TTS audio'); + } + } }