mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-15 00:01:10 +02:00
Move inactive projects out of active workspace: - bauntown (community website) - maerchenzauber (AI story generation) - memoro (voice memo app) - news (news aggregation) - nutriphi (nutrition tracking) - reader (reading app) - uload (URL shortener) - wisekeep (AI wisdom extraction) Update CLAUDE.md documentation: - Add presi to active projects - Document archived projects section - Update workspace configuration Archived apps can be re-activated by moving back to apps/ 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
339 lines
8.9 KiB
TypeScript
339 lines
8.9 KiB
TypeScript
import { supabase } from '~/utils/supabase';
|
|
import * as FileSystem from 'expo-file-system';
|
|
import { Audio } from 'expo-av';
|
|
import { AudioChunk } from '~/types/database';
|
|
import { getVoiceById } from '~/constants/voices';
|
|
|
|
const AUDIO_DIR = `${FileSystem.documentDirectory}audio/`;
|
|
|
|
export interface AudioGenerationProgress {
|
|
chunksCompleted: number;
|
|
totalChunks: number;
|
|
currentChunk: string;
|
|
isComplete: boolean;
|
|
}
|
|
|
|
export class AudioService {
|
|
private static instance: AudioService;
|
|
private supabase = supabase;
|
|
|
|
public static getInstance(): AudioService {
|
|
if (!AudioService.instance) {
|
|
AudioService.instance = new AudioService();
|
|
}
|
|
return AudioService.instance;
|
|
}
|
|
|
|
private constructor() {
|
|
this.initializeAudioDirectory();
|
|
}
|
|
|
|
private async initializeAudioDirectory(): Promise<void> {
|
|
try {
|
|
await FileSystem.makeDirectoryAsync(AUDIO_DIR, { intermediates: true });
|
|
} catch {
|
|
// Directory might already exist
|
|
}
|
|
}
|
|
|
|
// Generate audio for a text using Supabase Edge Function
|
|
async generateAudioForText(
|
|
textId: string,
|
|
content: string,
|
|
voice: string = 'de-DE',
|
|
speed: number = 1.0,
|
|
chunkSize: number = 1000,
|
|
onProgress?: (progress: AudioGenerationProgress) => void,
|
|
versionId?: string
|
|
): Promise<{ success: boolean; error?: string; chunks?: AudioChunk[] }> {
|
|
try {
|
|
// Estimate number of chunks for progress tracking
|
|
const estimatedChunks = Math.ceil(content.length / chunkSize);
|
|
|
|
onProgress?.({
|
|
chunksCompleted: 0,
|
|
totalChunks: estimatedChunks,
|
|
currentChunk: 'Starting generation...',
|
|
isComplete: false,
|
|
});
|
|
|
|
// Determine which provider to use based on the voice
|
|
let provider = 'google';
|
|
|
|
try {
|
|
const voiceInfo = getVoiceById(voice);
|
|
if (voiceInfo) {
|
|
provider = voiceInfo.provider;
|
|
} else {
|
|
console.warn(`Voice not found: ${voice}, defaulting to Google provider`);
|
|
}
|
|
} catch (error) {
|
|
console.error('Error getting voice info:', error);
|
|
// Continue with default Google provider
|
|
}
|
|
|
|
const { data, error } = await supabase.functions.invoke('generate-audio', {
|
|
body: {
|
|
textId,
|
|
content,
|
|
voice,
|
|
provider,
|
|
speed,
|
|
chunkSize,
|
|
versionId,
|
|
},
|
|
});
|
|
|
|
if (error) {
|
|
throw error;
|
|
}
|
|
|
|
if (!data.success) {
|
|
throw new Error(data.error || 'Failed to generate audio');
|
|
}
|
|
|
|
onProgress?.({
|
|
chunksCompleted: data.chunksGenerated,
|
|
totalChunks: data.chunksGenerated,
|
|
currentChunk: 'Audio generation complete!',
|
|
isComplete: true,
|
|
});
|
|
|
|
return {
|
|
success: true,
|
|
chunks: data.chunks,
|
|
};
|
|
} catch (error) {
|
|
console.error('Error generating audio:', error);
|
|
return {
|
|
success: false,
|
|
error: error instanceof Error ? error.message : 'Unknown error',
|
|
};
|
|
}
|
|
}
|
|
|
|
// Download audio chunks to local storage
|
|
async downloadAudioChunks(
|
|
textId: string,
|
|
chunks: AudioChunk[],
|
|
onProgress?: (progress: { completed: number; total: number; currentChunk: string }) => void
|
|
): Promise<{ success: boolean; error?: string; localChunks?: AudioChunk[] }> {
|
|
try {
|
|
const localChunks: AudioChunk[] = [];
|
|
|
|
for (let i = 0; i < chunks.length; i++) {
|
|
const chunk = chunks[i];
|
|
|
|
onProgress?.({
|
|
completed: i,
|
|
total: chunks.length,
|
|
currentChunk: chunk.id,
|
|
});
|
|
|
|
// Get signed URL for the chunk
|
|
const { data: urlData, error: urlError } = await supabase.functions.invoke(
|
|
'get-audio-url',
|
|
{
|
|
body: {
|
|
textId,
|
|
chunkId: chunk.id,
|
|
},
|
|
}
|
|
);
|
|
|
|
if (urlError || !urlData.success) {
|
|
throw new Error(`Failed to get URL for chunk ${chunk.id}`);
|
|
}
|
|
|
|
// Download the audio file
|
|
const localFilePath = `${AUDIO_DIR}${textId}_${chunk.id}.mp3`;
|
|
const downloadResult = await FileSystem.downloadAsync(urlData.url, localFilePath);
|
|
|
|
if (downloadResult.status !== 200) {
|
|
throw new Error(`Failed to download chunk ${chunk.id}`);
|
|
}
|
|
|
|
// Get file info
|
|
const fileInfo = await FileSystem.getInfoAsync(localFilePath);
|
|
|
|
localChunks.push({
|
|
...chunk,
|
|
filename: `${textId}_${chunk.id}.mp3`,
|
|
size: fileInfo.exists && 'size' in fileInfo ? fileInfo.size : chunk.size,
|
|
});
|
|
}
|
|
|
|
onProgress?.({
|
|
completed: chunks.length,
|
|
total: chunks.length,
|
|
currentChunk: 'Download complete!',
|
|
});
|
|
|
|
return {
|
|
success: true,
|
|
localChunks,
|
|
};
|
|
} catch (error) {
|
|
console.error('Error downloading audio chunks:', error);
|
|
return {
|
|
success: false,
|
|
error: error instanceof Error ? error.message : 'Unknown error',
|
|
};
|
|
}
|
|
}
|
|
|
|
// Play audio directly from Supabase Storage
|
|
async playAudioFromSupabase(
|
|
textId: string,
|
|
chunks: AudioChunk[],
|
|
startPosition: number = 0
|
|
): Promise<{ sound?: Audio.Sound; chunk?: AudioChunk; chunks?: AudioChunk[]; error?: string }> {
|
|
try {
|
|
// Calculate chunk positions if not already set
|
|
let currentPosition = 0;
|
|
const chunksWithPositions = chunks.map((chunk) => {
|
|
const chunkStart = currentPosition;
|
|
const chunkEnd = currentPosition + chunk.duration * 1000; // Convert to milliseconds
|
|
currentPosition = chunkEnd;
|
|
return {
|
|
...chunk,
|
|
start: chunk.start ?? chunkStart,
|
|
end: chunk.end ?? chunkEnd,
|
|
};
|
|
});
|
|
|
|
// Find the chunk that contains the start position
|
|
const chunk =
|
|
chunksWithPositions.find((c) => startPosition >= c.start && startPosition < c.end) ||
|
|
chunksWithPositions[0]; // Default to first chunk if position not found
|
|
|
|
if (!chunk) {
|
|
throw new Error('No chunk found for the given position');
|
|
}
|
|
|
|
// Get signed URL for the audio chunk
|
|
const { data: urlData, error: urlError } = await this.supabase.functions.invoke(
|
|
'get-audio-url',
|
|
{
|
|
body: {
|
|
textId,
|
|
chunkId: chunk.id,
|
|
},
|
|
}
|
|
);
|
|
|
|
if (urlError || !urlData.success) {
|
|
throw new Error(`Failed to get audio URL: ${urlError?.message || 'Unknown error'}`);
|
|
}
|
|
|
|
// Create and load the audio from signed URL
|
|
const { sound } = await Audio.Sound.createAsync({ uri: urlData.url });
|
|
|
|
// Calculate position within the chunk
|
|
const positionWithinChunk = Math.max(0, startPosition - chunk.start);
|
|
await sound.setPositionAsync(positionWithinChunk);
|
|
|
|
return { sound, chunk, chunks: chunksWithPositions };
|
|
} catch (error) {
|
|
console.error('Error playing audio from Supabase:', error);
|
|
return {
|
|
error: error instanceof Error ? error.message : 'Unknown error',
|
|
};
|
|
}
|
|
}
|
|
|
|
// Play audio from local cache (kept for backward compatibility)
|
|
async playAudioFromCache(
|
|
textId: string,
|
|
chunks: AudioChunk[],
|
|
startPosition: number = 0
|
|
): Promise<{ sound?: Audio.Sound; error?: string }> {
|
|
try {
|
|
// Find the chunk that contains the start position
|
|
const chunk = chunks.find((c) => startPosition >= c.start && startPosition < c.end);
|
|
|
|
if (!chunk) {
|
|
throw new Error('No chunk found for the given position');
|
|
}
|
|
|
|
const filePath = `${AUDIO_DIR}${chunk.filename}`;
|
|
const fileInfo = await FileSystem.getInfoAsync(filePath);
|
|
|
|
if (!fileInfo.exists) {
|
|
throw new Error('Audio file not found locally');
|
|
}
|
|
|
|
// Create and load the audio
|
|
const { sound } = await Audio.Sound.createAsync({ uri: filePath });
|
|
|
|
// Calculate position within the chunk
|
|
const chunkProgress = (startPosition - chunk.start) / (chunk.end - chunk.start);
|
|
const positionMillis = chunkProgress * chunk.duration * 1000;
|
|
|
|
await sound.setPositionAsync(positionMillis);
|
|
|
|
return { sound };
|
|
} catch (error) {
|
|
console.error('Error playing audio from cache:', error);
|
|
return {
|
|
error: error instanceof Error ? error.message : 'Unknown error',
|
|
};
|
|
}
|
|
}
|
|
|
|
// Clear local audio cache for a text
|
|
async clearAudioCache(textId: string, chunks: AudioChunk[]): Promise<void> {
|
|
try {
|
|
for (const chunk of chunks) {
|
|
const filePath = `${AUDIO_DIR}${chunk.filename}`;
|
|
try {
|
|
await FileSystem.deleteAsync(filePath);
|
|
} catch (deleteError) {
|
|
console.log(`Could not delete ${chunk.filename}:`, deleteError);
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error('Error clearing audio cache:', error);
|
|
}
|
|
}
|
|
|
|
// Get total cache size
|
|
async getCacheSize(): Promise<number> {
|
|
try {
|
|
const files = await FileSystem.readDirectoryAsync(AUDIO_DIR);
|
|
let totalSize = 0;
|
|
|
|
for (const file of files) {
|
|
const fileInfo = await FileSystem.getInfoAsync(`${AUDIO_DIR}${file}`);
|
|
totalSize += fileInfo.exists && 'size' in fileInfo ? fileInfo.size : 0;
|
|
}
|
|
|
|
return totalSize;
|
|
} catch (error) {
|
|
console.error('Error calculating cache size:', error);
|
|
return 0;
|
|
}
|
|
}
|
|
|
|
// Check if audio is cached locally
|
|
async isAudioCached(textId: string, chunks: AudioChunk[]): Promise<boolean> {
|
|
try {
|
|
for (const chunk of chunks) {
|
|
const filePath = `${AUDIO_DIR}${chunk.filename}`;
|
|
const fileInfo = await FileSystem.getInfoAsync(filePath);
|
|
|
|
if (!fileInfo.exists) {
|
|
return false;
|
|
}
|
|
}
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// Get file path for a chunk
|
|
getChunkFilePath(textId: string, chunkId: string): string {
|
|
return `${AUDIO_DIR}${textId}_${chunkId}.mp3`;
|
|
}
|
|
}
|