Files
geograsim/App/php/api/heli-timeline.php
Adminator 1e51ef7def Nachtrag: alle bisher untracked Ordner + hängende Änderungen mit-committen
- Konzept/, didaktik_geografie/, didaktik_simulation/, v2-modules/, v2-platform/
- 12 code-workspace-Files
- STATUS-*.md
- viele M/D/R-Änderungen an bereits getrackten Files
- .gitignore verstärkt: **/.humaninput/, **/secret_keys.txt

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-08 02:27:02 +02:00

408 lines
20 KiB
PHP

<?php
/**
* Heli Audio-Timeline API
*
* Endpunkte:
* GET /api/heli-timeline?mission=m1 → liefert Timeline, generiert Default falls keine Datei existiert
* POST /api/heli-timeline?mission=m1 → speichert Timeline-JSON (body) ins File-System, mit Backup
* GET /api/heli-timeline?missions=1 → Liste aller Missionen + Status (default/edited/saved)
*
* GET /api/heli-timeline?durations=1 → liefert audio-durations.json
* POST /api/heli-timeline?durations=1 → speichert durations (body: {key: seconds})
*
* GET /api/heli-timeline?audios=1 → liste alle verfuegbaren Audio-IDs + Texte aus audio-texts.json
*
* Datenpfade:
* App/sims/heli/data/missions.json
* App/sims/heli/data/audio-timeline-<mid>.json
* App/sims/heli/data/audio-timeline-<mid>.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_<wp> 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_<mid> bewusst NICHT mehr im Kartenflug (war Doppelung zu
// r_tower_geo_<mid> 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)));
}