diff --git a/aurora-livecam/SettingsManager.php b/aurora-livecam/SettingsManager.php index 56313c1..ad9b88b 100644 --- a/aurora-livecam/SettingsManager.php +++ b/aurora-livecam/SettingsManager.php @@ -282,7 +282,7 @@ class SettingsManager { } public function isWeeklyTimelapseEnabled() { - return $this->get('zoom_timelapse.weekly_timelapse_enabled') !== true; + return $this->get('zoom_timelapse.weekly_timelapse_enabled') !== false; } // Auto-Screenshot Helper diff --git a/aurora-livecam/WeatherManager.php b/aurora-livecam/WeatherManager.php index 2a05ba7..66869ed 100644 --- a/aurora-livecam/WeatherManager.php +++ b/aurora-livecam/WeatherManager.php @@ -27,9 +27,24 @@ class WeatherManager { return $cached; } - // Hole frische Daten von API (Open-Meteo) $coords = $this->settingsManager->getWeatherCoords(); + $apiKey = trim($this->settingsManager->getWeatherApiKey()); + $weather = $apiKey !== '' + ? $this->fetchOpenWeather($coords, $apiKey) + : $this->fetchOpenMeteo($coords); + + if (isset($weather['error'])) { + return $weather; + } + + // Cache speichern + $this->saveCache($weather); + + return $weather; + } + + private function fetchOpenMeteo($coords) { // Open-Meteo API URL - komplett kostenlos, kein API Key! $url = "https://api.open-meteo.com/v1/forecast?" . http_build_query([ 'latitude' => $coords['lat'], @@ -38,17 +53,8 @@ class WeatherManager { 'timezone' => 'Europe/Zurich' ]); - $ch = curl_init(); - curl_setopt($ch, CURLOPT_URL, $url); - curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); - curl_setopt($ch, CURLOPT_TIMEOUT, 5); - curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true); - - $response = curl_exec($ch); - $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); - curl_close($ch); - - if ($httpCode !== 200 || !$response) { + $response = $this->fetchUrl($url); + if ($response === null) { return ['error' => 'API Fehler']; } @@ -59,8 +65,7 @@ class WeatherManager { $current = $data['current']; - // Formatiere Daten - $weather = [ + return [ 'temp' => round($current['temperature_2m'], 1), 'feels_like' => round($current['temperature_2m'], 1), // Open-Meteo hat keine "feels like" 'humidity' => $current['relative_humidity_2m'], @@ -76,11 +81,67 @@ class WeatherManager { 'location' => $this->settingsManager->getWeatherLocation(), 'timestamp' => time() ]; + } - // Cache speichern - $this->saveCache($weather); + private function fetchOpenWeather($coords, $apiKey) { + $units = $this->settingsManager->getWeatherUnits(); + $url = "https://api.openweathermap.org/data/2.5/weather?" . http_build_query([ + 'lat' => $coords['lat'], + 'lon' => $coords['lon'], + 'appid' => $apiKey, + 'units' => $units, + 'lang' => 'de' + ]); - return $weather; + $response = $this->fetchUrl($url); + if ($response === null) { + return ['error' => 'API Fehler']; + } + + $data = json_decode($response, true); + if (!$data || !isset($data['main'], $data['weather'][0], $data['wind'])) { + return ['error' => 'Ungültige API Antwort']; + } + + $windSpeed = $data['wind']['speed']; + if ($units === 'metric') { + $windSpeed = $windSpeed * 3.6; // m/s -> km/h + } + + return [ + 'temp' => round($data['main']['temp'], 1), + 'feels_like' => round($data['main']['feels_like'], 1), + 'humidity' => $data['main']['humidity'], + 'pressure' => round($data['main']['pressure'], 0), + 'wind_speed' => round($windSpeed, 1), + 'wind_deg' => $data['wind']['deg'] ?? 0, + 'wind_direction' => $this->getWindDirection($data['wind']['deg'] ?? 0), + 'clouds' => $data['clouds']['all'] ?? 0, + 'description' => ucfirst($data['weather'][0]['description']), + 'icon' => $data['weather'][0]['icon'] ?? '01d', + 'rain_1h' => $data['rain']['1h'] ?? 0, + 'snow_1h' => $data['snow']['1h'] ?? 0, + 'location' => $data['name'] ?? $this->settingsManager->getWeatherLocation(), + 'timestamp' => time() + ]; + } + + private function fetchUrl($url) { + $ch = curl_init(); + curl_setopt($ch, CURLOPT_URL, $url); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_TIMEOUT, 5); + curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true); + + $response = curl_exec($ch); + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + if ($httpCode !== 200 || !$response) { + return null; + } + + return $response; } /** diff --git a/aurora-livecam/api/auto-screenshot.php b/aurora-livecam/api/auto-screenshot.php new file mode 100644 index 0000000..406a399 --- /dev/null +++ b/aurora-livecam/api/auto-screenshot.php @@ -0,0 +1,104 @@ +isAutoScreenshotEnabled()) { + echo json_encode(['success' => false, 'error' => 'Auto-Screenshot deaktiviert']); + exit; +} + +// Optionale API-Key Validierung +$configFile = dirname(__DIR__) . '/config.php'; +if (file_exists($configFile)) { + $config = require $configFile; + $apiKey = $config['auto_screenshot_key'] ?? ''; + + if (!empty($apiKey) && ($_GET['key'] ?? '') !== $apiKey) { + http_response_code(403); + echo json_encode(['success' => false, 'error' => 'Ungültiger API-Key']); + exit; + } +} + +// Galerie-Verzeichnis erstellen +$galleryDir = dirname(__DIR__) . '/gallery/auto/'; +if (!is_dir($galleryDir)) { + mkdir($galleryDir, 0755, true); +} + +// Screenshot-Dateiname +$filename = 'auto_' . date('Y-m-d_H-i-s') . '.jpg'; +$filepath = $galleryDir . $filename; + +// Video-Stream URL +$streamUrl = 'test_video.m3u8'; +$logoPath = dirname(__DIR__) . '/logo.png'; + +// FFmpeg-Befehl zum Erstellen des Screenshots +$command = sprintf( + 'ffmpeg -i %s -vframes 1 -q:v 2 %s 2>&1', + escapeshellarg($streamUrl), + escapeshellarg($filepath) +); + +exec($command, $output, $returnVar); + +if ($returnVar !== 0 || !file_exists($filepath)) { + echo json_encode([ + 'success' => false, + 'error' => 'Screenshot fehlgeschlagen', + 'command' => $command, + 'output' => implode("\n", $output) + ]); + exit; +} + +// Alte Screenshots aufräumen (max. Anzahl einhalten) +$maxImages = $settingsManager->getAutoScreenshotMaxImages(); +$existingFiles = glob($galleryDir . 'auto_*.jpg'); +rsort($existingFiles); // Neueste zuerst + +if (count($existingFiles) > $maxImages) { + $filesToDelete = array_slice($existingFiles, $maxImages); + foreach ($filesToDelete as $file) { + @unlink($file); + } +} + +// Metadaten speichern +$metaFile = $galleryDir . 'metadata.json'; +$metadata = []; +if (file_exists($metaFile)) { + $metadata = json_decode(file_get_contents($metaFile), true) ?? []; +} + +$metadata[$filename] = [ + 'created_at' => date('Y-m-d H:i:s'), + 'timestamp' => time(), + 'size' => filesize($filepath) +]; + +// Nur die letzten maxImages behalten +$metadata = array_slice($metadata, -$maxImages, null, true); +file_put_contents($metaFile, json_encode($metadata, JSON_PRETTY_PRINT)); + +echo json_encode([ + 'success' => true, + 'file' => $filename, + 'path' => '/gallery/auto/' . $filename, + 'total_images' => count(glob($galleryDir . 'auto_*.jpg')) +]); diff --git a/aurora-livecam/api/gallery.php b/aurora-livecam/api/gallery.php new file mode 100644 index 0000000..4ecee5f --- /dev/null +++ b/aurora-livecam/api/gallery.php @@ -0,0 +1,97 @@ + true, 'images' => [], 'total' => 0]); + exit; +} + +// Parameter +$date = $_GET['date'] ?? null; +$from = $_GET['from'] ?? null; +$to = $_GET['to'] ?? null; +$limit = min(100, (int)($_GET['limit'] ?? 50)); +$offset = max(0, (int)($_GET['offset'] ?? 0)); + +// Alle Bilder holen +$allFiles = glob($galleryDir . 'auto_*.jpg'); +rsort($allFiles); // Neueste zuerst + +$images = []; + +foreach ($allFiles as $file) { + $filename = basename($file); + // Extrahiere Datum aus Dateinamen: auto_2024-01-30_14-30-00.jpg + if (preg_match('/auto_(\d{4}-\d{2}-\d{2})_(\d{2}-\d{2}-\d{2})\.jpg/', $filename, $matches)) { + $fileDate = $matches[1]; + $fileTime = str_replace('-', ':', $matches[2]); + + // Datumsfilter + if ($date !== null && $fileDate !== $date) { + continue; + } + + if ($from !== null && $fileDate < $from) { + continue; + } + + if ($to !== null && $fileDate > $to) { + continue; + } + + $images[] = [ + 'filename' => $filename, + 'path' => '/gallery/auto/' . $filename, + 'date' => $fileDate, + 'time' => $fileTime, + 'datetime' => $fileDate . ' ' . $fileTime, + 'timestamp' => strtotime($fileDate . ' ' . $fileTime), + 'size' => filesize($file) + ]; + } +} + +$total = count($images); + +// Pagination +$images = array_slice($images, $offset, $limit); + +// Verfügbare Daten (für Kalender/Filter) +$availableDates = []; +foreach (glob($galleryDir . 'auto_*.jpg') as $file) { + if (preg_match('/auto_(\d{4}-\d{2}-\d{2})/', basename($file), $m)) { + $availableDates[$m[1]] = ($availableDates[$m[1]] ?? 0) + 1; + } +} +krsort($availableDates); + +echo json_encode([ + 'success' => true, + 'images' => $images, + 'total' => $total, + 'offset' => $offset, + 'limit' => $limit, + 'available_dates' => $availableDates, + 'filters' => [ + 'date' => $date, + 'from' => $from, + 'to' => $to + ] +]); diff --git a/aurora-livecam/api/share.php b/aurora-livecam/api/share.php new file mode 100644 index 0000000..8591642 --- /dev/null +++ b/aurora-livecam/api/share.php @@ -0,0 +1,315 @@ +isEmailSharingEnabled()) { + echo json_encode(['success' => false, 'error' => 'E-Mail-Sharing ist deaktiviert']); + exit; +} + +// Config laden +$configFile = dirname(__DIR__) . '/config.php'; +$config = file_exists($configFile) ? require $configFile : []; +$mailConfig = $config['mail'] ?? []; + +if (empty($mailConfig['host']) || empty($mailConfig['username'])) { + echo json_encode(['success' => false, 'error' => 'E-Mail-Server nicht konfiguriert']); + exit; +} + +// === GET: Share-Link generieren === +if ($_SERVER['REQUEST_METHOD'] === 'GET' && isset($_GET['generate'])) { + $path = $_GET['path'] ?? ''; + $type = $_GET['type'] ?? 'video'; + + if (empty($path)) { + echo json_encode(['success' => false, 'error' => 'Kein Pfad angegeben']); + exit; + } + + // Token generieren + $expiryHours = $settingsManager->getShareLinkExpiryHours(); + $expiry = time() + ($expiryHours * 3600); + $token = hash_hmac('sha256', $path . $expiry, session_id() . 'share_secret'); + + // Share-Link speichern + $shareDir = dirname(__DIR__) . '/data/shares/'; + if (!is_dir($shareDir)) { + mkdir($shareDir, 0755, true); + } + + $shareId = bin2hex(random_bytes(16)); + $shareData = [ + 'id' => $shareId, + 'path' => $path, + 'type' => $type, + 'token' => $token, + 'expiry' => $expiry, + 'created_at' => date('Y-m-d H:i:s') + ]; + + file_put_contents($shareDir . $shareId . '.json', json_encode($shareData)); + + // URL generieren + $baseUrl = (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? 'https' : 'http') + . '://' . $_SERVER['HTTP_HOST']; + $shareUrl = $baseUrl . '/api/share.php?view=' . $shareId; + + echo json_encode([ + 'success' => true, + 'share_url' => $shareUrl, + 'share_id' => $shareId, + 'expires_at' => date('Y-m-d H:i:s', $expiry) + ]); + exit; +} + +// === GET: Share-Link anzeigen === +if ($_SERVER['REQUEST_METHOD'] === 'GET' && isset($_GET['view'])) { + $shareId = preg_replace('/[^a-f0-9]/', '', $_GET['view']); + $shareFile = dirname(__DIR__) . '/data/shares/' . $shareId . '.json'; + + if (!file_exists($shareFile)) { + header('Content-Type: text/html; charset=utf-8'); + echo 'Link ungültig

❌ Link nicht gefunden

Dieser Share-Link existiert nicht oder wurde bereits gelöscht.

'; + exit; + } + + $shareData = json_decode(file_get_contents($shareFile), true); + + // Ablauf prüfen + if (time() > $shareData['expiry']) { + @unlink($shareFile); + header('Content-Type: text/html; charset=utf-8'); + echo 'Link abgelaufen

⏰ Link abgelaufen

Dieser Share-Link ist abgelaufen. Bitte fordere einen neuen Link an.

'; + exit; + } + + // Datei existiert? + $filePath = dirname(__DIR__) . $shareData['path']; + if (!file_exists($filePath)) { + header('Content-Type: text/html; charset=utf-8'); + echo 'Datei nicht gefunden

📭 Datei nicht gefunden

Die geteilte Datei existiert nicht mehr.

'; + exit; + } + + // Redirect zur Datei oder HTML-Seite mit eingebettetem Player + $isVideo = in_array(pathinfo($filePath, PATHINFO_EXTENSION), ['mp4', 'webm', 'mov']); + $isImage = in_array(pathinfo($filePath, PATHINFO_EXTENSION), ['jpg', 'jpeg', 'png', 'gif', 'webp']); + + $siteName = $config['app']['name'] ?? 'Aurora Livecam'; + $baseUrl = (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? 'https' : 'http') + . '://' . $_SERVER['HTTP_HOST']; + + header('Content-Type: text/html; charset=utf-8'); + echo ' + + + + + Geteilte ' . ($isVideo ? 'Video' : 'Bild') . ' - ' . htmlspecialchars($siteName) . ' + + + +
+

📤 Geteilte' . ($isVideo ? 's Video' : 's Bild') . '

'; + + if ($isVideo) { + echo ''; + } else { + echo 'Geteiltes Bild'; + } + + echo ' + ⬇️ Herunterladen +
+ + +'; + exit; +} + +// === POST: E-Mail senden === +if ($_SERVER['REQUEST_METHOD'] !== 'POST') { + http_response_code(405); + echo json_encode(['success' => false, 'error' => 'Nur POST erlaubt']); + exit; +} + +// JSON-Body parsen +$input = json_decode(file_get_contents('php://input'), true); +if (!$input) { + $input = $_POST; +} + +$email = filter_var($input['email'] ?? '', FILTER_VALIDATE_EMAIL); +$path = $input['path'] ?? ''; +$type = $input['type'] ?? 'video'; +$message = htmlspecialchars($input['message'] ?? ''); +$senderName = htmlspecialchars($input['sender_name'] ?? 'Ein Freund'); + +if (!$email) { + echo json_encode(['success' => false, 'error' => 'Ungültige E-Mail-Adresse']); + exit; +} + +if (empty($path)) { + echo json_encode(['success' => false, 'error' => 'Kein Pfad angegeben']); + exit; +} + +// Share-Link generieren +$expiryHours = $settingsManager->getShareLinkExpiryHours(); +$expiry = time() + ($expiryHours * 3600); + +$shareDir = dirname(__DIR__) . '/data/shares/'; +if (!is_dir($shareDir)) { + mkdir($shareDir, 0755, true); +} + +$shareId = bin2hex(random_bytes(16)); +$shareData = [ + 'id' => $shareId, + 'path' => $path, + 'type' => $type, + 'expiry' => $expiry, + 'created_at' => date('Y-m-d H:i:s'), + 'shared_to' => $email +]; + +file_put_contents($shareDir . $shareId . '.json', json_encode($shareData)); + +$baseUrl = (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? 'https' : 'http') + . '://' . $_SERVER['HTTP_HOST']; +$shareUrl = $baseUrl . '/api/share.php?view=' . $shareId; +$siteName = $config['app']['name'] ?? 'Aurora Livecam'; + +// E-Mail senden +try { + $mail = new PHPMailer(true); + + // SMTP Konfiguration + $mail->isSMTP(); + $mail->Host = $mailConfig['host']; + $mail->SMTPAuth = true; + $mail->Username = $mailConfig['username']; + $mail->Password = $mailConfig['password']; + $mail->SMTPSecure = PHPMailer::ENCRYPTION_STARTTLS; + $mail->Port = $mailConfig['port'] ?? 587; + $mail->CharSet = 'UTF-8'; + + // Absender/Empfänger + $mail->setFrom($mailConfig['from_address'], $mailConfig['from_name'] ?? $siteName); + $mail->addAddress($email); + + // Inhalt + $mail->isHTML(true); + $mail->Subject = $senderName . ' hat ' . ($type === 'video' ? 'ein Video' : 'ein Bild') . ' mit dir geteilt'; + + $mail->Body = ' +
+
+

📤 ' . htmlspecialchars($siteName) . '

+
+
+

+ ' . htmlspecialchars($senderName) . ' hat ' . ($type === 'video' ? 'ein Video' : 'ein Bild') . ' mit dir geteilt! +

+ ' . (!empty($message) ? '
"' . nl2br($message) . '"
' : '') . ' + + ▶️ Jetzt ansehen + +

+ Dieser Link ist ' . $expiryHours . ' Stunden gültig. +

+
+
'; + + $mail->AltBody = $senderName . ' hat ' . ($type === 'video' ? 'ein Video' : 'ein Bild') . ' mit dir geteilt: ' . $shareUrl; + + $mail->send(); + + echo json_encode([ + 'success' => true, + 'message' => 'E-Mail wurde gesendet', + 'share_url' => $shareUrl + ]); + +} catch (Exception $e) { + error_log('Share email error: ' . $e->getMessage()); + echo json_encode([ + 'success' => false, + 'error' => 'E-Mail konnte nicht gesendet werden', + 'share_url' => $shareUrl // URL trotzdem zurückgeben + ]); +} diff --git a/aurora-livecam/api/video-search.php b/aurora-livecam/api/video-search.php new file mode 100644 index 0000000..751cb81 --- /dev/null +++ b/aurora-livecam/api/video-search.php @@ -0,0 +1,192 @@ + [], + 'ai_videos' => [], + 'gallery_images' => [] +]; + +// AI-Kategorien +$aiCategories = ['sunny', 'rainy', 'snowy', 'planes', 'birds', 'sunset', 'sunrise', 'rainbow']; + +// === TAGESVIDEOS SUCHEN === +if ($type === 'all' || $type === 'daily') { + $pattern = $videoDir . 'daily_video_*.mp4'; + $dailyVideos = glob($pattern); + + foreach ($dailyVideos as $video) { + $filename = basename($video); + + // Extrahiere Datum aus Dateinamen: daily_video_YYYYMMDD_HHMMSS.mp4 + if (preg_match('/daily_video_(\d{4})(\d{2})(\d{2})_(\d{2})(\d{2})(\d{2})\.mp4/', $filename, $matches)) { + $videoDate = $matches[1] . '-' . $matches[2] . '-' . $matches[3]; + $videoTime = $matches[4] . ':' . $matches[5]; + $videoDateTime = $videoDate . ' ' . $videoTime . ':' . $matches[6]; + + // Datumsfilter + if ($date !== null && $videoDate !== $date) { + continue; + } + + if ($fromDate !== null && $videoDate < $fromDate) { + continue; + } + + if ($toDate !== null && $videoDate > $toDate) { + continue; + } + + // Uhrzeitfilter + if ($timeFrom !== null && $videoTime < $timeFrom) { + continue; + } + + if ($timeTo !== null && $videoTime > $timeTo) { + continue; + } + + // Spezifische Uhrzeit (mit 30 Min Toleranz) + if ($time !== null) { + $searchMinutes = intval(substr($time, 0, 2)) * 60 + intval(substr($time, 3, 2)); + $videoMinutes = intval($matches[4]) * 60 + intval($matches[5]); + + if (abs($searchMinutes - $videoMinutes) > 30) { + continue; + } + } + + $results['daily_videos'][] = [ + 'type' => 'daily', + 'filename' => $filename, + 'path' => '/videos/' . $filename, + 'date' => $videoDate, + 'time' => $videoTime, + 'datetime' => $videoDateTime, + 'timestamp' => strtotime($videoDateTime), + 'size' => filesize($video), + 'size_mb' => round(filesize($video) / (1024 * 1024), 2) + ]; + } + } +} + +// === AI-VIDEOS SUCHEN === +if ($type === 'all' || $type === 'ai') { + $searchCategories = $aiCategory ? [$aiCategory] : $aiCategories; + + foreach ($searchCategories as $category) { + $categoryDir = $aiDir . $category . '/'; + if (!is_dir($categoryDir)) continue; + + $pattern = $categoryDir . $category . '_*.mp4'; + $aiVideos = glob($pattern); + + foreach ($aiVideos as $video) { + $filename = basename($video); + + // Extrahiere Datum aus Dateinamen: category_YYYYMMDD_HHMMSS.mp4 + if (preg_match('/' . $category . '_(\d{4})(\d{2})(\d{2})_?(\d{2})?(\d{2})?(\d{2})?\.mp4/', $filename, $matches)) { + $videoDate = $matches[1] . '-' . $matches[2] . '-' . $matches[3]; + $videoTime = isset($matches[4]) ? ($matches[4] . ':' . ($matches[5] ?? '00')) : '00:00'; + $videoDateTime = $videoDate . ' ' . $videoTime; + + // Datumsfilter + if ($date !== null && $videoDate !== $date) { + continue; + } + + if ($fromDate !== null && $videoDate < $fromDate) { + continue; + } + + if ($toDate !== null && $videoDate > $toDate) { + continue; + } + + // Uhrzeitfilter + if ($timeFrom !== null && $videoTime < $timeFrom) { + continue; + } + + if ($timeTo !== null && $videoTime > $timeTo) { + continue; + } + + $results['ai_videos'][] = [ + 'type' => 'ai', + 'category' => $category, + 'filename' => $filename, + 'path' => '/ai/' . $category . '/' . $filename, + 'date' => $videoDate, + 'time' => $videoTime, + 'datetime' => $videoDateTime, + 'timestamp' => strtotime($videoDateTime), + 'size' => filesize($video), + 'size_mb' => round(filesize($video) / (1024 * 1024), 2) + ]; + } + } + } +} + +// Sortieren nach Datum/Zeit (neueste zuerst) +usort($results['daily_videos'], fn($a, $b) => $b['timestamp'] - $a['timestamp']); +usort($results['ai_videos'], fn($a, $b) => $b['timestamp'] - $a['timestamp']); + +// Limit anwenden +$results['daily_videos'] = array_slice($results['daily_videos'], 0, $limit); +$results['ai_videos'] = array_slice($results['ai_videos'], 0, $limit); + +// Statistiken +$results['stats'] = [ + 'total_daily' => count($results['daily_videos']), + 'total_ai' => count($results['ai_videos']), + 'total' => count($results['daily_videos']) + count($results['ai_videos']) +]; + +$results['filters'] = [ + 'date' => $date, + 'time' => $time, + 'from' => $fromDate, + 'to' => $toDate, + 'time_from' => $timeFrom, + 'time_to' => $timeTo, + 'type' => $type, + 'ai_category' => $aiCategory +]; + +$results['success'] = true; + +echo json_encode($results, JSON_PRETTY_PRINT); diff --git a/aurora-livecam/index.php b/aurora-livecam/index.php index 0352a2a..d8b3f94 100644 --- a/aurora-livecam/index.php +++ b/aurora-livecam/index.php @@ -1310,7 +1310,7 @@ class AdminManager { // Weather Settings $output .= '
'; - $output .= '

🌤️ Wetter-Widget (Open-Meteo - kostenlos, kein API-Key nötig)

'; + $output .= '

🌤️ Wetter-Widget (Open-Meteo kostenlos, OpenWeatherMap optional)

'; $output .= '
'; $output .= 'Wetter-Widget anzeigen'; @@ -1325,7 +1325,14 @@ class AdminManager { // API-KEY FELD KOMPLETT ENTFERNT $output .= '
'; - $output .= 'Standort (Anzeigename)'; + $output .= 'API Key (OpenWeatherMap, optional)'; + $output .= '
'; + $output .= ''; + $output .= '
'; + $output .= '
'; + + $output .= '
'; + $output .= 'Standort (Stadt,Land)'; $output .= '
'; $output .= ''; $output .= '
'; @@ -2843,6 +2850,39 @@ body.theme-neo footer {

Videoarchiv Tagesvideos

+ +
+

+ 🔍 Suche nach Datum/Uhrzeit +

+
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ +
+ displayVisualCalendar(); diff --git a/phase_locked_vocoder.py b/phase_locked_vocoder.py new file mode 100644 index 0000000..5e1c61a --- /dev/null +++ b/phase_locked_vocoder.py @@ -0,0 +1,215 @@ +""" +Phase-Locked Timestretcher +========================== + +High-quality offline time-stretching using a phase-locked phase vocoder. +This approach keeps the original spectral texture by propagating peak phases +and locking surrounding bins to preserve vertical phase coherence. + +Usage: + python phase_locked_vocoder.py input.wav output.wav 10.0 +""" + +from __future__ import annotations + +import argparse +from dataclasses import dataclass +from typing import Tuple + +import numpy as np +from scipy import signal + +try: + import soundfile as sf +except ImportError: # pragma: no cover - optional dependency + sf = None + + +@dataclass +class StretchConfig: + stretch_factor: float = 10.0 + window_size: int = 4096 + hop_size: int = 1024 + peak_threshold_db: float = -60.0 + peak_min_distance: int = 3 + + +def stft(audio: np.ndarray, window_size: int, hop_size: int) -> np.ndarray: + window = signal.windows.hann(window_size, sym=False) + n_frames = 1 + (len(audio) - window_size) // hop_size + frames = np.lib.stride_tricks.as_strided( + audio, + shape=(n_frames, window_size), + strides=(audio.strides[0] * hop_size, audio.strides[0]), + writeable=False, + ) + windowed = frames * window[None, :] + return np.fft.rfft(windowed, axis=1).T + + +def istft(stft_matrix: np.ndarray, window_size: int, hop_size: int, length: int) -> np.ndarray: + window = signal.windows.hann(window_size, sym=False) + n_frames = stft_matrix.shape[1] + output = np.zeros(hop_size * (n_frames - 1) + window_size) + window_sums = np.zeros_like(output) + + for i in range(n_frames): + frame = np.fft.irfft(stft_matrix[:, i], n=window_size) + start = i * hop_size + output[start:start + window_size] += frame * window + window_sums[start:start + window_size] += window**2 + + nonzero = window_sums > 1e-8 + output[nonzero] /= window_sums[nonzero] + return output[:length] + + +def detect_peaks(magnitude: np.ndarray, threshold_db: float, min_distance: int) -> np.ndarray: + mag_db = 20 * np.log10(magnitude + 1e-12) + candidates = np.where( + (mag_db[1:-1] > threshold_db) + & (mag_db[1:-1] > mag_db[:-2]) + & (mag_db[1:-1] > mag_db[2:]) + )[0] + 1 + + if candidates.size == 0: + return np.array([], dtype=int) + + # Enforce minimum distance between peaks + peaks = [candidates[0]] + for idx in candidates[1:]: + if idx - peaks[-1] >= min_distance: + peaks.append(idx) + return np.array(peaks, dtype=int) + + +def phase_locked_vocoder( + stft_matrix: np.ndarray, + hop_size: int, + stretch_factor: float, + peak_threshold_db: float, + peak_min_distance: int, +) -> np.ndarray: + n_bins, n_frames = stft_matrix.shape + if n_frames < 2: + return stft_matrix + + time_steps = np.arange(0, n_frames - 1, 1 / stretch_factor) + output = np.zeros((n_bins, len(time_steps)), dtype=np.complex128) + + phase_acc = np.angle(stft_matrix[:, 0]) + expected_phase = 2 * np.pi * hop_size * np.arange(n_bins) / (2 * (n_bins - 1)) + + for t, step in enumerate(time_steps): + idx = int(np.floor(step)) + frac = step - idx + if idx + 1 >= n_frames: + break + + mag1 = np.abs(stft_matrix[:, idx]) + mag2 = np.abs(stft_matrix[:, idx + 1]) + mag = (1 - frac) * mag1 + frac * mag2 + + phase1 = np.angle(stft_matrix[:, idx]) + phase2 = np.angle(stft_matrix[:, idx + 1]) + + phase_diff = phase2 - phase1 - expected_phase + phase_diff = (phase_diff + np.pi) % (2 * np.pi) - np.pi + true_freq = expected_phase + phase_diff + phase_acc += true_freq + + peaks = detect_peaks(mag, threshold_db=peak_threshold_db, min_distance=peak_min_distance) + if peaks.size == 0: + output[:, t] = mag * np.exp(1j * phase_acc) + continue + + output_phase = phase_acc.copy() + peak_phases = phase_acc[peaks] + analysis_phases = phase1 + + # Determine regions between peaks + boundaries = [0] + boundaries += [int((peaks[i] + peaks[i + 1]) / 2) for i in range(len(peaks) - 1)] + boundaries.append(n_bins - 1) + + for i, peak in enumerate(peaks): + start = boundaries[i] + end = boundaries[i + 1] + if end <= start: + continue + relative_phase = analysis_phases[start:end + 1] - analysis_phases[peak] + output_phase[start:end + 1] = peak_phases[i] + relative_phase + + output[:, t] = mag * np.exp(1j * output_phase) + + return output + + +def stretch_audio(audio: np.ndarray, sample_rate: int, config: StretchConfig) -> np.ndarray: + if audio.ndim > 1: + audio = np.mean(audio, axis=1) + + audio = audio.astype(np.float64) + audio /= np.max(np.abs(audio)) + 1e-12 + + if len(audio) < config.window_size: + raise ValueError("Audio is shorter than the analysis window.") + + padded = np.pad(audio, (config.window_size // 2, config.window_size // 2), mode="reflect") + stft_matrix = stft(padded, config.window_size, config.hop_size) + + stretched_stft = phase_locked_vocoder( + stft_matrix, + hop_size=config.hop_size, + stretch_factor=config.stretch_factor, + peak_threshold_db=config.peak_threshold_db, + peak_min_distance=config.peak_min_distance, + ) + + output_length = int(len(audio) * config.stretch_factor) + output = istft(stretched_stft, config.window_size, config.hop_size, output_length + config.window_size) + + output = output[config.window_size // 2:config.window_size // 2 + output_length] + peak = np.max(np.abs(output)) + if peak > 0: + output = 0.95 * output / peak + return output + + +def stretch_file(input_path: str, output_path: str, config: StretchConfig) -> None: + if sf is None: + raise RuntimeError("soundfile is required for file IO. Install with `pip install soundfile`.") + + audio, sr = sf.read(input_path) + result = stretch_audio(audio, sr, config) + sf.write(output_path, result, sr) + + +def parse_args() -> Tuple[str, str, StretchConfig]: + parser = argparse.ArgumentParser(description="Phase-locked time-stretching") + parser.add_argument("input", help="Input WAV file") + parser.add_argument("output", help="Output WAV file") + parser.add_argument("stretch", type=float, help="Stretch factor (e.g., 10.0)") + parser.add_argument("--window", type=int, default=4096) + parser.add_argument("--hop", type=int, default=1024) + parser.add_argument("--peak-db", type=float, default=-60.0) + parser.add_argument("--peak-distance", type=int, default=3) + args = parser.parse_args() + + config = StretchConfig( + stretch_factor=args.stretch, + window_size=args.window, + hop_size=args.hop, + peak_threshold_db=args.peak_db, + peak_min_distance=args.peak_distance, + ) + return args.input, args.output, config + + +def main() -> None: + input_path, output_path, config = parse_args() + stretch_file(input_path, output_path, config) + + +if __name__ == "__main__": + main()