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.
|
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
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
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 */}
|
{/* 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>
|
||||||
|
|
|
||||||
|
|
@ -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,6 +146,28 @@ 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 && (
|
||||||
|
<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
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
|
@ -105,6 +178,7 @@ export function CardVoicesManager({ cardId, disabled = false }: CardVoicesManage
|
||||||
<Plus className="h-4 w-4 mr-2" />
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
Add Voice
|
Add Voice
|
||||||
</Button>
|
</Button>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue