voice
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:
Dmitry 2026-01-24 19:01:36 +03:00
parent 2a4b3ecc4e
commit f665c75eeb
6 changed files with 326 additions and 20 deletions

View file

@ -2,6 +2,20 @@
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. 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: 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 - [@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

View file

@ -58,11 +58,15 @@ export const mediaApi = {
/** /**
* Upload voice audio file to MinIO * Upload voice audio file to MinIO
* @param file Audio file to upload * @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 * @returns Object ID and presigned URL
*/ */
async uploadVoice(file: File): Promise<UploadResponse> { async uploadVoice(file: File, objectId?: string): Promise<UploadResponse> {
const formData = new FormData() const formData = new FormData()
formData.append('file', file) formData.append('file', file)
if (objectId) {
formData.append('objectId', objectId)
}
const response = await adminApiClient.post<UploadResponse>( const response = await adminApiClient.post<UploadResponse>(
'/api/v2/media/upload/voice', '/api/v2/media/upload/voice',

View file

@ -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<GenerateTTSResponse> {
const response = await adminApiClient.post<GenerateTTSResponse>(
'/api/v2/media/generate-tts',
{
text,
voice: voice || 'alloy',
}
)
return response.data
},
}

View file

@ -349,7 +349,11 @@ export function CardEditorPreview({
{/* Voice Controls - only show if cardId exists */} {/* Voice Controls - only show if cardId exists */}
{cardId && ( {cardId && (
<div className="space-y-2"> <div className="space-y-2">
<CardVoicesManager cardId={cardId} disabled={disabled} /> <CardVoicesManager
cardId={cardId}
cardOriginal={formData.original}
disabled={disabled}
/>
</div> </div>
)} )}
</div> </div>

View file

@ -6,19 +6,26 @@ 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 { Badge } from '@/components/ui/badge' 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 type { AxiosError } from 'axios'
import { voiceServiceApi } from '@/api/voiceService'
interface CardVoicesManagerProps { interface CardVoicesManagerProps {
cardId: string cardId: string
cardOriginal?: string // Text from card's original field for TTS generation
disabled?: boolean disabled?: boolean
} }
export function CardVoicesManager({ cardId, disabled = false }: CardVoicesManagerProps) { export function CardVoicesManager({
cardId,
cardOriginal,
disabled = false
}: CardVoicesManagerProps) {
const queryClient = useQueryClient() const queryClient = useQueryClient()
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('en') const [newLanguage, setNewLanguage] = useState('en')
const [isGeneratingTTS, setIsGeneratingTTS] = useState(false)
// Load voices for the card // Load voices for the card
const { data: voicesData, isLoading, refetch } = useQuery({ 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 || [] const voices = voicesData?.items || []
return ( return (
@ -95,16 +146,39 @@ export function CardVoicesManager({ cardId, disabled = false }: CardVoicesManage
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<Label>Card Voices ({voices.length})</Label> <Label>Card Voices ({voices.length})</Label>
{!showAddForm && ( {!showAddForm && (
<Button <div className="flex items-center space-x-2">
type="button" {cardOriginal && cardOriginal.trim() && (
variant="outline" <Button
size="sm" type="button"
onClick={() => setShowAddForm(true)} variant="outline"
disabled={disabled} size="sm"
> onClick={handleGenerateTTS}
<Plus className="h-4 w-4 mr-2" /> disabled={disabled || isGeneratingTTS}
Add Voice >
</Button> {isGeneratingTTS ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Generating...
</>
) : (
<>
<Volume2 className="h-4 w-4 mr-2" />
Generate TTS
</>
)}
</Button>
)}
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setShowAddForm(true)}
disabled={disabled}
>
<Plus className="h-4 w-4 mr-2" />
Add Voice
</Button>
</div>
)} )}
</div> </div>

View file

@ -1,6 +1,7 @@
import 'dart:convert'; import 'dart:convert';
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:dio/dio.dart' as dio;
import 'package:injectable/injectable.dart'; import 'package:injectable/injectable.dart';
import 'package:mnemo_cards_backend/api/authorize/acl_types.dart'; import 'package:mnemo_cards_backend/api/authorize/acl_types.dart';
import 'package:mnemo_cards_backend/api/authorize/access_service.dart'; import 'package:mnemo_cards_backend/api/authorize/access_service.dart';
@ -18,6 +19,12 @@ part 'media_api_v2.g.dart';
@lazySingleton @lazySingleton
class MediaApiV2 { class MediaApiV2 {
final MinioService _minioService; 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); 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 = <List<int>>[];
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 = <List<int>>[];
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 /// POST /api/v2/media/upload/card-image
/// Upload card image to MinIO /// Upload card image to MinIO
@Route.post('/media/upload/card-image') @Route.post('/media/upload/card-image')
@ -242,6 +326,7 @@ class MediaApiV2 {
/// POST /api/v2/media/upload/voice /// POST /api/v2/media/upload/voice
/// Upload voice audio file to MinIO /// Upload voice audio file to MinIO
/// Supports optional objectId parameter for caching (hash-based)
@Route.post('/media/upload/voice') @Route.post('/media/upload/voice')
@OpenApiRouteHttp() @OpenApiRouteHttp()
Future<Response> uploadVoice(Request request) async { Future<Response> uploadVoice(Request request) async {
@ -252,8 +337,8 @@ class MediaApiV2 {
return authResponse; return authResponse;
} }
// Parse multipart file // Parse multipart file with optional objectId
final fileData = await _parseMultipartFile(request); final fileData = await _parseMultipartFileWithObjectId(request);
if (fileData == null) { if (fileData == null) {
return _badRequest('No file provided or invalid multipart format'); 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'); return _badRequest('File size exceeds maximum allowed size of 20MB');
} }
// Upload to MinIO // Validate objectId if provided (should be UUID or MD5 hash - 32 hex chars)
final objectId = await _minioService.uploadFile( 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, bucket: MinioConfig.voiceAudioBucket,
bytes: fileData.bytes, bytes: fileData.bytes,
contentType: fileData.contentType, contentType: fileData.contentType,
objectId: objectId,
); );
// Generate presigned URL for preview // Generate presigned URL for preview
final presignedUrl = await _minioService.getPresignedUrl( final presignedUrl = await _minioService.getPresignedUrl(
bucket: MinioConfig.voiceAudioBucket, bucket: MinioConfig.voiceAudioBucket,
objectId: objectId, objectId: finalObjectId,
); );
return _ok({'objectId': objectId, 'url': presignedUrl}); return _ok({'objectId': finalObjectId, 'url': presignedUrl});
} catch (e, s) { } catch (e, s) {
print('Error uploading voice: $e\n$s'); print('Error uploading voice: $e\n$s');
return _internalServerError('Failed to upload audio'); return _internalServerError('Failed to upload audio');
@ -386,4 +492,78 @@ class MediaApiV2 {
return _internalServerError('Failed to delete file'); 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<Response> 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<String, dynamic>;
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<Uint8List>(
'$_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');
}
}
} }