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
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
2a4b3ecc4e
commit
f665c75eeb
6 changed files with 326 additions and 20 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<UploadResponse> {
|
||||
async uploadVoice(file: File, objectId?: string): Promise<UploadResponse> {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
if (objectId) {
|
||||
formData.append('objectId', objectId)
|
||||
}
|
||||
|
||||
const response = await adminApiClient.post<UploadResponse>(
|
||||
'/api/v2/media/upload/voice',
|
||||
|
|
|
|||
30
mnemo_cards_admin/src/api/voiceService.ts
Normal file
30
mnemo_cards_admin/src/api/voiceService.ts
Normal 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
|
||||
},
|
||||
}
|
||||
|
|
@ -349,7 +349,11 @@ export function CardEditorPreview({
|
|||
{/* Voice Controls - only show if cardId exists */}
|
||||
{cardId && (
|
||||
<div className="space-y-2">
|
||||
<CardVoicesManager cardId={cardId} disabled={disabled} />
|
||||
<CardVoicesManager
|
||||
cardId={cardId}
|
||||
cardOriginal={formData.original}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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<string | undefined>(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
|
|||
<div className="flex items-center justify-between">
|
||||
<Label>Card Voices ({voices.length})</Label>
|
||||
{!showAddForm && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowAddForm(true)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Add Voice
|
||||
</Button>
|
||||
<div className="flex items-center space-x-2">
|
||||
{cardOriginal && cardOriginal.trim() && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleGenerateTTS}
|
||||
disabled={disabled || isGeneratingTTS}
|
||||
>
|
||||
{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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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 = <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
|
||||
/// 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<Response> 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<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');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue