diff --git a/CLAUDE.md b/CLAUDE.md index d0111e4eb..dbdaf663c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -58,6 +58,7 @@ For comprehensive guidelines on code patterns and conventions, see the `.claude/ | **uload** | URL shortener & link management | Server, Web, Landing | | **news** | AI news reader & personal library | Server, Web, Landing | | **wisekeep** | AI transcription & wisdom library | Server, Web, Landing | +| **reader** | Text-to-Speech with offline audio | Mobile | | **calc** | Calculator & converter | Web | | **playground** | LLM playground | Web | diff --git a/apps-archived/reader/apps/mobile/ReadMe/MinimalDatabase.md b/apps-archived/reader/apps/mobile/ReadMe/MinimalDatabase.md deleted file mode 100644 index a566b8c70..000000000 --- a/apps-archived/reader/apps/mobile/ReadMe/MinimalDatabase.md +++ /dev/null @@ -1,570 +0,0 @@ -# Absolut Minimalste Text-to-Speech Datenbank - -## Philosophie -Eine einzige Tabelle für alles. JSONB macht's möglich. Keine Joins, keine Komplexität, nur pure Funktionalität. - -## Die Eine Tabelle - -```sql --- Die einzige Tabelle die du brauchst -CREATE TABLE texts ( - id UUID DEFAULT uuid_generate_v4() PRIMARY KEY, - user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE, - - -- Der eigentliche Content - title TEXT NOT NULL, - content TEXT NOT NULL, - - -- ALLES andere in einem JSONB Feld - data JSONB DEFAULT '{}' NOT NULL, - - -- Nur die absolut nötigen Timestamps - created_at TIMESTAMPTZ DEFAULT NOW(), - updated_at TIMESTAMPTZ DEFAULT NOW() -); - --- Ein Index für Performance -CREATE INDEX idx_texts_user ON texts(user_id); -CREATE INDEX idx_texts_data ON texts USING GIN (data); - --- RLS aktivieren -ALTER TABLE texts ENABLE ROW LEVEL SECURITY; - --- Jeder sieht nur seine eigenen Texte -CREATE POLICY "Own texts only" ON texts - FOR ALL USING (auth.uid() = user_id); - --- Update Timestamp Trigger -CREATE OR REPLACE FUNCTION update_updated_at() -RETURNS TRIGGER AS $$ -BEGIN - NEW.updated_at = NOW(); - RETURN NEW; -END; -$$ LANGUAGE plpgsql; - -CREATE TRIGGER update_texts_updated_at - BEFORE UPDATE ON texts - FOR EACH ROW - EXECUTE FUNCTION update_updated_at(); -``` - -## Was kommt ins `data` JSONB Feld? - -```javascript -// Beispiel eines vollständigen Text-Objekts -{ - id: "uuid-hier", - user_id: "user-uuid", - title: "Mein Buch", - content: "Der eigentliche Text...", - data: { - // Vorlese-Einstellungen - tts: { - speed: 1.0, - voice: "de-DE", - lastPosition: 1234, // Zeichen-Position - lastPlayed: "2024-01-15T10:30:00Z" - }, - - // Audio-Cache (NEU!) - audio: { - hasLocalCache: false, - chunks: [ - { - id: "chunk-1", - start: 0, - end: 1000, // Zeichen-Position - filename: "text-uuid-chunk-1.mp3", - size: 245760, // Bytes - duration: 120, // Sekunden - createdAt: "2024-01-15T10:00:00Z" - } - ], - totalSize: 2457600, // Total in Bytes - lastGenerated: "2024-01-15T10:00:00Z", - settings: { // Settings bei Generierung - voice: "de-DE", - speed: 1.0 - } - }, - - // Organisation (optional) - tags: ["roman", "favorit"], - color: "#FF5733", - - // Statistiken (optional) - stats: { - playCount: 5, - totalTime: 3600, // Sekunden - completed: false - }, - - // Was auch immer du später brauchst - notes: "Für die Zugfahrt", - source: "kindle-import" - }, - created_at: "2024-01-01T10:00:00Z", - updated_at: "2024-01-15T10:30:00Z" -} -``` - -## Basis-Operationen - -### Text erstellen -```javascript -const { data, error } = await supabase - .from('texts') - .insert({ - title: 'Mein Text', - content: 'Inhalt hier...', - data: { - tts: { speed: 1.0, voice: 'de-DE' }, - tags: ['neu'] - } - }); -``` - -### Alle Texte holen -```javascript -const { data: texts } = await supabase - .from('texts') - .select('*') - .order('updated_at', { ascending: false }); -``` - -### Nach Tags filtern -```javascript -const { data: filtered } = await supabase - .from('texts') - .select('*') - .contains('data', { tags: ['favorit'] }); -``` - -### Leseposition updaten -```javascript -const { error } = await supabase - .from('texts') - .update({ - data: { - ...currentData, - tts: { - ...currentData.tts, - lastPosition: 5678, - lastPlayed: new Date().toISOString() - } - } - }) - .eq('id', textId); -``` - -### Statistiken hochzählen -```sql --- Als Postgres Funktion für atomare Updates -CREATE OR REPLACE FUNCTION increment_play_count(text_id UUID) -RETURNS void AS $$ -BEGIN - UPDATE texts - SET data = jsonb_set( - jsonb_set( - data, - '{stats,playCount}', - to_jsonb(COALESCE((data->'stats'->>'playCount')::int, 0) + 1) - ), - '{tts,lastPlayed}', - to_jsonb(NOW()) - ) - WHERE id = text_id; -END; -$$ LANGUAGE plpgsql; - --- Aufruf -SELECT increment_play_count('text-uuid-hier'); -``` - -## Supabase Quickstart - -```bash -# 1. Supabase CLI installieren -npm install -g supabase - -# 2. Projekt initialisieren -supabase init - -# 3. Migration erstellen -supabase migration new create_texts_table - -# 4. SQL von oben in die Migration kopieren - -# 5. Migration ausführen -supabase db push -``` - -## React Native Integration - -```javascript -// hooks/useTexts.js -import { useState, useEffect } from 'react'; -import { supabase } from '../lib/supabase'; - -export const useTexts = () => { - const [texts, setTexts] = useState([]); - const [loading, setLoading] = useState(true); - - useEffect(() => { - fetchTexts(); - }, []); - - const fetchTexts = async () => { - const { data } = await supabase - .from('texts') - .select('*') - .order('updated_at', { ascending: false }); - - setTexts(data || []); - setLoading(false); - }; - - const createText = async (title, content) => { - const { data, error } = await supabase - .from('texts') - .insert({ - title, - content, - data: { tts: { speed: 1.0, voice: 'de-DE' } } - }) - .select() - .single(); - - if (data) { - setTexts([data, ...texts]); - } - return { data, error }; - }; - - const updatePosition = async (textId, position) => { - const text = texts.find(t => t.id === textId); - if (!text) return; - - await supabase - .from('texts') - .update({ - data: { - ...text.data, - tts: { - ...text.data.tts, - lastPosition: position, - lastPlayed: new Date().toISOString() - } - } - }) - .eq('id', textId); - }; - - return { texts, loading, createText, updatePosition, refetch: fetchTexts }; -}; -``` - -## Audio-Cache Management - -```javascript -// hooks/useAudioCache.js -import * as FileSystem from 'expo-file-system'; -import * as Speech from 'expo-speech'; -import { Audio } from 'expo-av'; -import { supabase } from '../lib/supabase'; - -const AUDIO_DIR = `${FileSystem.documentDirectory}audio/`; - -export const useAudioCache = () => { - // Verzeichnis erstellen beim Start - useEffect(() => { - FileSystem.makeDirectoryAsync(AUDIO_DIR, { intermediates: true }) - .catch(() => {}); // Ignorieren wenn bereits existiert - }, []); - - // Text in Chunks aufteilen (z.B. alle 1000 Zeichen) - const chunkText = (text, chunkSize = 1000) => { - const chunks = []; - for (let i = 0; i < text.length; i += chunkSize) { - chunks.push({ - id: `chunk-${chunks.length}`, - start: i, - end: Math.min(i + chunkSize, text.length), - content: text.slice(i, i + chunkSize) - }); - } - return chunks; - }; - - // Audio für einen Chunk generieren und speichern - const generateAudioChunk = async (textId, chunk, settings) => { - const filename = `${textId}-${chunk.id}.mp3`; - const filePath = `${AUDIO_DIR}${filename}`; - - // Option 1: Mit einer TTS API (z.B. Google Cloud TTS) - // const audioData = await callTTSAPI(chunk.content, settings); - // await FileSystem.writeAsStringAsync(filePath, audioData, { - // encoding: FileSystem.EncodingType.Base64 - // }); - - // Option 2: Workaround mit expo-speech (keine direkte MP3 Generierung) - // Hinweis: expo-speech kann nicht direkt als Datei speichern - // Alternative: Web-API oder Cloud-Service nutzen - - const fileInfo = await FileSystem.getInfoAsync(filePath); - - return { - id: chunk.id, - start: chunk.start, - end: chunk.end, - filename, - size: fileInfo.size || 0, - duration: Math.ceil(chunk.content.length / 150) * 60, // Geschätzt - createdAt: new Date().toISOString() - }; - }; - - // Alle Chunks für einen Text generieren - const generateAudioForText = async (textId, content, settings = {}) => { - const chunks = chunkText(content); - const audioChunks = []; - - for (const chunk of chunks) { - const audioChunk = await generateAudioChunk(textId, chunk, settings); - audioChunks.push(audioChunk); - } - - // Metadaten in Supabase updaten - await updateAudioMetadata(textId, audioChunks, settings); - - return audioChunks; - }; - - // Audio-Metadaten in Supabase speichern - const updateAudioMetadata = async (textId, chunks, settings) => { - const totalSize = chunks.reduce((sum, chunk) => sum + chunk.size, 0); - - const { data: currentText } = await supabase - .from('texts') - .select('data') - .eq('id', textId) - .single(); - - await supabase - .from('texts') - .update({ - data: { - ...currentText.data, - audio: { - hasLocalCache: true, - chunks, - totalSize, - lastGenerated: new Date().toISOString(), - settings - } - } - }) - .eq('id', textId); - }; - - // Audio abspielen - const playAudioFromCache = async (textId, startPosition = 0) => { - const { data: text } = await supabase - .from('texts') - .select('data') - .eq('id', textId) - .single(); - - if (!text?.data?.audio?.hasLocalCache) { - throw new Error('Kein Audio-Cache vorhanden'); - } - - // Richtigen Chunk finden - const chunk = text.data.audio.chunks.find( - c => startPosition >= c.start && startPosition < c.end - ); - - if (!chunk) return; - - const filePath = `${AUDIO_DIR}${chunk.filename}`; - const { sound } = await Audio.Sound.createAsync({ uri: filePath }); - - // Position innerhalb des Chunks berechnen - const chunkPosition = startPosition - chunk.start; - const positionMillis = (chunkPosition / chunk.end) * chunk.duration * 1000; - - await sound.setPositionAsync(positionMillis); - await sound.playAsync(); - - return sound; - }; - - // Cache löschen - const clearAudioCache = async (textId) => { - const { data: text } = await supabase - .from('texts') - .select('data') - .eq('id', textId) - .single(); - - if (text?.data?.audio?.chunks) { - for (const chunk of text.data.audio.chunks) { - try { - await FileSystem.deleteAsync(`${AUDIO_DIR}${chunk.filename}`); - } catch (e) { - console.log('Fehler beim Löschen:', e); - } - } - } - - // Metadaten updaten - await supabase - .from('texts') - .update({ - data: { - ...text.data, - audio: { - hasLocalCache: false, - chunks: [], - totalSize: 0 - } - } - }) - .eq('id', textId); - }; - - // Cache-Größe berechnen - const getCacheSize = async () => { - const files = await FileSystem.readDirectoryAsync(AUDIO_DIR); - let totalSize = 0; - - for (const file of files) { - const info = await FileSystem.getInfoAsync(`${AUDIO_DIR}${file}`); - totalSize += info.size || 0; - } - - return totalSize; - }; - - return { - generateAudioForText, - playAudioFromCache, - clearAudioCache, - getCacheSize - }; -}; -``` - -## Beispiel-Screen für Audio-Management - -```javascript -// screens/TextDetailScreen.js -import React, { useState } from 'react'; -import { View, Text, Button, ActivityIndicator } from 'react-native'; -import { useAudioCache } from '../hooks/useAudioCache'; - -export const TextDetailScreen = ({ route }) => { - const { text } = route.params; - const { generateAudioForText, playAudioFromCache, clearAudioCache } = useAudioCache(); - const [generating, setGenerating] = useState(false); - - const hasCache = text.data?.audio?.hasLocalCache; - - const handleGenerateAudio = async () => { - setGenerating(true); - try { - await generateAudioForText(text.id, text.content, { - voice: text.data?.tts?.voice || 'de-DE', - speed: text.data?.tts?.speed || 1.0 - }); - // Text-Objekt neu laden - } catch (error) { - console.error('Fehler beim Generieren:', error); - } finally { - setGenerating(false); - } - }; - - const handlePlay = async () => { - try { - const position = text.data?.tts?.lastPosition || 0; - await playAudioFromCache(text.id, position); - } catch (error) { - // Fallback zu expo-speech - Speech.speak(text.content.slice(position), { - language: text.data?.tts?.voice || 'de-DE', - rate: text.data?.tts?.speed || 1.0 - }); - } - }; - - return ( - - {text.title} - - {!hasCache && ( -