.json * App/sims/heli/data/audio-timeline-.json.bak-YYYYMMDD-HHMMSS * App/sims/heli/data/audio-durations.json * App/sims/heli/data/briefings.json * App/sims/heli/data/heli-stations.json * App/sims/heli/scripts/audio-texts.json * * Konzept: docs/konzept-audio-schnitt-tool.md */ require_once __DIR__ . '/../config/app.php'; header('Content-Type: application/json; charset=utf-8'); $dataDir = __DIR__ . '/../../sims/heli/data'; $scriptsDir = __DIR__ . '/../../sims/heli/scripts'; function jsonReadOr($path, $fallback) { if (!file_exists($path)) return $fallback; $j = json_decode(file_get_contents($path), true); return is_array($j) ? $j : $fallback; } function jsonWrite($path, $data) { $json = json_encode($data, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT); return file_put_contents($path, $json) !== false; } function backupFile($path) { if (!file_exists($path)) return; $bak = $path . '.bak-' . date('Ymd-His'); @copy($path, $bak); } $method = $_SERVER['REQUEST_METHOD']; // === GET: Audios-Liste === if ($method === 'GET' && isset($_GET['audios'])) { $texts = jsonReadOr($scriptsDir . '/audio-texts.json', []); $durations = jsonReadOr($dataDir . '/audio-durations.json', ['durations' => []]); $durMap = $durations['durations'] ?? []; $out = []; foreach ($texts as $id => $text) { if (!is_string($text) || strpos($id, '_') === 0) continue; $out[] = [ 'id' => $id, 'text' => $text, 'dur' => $durMap[$id] ?? null, ]; } echo json_encode(['audios' => $out], JSON_UNESCAPED_UNICODE); exit; } // === GET/POST: Audio-Dauern-Cache === if (isset($_GET['durations'])) { $path = $dataDir . '/audio-durations.json'; if ($method === 'GET') { $existing = jsonReadOr($path, ['_doc' => 'Audio-Dauern in Sekunden (clientseitig gemessen)', 'durations' => []]); echo json_encode($existing, JSON_UNESCAPED_UNICODE); exit; } if ($method === 'POST') { $body = json_decode(file_get_contents('php://input'), true); if (!is_array($body)) { http_response_code(400); echo json_encode(['error' => 'invalid body']); exit; } $existing = jsonReadOr($path, ['durations' => []]); $existing['_doc'] = 'Audio-Dauern in Sekunden (clientseitig gemessen)'; $existing['lastUpdated'] = date('c'); $existing['durations'] = array_merge($existing['durations'] ?? [], $body['durations'] ?? []); jsonWrite($path, $existing); echo json_encode(['ok' => true, 'count' => count($existing['durations'])]); exit; } } // === GET: Missionen-Liste mit Status === if ($method === 'GET' && isset($_GET['missions'])) { $missions = jsonReadOr($dataDir . '/missions.json', ['missions' => []]); $out = []; foreach (($missions['missions'] ?? []) as $m) { $tlPath = $dataDir . '/audio-timeline-' . $m['id'] . '.json'; $status = 'default'; $lastSaved = null; if (file_exists($tlPath)) { $tl = jsonReadOr($tlPath, []); $status = !empty($tl['edited']) ? 'edited' : 'saved'; $lastSaved = $tl['lastSaved'] ?? null; } $out[] = array_merge($m, ['status' => $status, 'lastSaved' => $lastSaved]); } echo json_encode(['missions' => $out], JSON_UNESCAPED_UNICODE); exit; } // === GET/POST: Timeline pro Mission === $missionId = $_GET['mission'] ?? ''; if (!preg_match('/^m[1-9][0-9]?$/', $missionId)) { http_response_code(400); echo json_encode(['error' => 'invalid mission id']); exit; } $tlPath = $dataDir . '/audio-timeline-' . $missionId . '.json'; if ($method === 'GET') { $forceDefault = !empty($_GET['forceDefault']); if (!$forceDefault && file_exists($tlPath)) { // existierende Timeline laden + zurueckgeben $tl = jsonReadOr($tlPath, null); if ($tl) { echo json_encode($tl, JSON_UNESCAPED_UNICODE); exit; } } // Default-Timeline generieren $tl = generateDefaultTimeline($missionId, $dataDir, $scriptsDir); echo json_encode($tl, JSON_UNESCAPED_UNICODE); exit; } if ($method === 'POST') { $body = json_decode(file_get_contents('php://input'), true); if (!is_array($body)) { http_response_code(400); echo json_encode(['error' => 'invalid body']); exit; } // Backup + Save backupFile($tlPath); $body['missionId'] = $missionId; $body['lastSaved'] = date('c'); $body['edited'] = true; if (!isset($body['version'])) $body['version'] = 1; if (jsonWrite($tlPath, $body)) { echo json_encode(['ok' => true, 'lastSaved' => $body['lastSaved']]); } else { http_response_code(500); echo json_encode(['error' => 'write failed']); } exit; } http_response_code(405); echo json_encode(['error' => 'method not allowed']); // =================================================================== // Default-Timeline-Generator // Erzeugt eine vollstaendige Vorbefuellung pro Mission aus: // - missions.json (Mission-Daten) // - audio-texts.json (welche Audios existieren) // - audio-durations.json (echte Dauern, sonst Schaetzung) // - briefings.json (Wetter-Flag pro Mission) // - audio-drehbuch-regelwerk.md (Reihenfolge — hier in Code) // =================================================================== function generateDefaultTimeline($mid, $dataDir, $scriptsDir) { $missionsJ = jsonReadOr($dataDir . '/missions.json', ['missions' => []]); $mission = null; foreach ($missionsJ['missions'] as $m) { if ($m['id'] === $mid) { $mission = $m; break; } } if (!$mission) { return ['error' => 'mission not found', 'missionId' => $mid]; } $texts = jsonReadOr($scriptsDir . '/audio-texts.json', []); $durJ = jsonReadOr($dataDir . '/audio-durations.json', ['durations' => []]); $dur = $durJ['durations'] ?? []; $brief = jsonReadOr($dataDir . '/briefings.json', ['missions' => []]); $hasWetter = !empty($brief['missions'][$mid]['wetter']); $gap = 0.5; // Hilfsfunktion: Audio-Slot anhaengen $addAudio = function(&$phase, $audioId, &$t, $note = '') use ($texts, $dur, $gap) { if (!isset($texts[$audioId])) return; // existiert nicht → ueberspringen $d = $dur[$audioId] ?? estimateDur($texts[$audioId]); $phase['audios'][] = [ 'id' => 'a' . count($phase['audios']), 'audioId' => $audioId, 't' => round($t, 2), 'dur' => round($d, 2), 'note' => $note, ]; $t += $d + $gap; }; // Hilfsfunktion: Event-Marker $addEvent = function(&$phase, $label, $t, $extra = []) { $phase['events'][] = array_merge([ 'id' => 'ev' . count($phase['events']), 't' => round($t, 2), 'label' => $label, ], $extra); }; // === PLAN-PHASE === $plan = ['id' => 'plan', 'label' => 'Routenplanung', 'color' => '#4a7c8a', 'audios' => [], 'events' => []]; $t = 0; $addEvent($plan, 'Plan-Start', $t); $addAudio($plan, 'r_mission_intro_' . $mid, $t, 'Mission-Intro (Tower)'); // Pro Wegpunkt ab Index 1 (Index 0 = Stuetzpunkt). V3-Konzept (2026-06-01): // r_nav_plan_correct ist KEIN Sprach-Audio mehr in der Pipe, sondern ein // UI-Klick-Sound (click-ok.mp3), den engine.notifyClick(wp, true) parallel // sofort beim User-Klick abspielt. Nur r_wp_geo_ ist in der Sprach-Pipe. $wps = $mission['waypoints']; for ($i = 1; $i < count($wps); $i++) { $wpKey = $wps[$i]; $isLast = $i === count($wps) - 1; // Event "sichtbar": Audio-START → Wegpunkt-Marker enthuellt sich $addEvent($plan, ($isLast ? 'Ziel' : 'Wegpunkt') . ' sichtbar: ' . $wpKey, $t, ['waypoint' => $wpKey]); // Audio: Geo-Info (Sprach-Pipe) $addAudio($plan, 'r_wp_geo_' . $wpKey, $t, ($isLast ? 'Ziel-Hinweis' : 'Wegpunkt-Hinweis')); // Event "erreicht": Audio-ENDE → Spieler wird zum Klicken erwartet $addEvent($plan, ($isLast ? 'Ziel' : 'Wegpunkt') . ' erreicht: ' . $wpKey, $t, ['waypoint' => $wpKey, 'click' => true]); } // Plan-Ende-Sprache (am Ende der Sprach-Pipe) $addAudio($plan, 'r_nav_plan_done', $t, 'Plan fertig'); $addEvent($plan, 'Plan-Ende', $t); // === STARTFLUG-PHASE (start.html iframe) === // Pilot-Hinweise sind szenario-spezifisch (start.html Builder waehlen pro scen // ein eigenes radio.messages Array). Wir spiegeln das hier. $hk = $mission['heliKey']; $scen = $mission['scen'] ?? 'berg-fels'; $startflug = ['id' => 'startflug', 'label' => 'Startflug', 'color' => '#c97a3f', 'audios' => [], 'events' => []]; $t = 0; $addEvent($startflug, 'Startflug-Beginn (Szenario: ' . $scen . ')', $t); $addAudio($startflug, 'r_tower_start_' . $hk, $t, 'Tower: Startplatz frei'); $addAudio($startflug, 'r_mission_start_' . $mid, $t, 'Tower: Mission-Freigabe'); // Pilot-Wetter-Kommentare (immer, Szenario-unabhaengig) $addEvent($startflug, 'Pool: Pilot-Wetter (immer)', $t); foreach (['r_pilot_calm1','r_pilot_calm2','r_pilot_calm3'] as $aid) $addAudio($startflug, $aid, $t, 'Wetter-OK'); // Szenario-spezifische Pre-Warn (Hindernis-Hinweise) $startflugScenAudios = [ 'berg-schnee' => ['r_pilot_trees', 'r_pilot_flock', 'r_pilot_wind', 'r_pilot_narrow', 'r_pilot_fog1', 'r_pilot_fog2', 'r_pilot_fog3', 'r_warn_nebel'], 'berg-fels' => ['r_pilot_trees', 'r_pilot_flock', 'r_pilot_wind', 'r_pilot_narrow', 'r_pilot_fog1', 'r_pilot_fog2', 'r_warn_nebel'], 'schlucht' => ['r_pilot_narrow', 'r_pilot_gusts', 'r_pilot_wind', 'r_pilot_flock', 'r_pilot_trees'], 'wasser' => ['r_pilot_flock', 'r_pilot_gusts', 'r_pilot_wind', 'r_pilot_fog1', 'r_pilot_fog2', 'r_warn_nebel'], 'autobahn' => ['r_warn_kran', 'r_warn_antenne', 'r_warn_strom', 'r_warn_schornstein', 'r_warn_fabrik', 'r_pilot_gap', 'r_pilot_gusts', 'r_pilot_time'], 'tal-dorf' => ['r_warn_kran', 'r_warn_antenne', 'r_pilot_gap', 'r_pilot_trees', 'r_pilot_flock', 'r_pilot_wind', 'r_pilot_fog1'], ]; $addEvent($startflug, 'Pool: Pilot-Strecken-Hinweise (szenario-spezifisch)', $t); foreach (($startflugScenAudios[$scen] ?? $startflugScenAudios['berg-fels']) as $aid) { $addAudio($startflug, $aid, $t, 'Strecke ' . $scen); } // Proximity-Warns (immer — Spieler-Fehler unabhaengig von Szenario) $addEvent($startflug, 'Pool: Proximity-Warns (immer)', $t); foreach (['r_pilot_bird_ahead','r_pilot_plane_ahead','r_pilot_sportplane_level','r_pilot_storm_detour','r_pilot_fog_thickens','r_pilot_wind_turn','r_warn_gewitter','r_warn_fuel'] as $aid) { $addAudio($startflug, $aid, $t, 'Proximity'); } // Crash-Reaktionen — gefiltert nach Szenario (was kann ueberhaupt getroffen werden) $crashScenAudios = [ 'berg-schnee' => ['r_warn_vogel', 'r_warn_baum', 'r_warn_blitz', 'r_warn_flugzeug', 'r_warn_sport'], 'berg-fels' => ['r_warn_vogel', 'r_warn_baum', 'r_warn_blitz', 'r_warn_flugzeug', 'r_warn_sport'], 'schlucht' => ['r_warn_vogel', 'r_warn_baum', 'r_warn_blitz', 'r_warn_flugzeug'], 'wasser' => ['r_warn_vogel', 'r_warn_flugzeug', 'r_warn_sport', 'r_warn_blitz'], 'autobahn' => ['r_warn_haus', 'r_warn_klima', 'r_warn_vogel', 'r_warn_flugzeug', 'r_warn_blitz'], 'tal-dorf' => ['r_warn_haus', 'r_warn_baum', 'r_warn_vogel', 'r_warn_klima', 'r_warn_flugzeug'], ]; $addEvent($startflug, 'Crash-Reaktionen (szenario-spezifisch)', $t); foreach (($crashScenAudios[$scen] ?? $crashScenAudios['berg-fels']) as $aid) { $addAudio($startflug, $aid, $t, 'Crash'); } // Strikes (Pilot-Schreck + Tower-Reaktion — immer) $addEvent($startflug, 'Pool: Strikes (immer)', $t); foreach (['r_pilot_strike_1','r_pilot_strike_2','r_pilot_strike_3','r_pilot_strike_4','r_pilot_strike_5'] as $aid) $addAudio($startflug, $aid, $t, 'Pilot-Schreck'); $addAudio($startflug, 'r_pilot_three_strikes', $t, '3 Strikes erreicht'); $addAudio($startflug, 'r_tower_strike_' . $hk, $t, 'Tower nach Strike'); $addAudio($startflug, 'r_tower_handover_' . $hk, $t, 'Tower: Chefpilotin uebernimmt'); foreach (['r_pilot_too_high','r_pilot_time_up'] as $aid) $addAudio($startflug, $aid, $t, 'Fehler-Hinweis'); // Tower-generic (selten, immer) $addEvent($startflug, 'Pool: Tower-generic', $t); foreach (['r_tower1','r_tower2','r_tower3','r_tower4'] as $aid) $addAudio($startflug, $aid, $t, 'generic Tower'); $addAudio($startflug, 'r_start_erfolg', $t, 'Tower: Start erfolgreich'); $addEvent($startflug, 'Startflug-Ende', $t); // === KARTENFLUG-PHASE (game.html flight-Phase) === // r_mission_geo_ bewusst NICHT mehr im Kartenflug (war Doppelung zu // r_tower_geo_ in Bergung). Beide hatten denselben Geo-Lehrtext, nur // einmal als Briefing, einmal als Tower-Roger. Spart 1 Slot pro Mission. $flug = ['id' => 'flug', 'label' => 'Kartenflug', 'color' => '#5a8a5e', 'audios' => [], 'events' => []]; $t = 0; $addEvent($flug, 'Flug-Start', $t); $addAudio($flug, 'r_nav_heli_intro_' . $hk, $t, 'Crew-Vorstellung'); $addAudio($flug, 'r_nav_fuel_half', $t, 'Treibstoff-Halbzeit'); if ($hasWetter) { $addAudio($flug, 'r_nav_weather_enter', $t, 'Wetter-Eintritt'); } // r_nav_altitude_* ans ENDE verlegt (2026-06-02 nach Thomas): didaktisch beim // Zielanflug sinnvoller — Spieler kommt grad in die Berge / ans Tal. $isMountain = in_array($mission['scen'] ?? '', ['berg-schnee', 'berg-fels', 'schlucht']); $addAudio($flug, $isMountain ? 'r_nav_altitude_mountain' : 'r_nav_altitude_valley', $t, 'Hoehen-Info am Zielanflug'); // r_im_zielgebiet bewusst NICHT mehr im Kartenflug (war zeitbasiert nach // Stack, kam bei langen Routen viel zu frueh — z.B. bei M4 ueber Oetz). // landing.html spielt es beim hover_drop wenn Heli physisch unter dem Patient steht. $addEvent($flug, 'Flug-Ende (Anflug)', $t); // === BERGUNG-PHASE (landing.html iframe) === // Drei Bergungs-Modi: // - HIGHWAY (hw): Heli LANDET auf gesperrter Fahrbahn → KEIN Winch, Pilot steigt aus // - WASSER (sea): Heli schwebt, Rettungsschwimmer/Winch, Patient ueber Wasser // - BERG (default): Heli schwebt, Flugretter abgeseilt, Winch $isHw = $scen === 'autobahn'; $isSea = $scen === 'wasser'; $modeLabel = $isHw ? 'HIGHWAY-Landung' : ($isSea ? 'WASSER-Schwebeflug' : 'BERG-Winch'); // r_caution_* Audios verwenden Underscore (berg_fels), missions.json hat Bindestrich (berg-fels) $scenU = str_replace('-', '_', $scen); $bergung = ['id' => 'bergung', 'label' => 'Bergungsflug', 'color' => '#a25cc8', 'audios' => [], 'events' => []]; $t = 0; $addEvent($bergung, 'Bergung-Beginn (' . $modeLabel . ')', $t); $addAudio($bergung, 'r_pilot_call_' . $hk, $t, 'Pilot: Einsatzort erreicht'); $addAudio($bergung, 'r_tower_geo_' . $mid, $t, 'Tower: Roger + Region-Info'); if ($isHw) { // HIGHWAY: Heli landet, kein Winch $addEvent($bergung, 'Tower-Anflug (Highway)', $t); $addAudio($bergung, 'r_tower_hw_approach_' . $hk, $t, 'Tower-HW: Sperrzone erreicht'); $addEvent($bergung, 'Landezone-Checks (Sequenz)', $t); $addAudio($bergung, 'r_pilot_hw_zone_check', $t, '1. Pilotin prueft Landezone'); $addAudio($bergung, 'r_pilot_hw_zone_blocked', $t, '2a. Falls Zone blockiert'); $addAudio($bergung, 'r_pilot_hw_zone_clear', $t, '2b. Falls Zone frei → absinken'); $addAudio($bergung, 'r_land_approach', $t, '3. Anflug auf Fahrbahn'); $addAudio($bergung, 'r_pilot_hw_ground_hold', $t, '4. Pilotin am Boden — wartet auf Patient'); $addAudio($bergung, 'r_patient_aboard', $t, '5. Patient an Bord'); $addEvent($bergung, 'Klinik-Freigabe + Wieder-Start', $t); $addAudio($bergung, 'r_tower_hw_extract_' . $hk, $t, '6. Tower-HW: Strecke frei'); $addAudio($bergung, 'r_pilot_hw_clinic_lift', $t, '7. Pilotin: Klinik-Lift'); } elseif ($isSea) { // WASSER: Schwebeflug, Rettungsschwimmer/Winch $addEvent($bergung, 'Tower-Anflug (Wasser)', $t); $addAudio($bergung, 'r_tower_sea_approach_' . $hk, $t, 'Tower-See: Wasserlage'); $addAudio($bergung, 'r_caution_wasser', $t, 'Sicherheit: Wasser'); $addEvent($bergung, 'Wasser-Bergung (Sequenz Schritt 1-6)', $t); $addAudio($bergung, 'r_land_approach', $t, '1. Anflug zum Patienten'); $addAudio($bergung, 'r_land_winch_down', $t, '2. Winde fährt aus, Retter abgeseilt'); $addAudio($bergung, 'r_land_rescuer_down', $t, '3. Retter ist im Wasser'); $addAudio($bergung, 'r_warteschleife', $t, '4. Heli in Warteschleife/Zwischenland.'); $addAudio($bergung, 'r_land_rescuer_ready', $t, '5. Retter signalisiert bereit'); $addAudio($bergung, 'r_land_hook_connected', $t, '6. Haken eingeklinkt'); $addAudio($bergung, 'r_land_lift_success', $t, '7. Patient gehoben'); $addAudio($bergung, 'r_patient_aboard', $t, '8. Patient an Bord'); $addEvent($bergung, 'Klinik-Freigabe', $t); $addAudio($bergung, 'r_tower_sea_extract_' . $hk, $t, '9. Tower-See: Klinik freigegeben'); } else { // BERG (default): Schwebeflug + Winch + Flugretter abgeseilt $addEvent($bergung, 'Tower-Anflug (Berg)', $t); $addAudio($bergung, 'r_tower_approach_' . $hk, $t, 'Tower: Zielanflug freigegeben'); $addAudio($bergung, 'r_caution_' . $scenU, $t, 'Sicherheit: ' . $scen); $addEvent($bergung, 'Berg-Winch-Bergung (Sequenz Schritt 1-8)', $t); $addAudio($bergung, 'r_land_approach', $t, '1. Schwebeflug zum Patienten'); $addAudio($bergung, 'r_land_winch_down', $t, '2. Winde fährt aus, Retter abgeseilt'); $addAudio($bergung, 'r_retter_intermediate', $t, '3. Retter unten, Versorgung lauft'); $addAudio($bergung, 'r_warteschleife', $t, '4. Heli kreist / landet zwischen'); $addAudio($bergung, 'r_land_rescuer_ready', $t, '5. Retter signalisiert bereit'); $addAudio($bergung, 'r_land_hook_connected', $t, '6. Haken eingeklinkt'); $addAudio($bergung, 'r_land_lift_success', $t, '7. Patient gehoben'); $addAudio($bergung, 'r_patient_aboard', $t, '8. Patient an Bord'); $addEvent($bergung, 'Klinik-Freigabe', $t); $addAudio($bergung, 'r_tower_extract_' . $hk, $t, '9. Tower: Direktfreigabe Klinik'); } // Klinik-Anflug + Ende (alle Modi) $addEvent($bergung, 'Klinik-Anflug + Ende', $t); $addAudio($bergung, 'r_kurs_klinik', $t, 'Pilot: Kurs Klinik'); $addAudio($bergung, 'r_land_clinic_ahead', $t, 'Land: Klinik voraus'); $addAudio($bergung, 'r_land_disembark', $t, 'Land: Patient uebergeben'); $addAudio($bergung, 'r_mission_success', $t, 'Mission erfolgreich'); $addAudio($bergung, 'r_mission_abort', $t, 'Mission abgebrochen (Alternative)'); $addEvent($bergung, 'Bergung-Ende', $t); return [ '_doc' => 'Default-Timeline (Atlas-generiert). 4 Phasen: plan/startflug/flug/bergung. Tool ueberschreibt beim ersten Save.', 'missionId' => $mid, 'version' => 2, 'gap' => $gap, 'edited' => false, 'lastSaved' => null, 'phases' => [$plan, $startflug, $flug, $bergung], ]; } // TTS-Sprechdauer-Schaetzung aus Text (Fallback wenn keine echte Dauer da) function estimateDur($text) { if (!$text) return 3; $sec = mb_strlen($text) / 12.0 + 1.5; return max(3, min(25, round($sec, 1))); }