/** * Heli Audio-Engine V3 * -------------------- * Spielt eine Audio-Timeline (vom Audio-Editor erzeugt, siehe * /api/heli-timeline?mission=mX) deterministisch ab: * * • Sprach-Pipe: seriell, eine Audio nach der anderen * (Gap zwischen Audios laut Timeline.gap, default 0.5s) * * • UI-Sound-Layer: parallel zur Sprach-Pipe, sofortig * (z.B. click-ok, click-fail bei Wegpunkt-Klick) * * • Events: pro Audio-Ende werden gameseitige Callbacks * getriggert (z.B. "wp-sichtbar", "wp-erreicht", "phase-ende") * * • Pre-Click: User darf KLICKEN bevor Audio fertig ist. * Engine merkt sich Pre-Click und wendet ihn an, sobald * das wp-sichtbar-Event triggert. * * Verwendung im Spiel: * * var engine = new HeliAudioEngine({ * missionId: 'm1', * basePath: '/geograsim/App/sims/heli/sounds/', * apiBase: '/geograsim/App/api', * onEvent: function(ev) { ... }, // Events vom Spiel verarbeiten * onAudioStart: function(slot) { ... }, // Funk-Pille zeigen * onAudioEnd: function(slot) { ... }, // Funk-Pille verstecken * }); * engine.loadAndStart('plan'); * ... * engine.notifyClick('thueringen'); // bei richtigem Klick * * Konzept: docs/konzept-audio-schnitt-tool.md + docs/audio-drehbuch-regelwerk.md */ (function (global) { 'use strict'; function HeliAudioEngine(opts) { this.missionId = opts.missionId; this.basePath = opts.basePath || '/geograsim/App/sims/heli/sounds/'; this.apiBase = opts.apiBase || '/geograsim/App/api'; this.onEvent = opts.onEvent || function(){}; this.onAudioStart = opts.onAudioStart || function(){}; this.onAudioEnd = opts.onAudioEnd || function(){}; this.playbackRate = opts.playbackRate || 1.25; // 25% schneller (preservesPitch) this.markerFadeInMs = opts.markerFadeInMs || 10000; this.timeline = null; this.audioCtx = null; this.bufCache = {}; // path → AudioBuffer this.activePhase = null; // 'plan'|'flug' this.audioQueue = []; // [{audioId, t, dur, note}], sortiert nach t this.eventQueue = []; // [{label, t, waypoint?, click?}], sortiert nach t this.currentSlotIdx = -1; this.currentSource = null; this.currentGain = null; this.preClicks = new Set(); // wpKeys, die schon vorab geklickt wurden this.eventsFiredAt = new Set(); // schon gefeuerte Events (über Index) this.gap = 0.5; this.phaseStartMs = 0; // performance.now() beim Phase-Beginn this.uiAudios = {}; // ui-Sounds cached this.stopped = false; } // === API ==================================================== // Berechnet Gesamt-Audio-Dauer einer Phase (alle Slots + Gaps zwischen ihnen). // Beruecksichtigt die Engine-playbackRate fuer realistische Spielzeit. HeliAudioEngine.prototype.getPhaseTotalTime = function(phaseId) { if (!this.timeline) return 0; var phase = (this.timeline.phases || []).find(function(p){ return p.id === phaseId; }); if (!phase || !phase.audios || !phase.audios.length) return 0; var totalAudio = 0; phase.audios.forEach(function(a){ totalAudio += (a.dur || 0); }); // Plus Gaps zwischen Slots var gaps = (phase.audios.length - 1) * (this.timeline.gap || 0.5); // Geteilt durch playbackRate (z.B. 1.25 → schneller) return (totalAudio + gaps) / (this.playbackRate || 1.0); }; HeliAudioEngine.prototype.loadTimeline = function() { var self = this; var url = this.apiBase + '/heli-timeline?mission=' + encodeURIComponent(this.missionId); return fetch(url).then(function(r){ if (!r.ok) throw new Error('Timeline load failed: HTTP ' + r.status); return r.json(); }).then(function(tl){ self.timeline = tl; self.gap = tl.gap || 0.5; return tl; }); }; HeliAudioEngine.prototype.loadAndStart = function(phaseId) { var self = this; return (this.timeline ? Promise.resolve(this.timeline) : this.loadTimeline()) .then(function(){ return self.startPhase(phaseId); }); }; HeliAudioEngine.prototype.startPhase = function(phaseId) { if (!this.timeline) return Promise.reject(new Error('Timeline noch nicht geladen')); var phase = (this.timeline.phases || []).find(function(p){ return p.id === phaseId; }); if (!phase) return Promise.reject(new Error('Phase ' + phaseId + ' nicht in Timeline')); this._initCtx(); this.activePhase = phaseId; this.audioQueue = (phase.audios || []).slice().sort(function(a,b){ return a.t - b.t; }); this.eventQueue = (phase.events || []).slice().sort(function(a,b){ return a.t - b.t; }); this.currentSlotIdx = -1; this.eventsFiredAt.clear(); this.preClicks.clear(); this.phaseStartMs = performance.now(); this.stopped = false; this._scheduleEvents(); return this._playNext(); }; HeliAudioEngine.prototype._initCtx = function() { if (this.audioCtx) return; try { this.audioCtx = new (global.AudioContext || global.webkitAudioContext)(); } catch(e) { console.warn('AudioContext nicht verfügbar', e); } }; HeliAudioEngine.prototype.resume = function() { if (this.audioCtx && this.audioCtx.state === 'suspended') this.audioCtx.resume(); }; // Sprach-Pipe: nächstes Audio aus Queue spielen HeliAudioEngine.prototype._playNext = function() { if (this.stopped) return Promise.resolve(); this.currentSlotIdx++; if (this.currentSlotIdx >= this.audioQueue.length) { // Phase fertig this.onEvent({ type: 'phase-end', phaseId: this.activePhase }); return Promise.resolve(); } var slot = this.audioQueue[this.currentSlotIdx]; return this._playSlot(slot); }; HeliAudioEngine.prototype._playSlot = function(slot) { var self = this; if (this.stopped) return Promise.resolve(); // V3.2 (2026-06-01): MediaElementAudioSourceNode statt AudioBufferSource // → preservesPitch = true bleibt erhalten beim playbackRate-Speedup. // Bandpass-Filter (Funk-Sound) wird weiterhin im Web-Audio-Graph gemacht. // playbackRate global aus this.playbackRate (default 1.0). return new Promise(function(resolve) { if (!self.audioCtx) { self._afterSlot(slot); return resolve(); } if (!HeliAudioEngine._sessionStart) HeliAudioEngine._sessionStart = Date.now(); var url = self.basePath + 'radio/' + slot.audioId + '.mp3?_t=' + HeliAudioEngine._sessionStart; var audio = new Audio(); audio.crossOrigin = 'anonymous'; audio.preservesPitch = true; audio.mozPreservesPitch = true; audio.webkitPreservesPitch = true; audio.playbackRate = self.playbackRate || 1.0; audio.src = url; var src, gain, bp; try { src = self.audioCtx.createMediaElementSource(audio); gain = self.audioCtx.createGain(); gain.gain.value = 1.0; bp = self.audioCtx.createBiquadFilter(); bp.type = 'bandpass'; bp.frequency.value = 1800; bp.Q.value = 2; src.connect(bp); bp.connect(gain); gain.connect(self.audioCtx.destination); } catch(e) { // Fallback ohne Filter — wenigstens Audio spielt console.warn('Audio-Graph nicht moeglich, Fallback ohne Filter', e); } self.currentSource = audio; // jetzt HTMLAudioElement self.currentGain = gain; audio.addEventListener('loadedmetadata', function(){ self.onAudioStart(slot, audio.duration / (self.playbackRate || 1.0)); }); audio.addEventListener('ended', function() { if (self.currentSource !== audio) return; self.currentSource = null; self.currentGain = null; self.onAudioEnd(slot); self._afterSlot(slot); resolve(); }); audio.addEventListener('error', function() { console.warn('Audio-Slot Fehler', slot.audioId); self._afterSlot(slot); resolve(); }); audio.play().catch(function(err){ console.warn('audio.play() fail', err); self._afterSlot(slot); resolve(); }); }); }; // Wird nach Audio-Ende aufgerufen. Entscheidet ob: // • Wegpunkt-Audio: Marker-Reveal-Event + auf Klick warten (Gating) // • Anderes Audio (Intro, Plan-Done, etc.): nach Gap weiter HeliAudioEngine.prototype._afterSlot = function(slot) { var wp = this._waypointFromAudioId(slot.audioId); if (wp) { // Wegpunkt-Audio fertig: Marker erscheint mit Fade-In (Game-seitig) this.onEvent({ type: 'wp-marker-reveal', waypoint: wp, fadeInMs: this.markerFadeInMs || 10000, slot: slot, }); if (this.preClicks.has(wp)) { // User hat bereits geklickt → nächstes Audio sofort starten this.preClicks.delete(wp); this.onEvent({ type: 'wp-erreicht', waypoint: wp }); this._playNextAfterGap(); } else { // Auf Klick warten this.waitingForClickWp = wp; } } else { // Mission-Intro, Plan-Done, etc. → kein Gating this._playNextAfterGap(); } }; HeliAudioEngine.prototype._playNextAfterGap = function() { var self = this; setTimeout(function(){ self._playNext(); }, Math.max(50, this.gap * 1000)); }; HeliAudioEngine.prototype._loadBuffer = function(path) { var self = this; if (this.bufCache[path]) return Promise.resolve(this.bufCache[path]); // Cache-Buster: Browser cached MP3s aggressiv. Mit ?_t= // erzwingen wir bei jedem neuen Browser-Aufruf frische Daten, aber // innerhalb einer Session bleibt der gleiche Buster (kein doppelter Fetch). if (!HeliAudioEngine._sessionStart) HeliAudioEngine._sessionStart = Date.now(); var url = this.basePath + path + '?_t=' + HeliAudioEngine._sessionStart; return fetch(url).then(function(r){ if (!r.ok) return null; return r.arrayBuffer(); }).then(function(buf){ if (!buf || buf.byteLength < 200) return null; return new Promise(function(resolve){ self.audioCtx.decodeAudioData(buf, function(decoded){ self.bufCache[path] = decoded; resolve(decoded); }, function(){ resolve(null); }); }); }); }; // === Events auf Timeline ==================================== // Wir feuern Events VOR und NACH dem zugehörigen Audio. // Die meisten Events sind an Audios gekoppelt (waypoint-Marker // wurden vom Default-Generator so gesetzt, dass: // ev "sichtbar" ist genau am Audio-START // ev "erreicht" ist genau am Audio-ENDE (Klick-Zeitpunkt) HeliAudioEngine.prototype._scheduleEvents = function() { // Statt Polling: wir registrieren onAudioStart/onAudioEnd-Hooks intern, // die die zugehörigen Events feuern. Das ist robuster als Timer. var self = this; var origStart = this.onAudioStart; var origEnd = this.onAudioEnd; this.onAudioStart = function(slot, dur) { self._fireEventsAroundAudio(slot, 'start'); origStart(slot, dur); }; this.onAudioEnd = function(slot) { self._fireEventsAroundAudio(slot, 'end'); origEnd(slot); }; }; HeliAudioEngine.prototype._fireEventsAroundAudio = function(slot, which) { // Finde alle Events, die zum gleichen Wegpunkt gehören wie das Audio. var slotWp = this._waypointFromAudioId(slot.audioId); if (!slotWp) return; for (var i = 0; i < this.eventQueue.length; i++) { var ev = this.eventQueue[i]; if (ev.waypoint !== slotWp) continue; if (this.eventsFiredAt.has(i)) continue; // sichtbar = beim Audio-Start (ev.click=false) // erreicht = beim Audio-Ende (ev.click=true) if ((which === 'start' && !ev.click) || (which === 'end' && ev.click)) { this.eventsFiredAt.add(i); this.onEvent({ type: ev.click ? 'wp-erreicht' : 'wp-sichtbar', waypoint: ev.waypoint, label: ev.label, slot: slot, }); // Pre-Click prüfen: wenn User schon vorab geklickt hat if (!ev.click && this.preClicks.has(slotWp)) { // Pre-Click galt für sichtbar-Event → wird erst nach Audio-Ende anerkannt // → wir machen nix hier, das erreicht-Event kommt eh in _fireEventsAroundAudio(end) } } } }; HeliAudioEngine.prototype._waypointFromAudioId = function(audioId) { // r_wp_geo_ → wpkey var m = /^r_wp_geo_(.+)$/.exec(audioId || ''); return m ? m[1] : null; }; // === API für Game: Klick-Nachricht ========================== // Wird gerufen wenn User auf einen Wegpunkt klickt (richtig oder falsch). // Gating-Logik: // • Aktuelles Audio läuft noch → Pre-Click in this.preClicks // • Audio bereits fertig & waitingForClickWp passt → nächstes Audio sofort // • opts.isLast = true (letzter Wegpunkt) → Skip-To-Plan-Done: // laufendes Audio abbrechen, alle r_wp_geo aus Queue, direkt r_nav_plan_done HeliAudioEngine.prototype.notifyClick = function(waypoint, isCorrect, opts) { opts = opts || {}; if (isCorrect) { this.playUiSound('ui/click-ok.mp3'); if (opts.isLast) { this._skipToFinal(); return; } // Pruefen ob wir auf genau diesen Wegpunkt gewartet haben if (this.waitingForClickWp === waypoint) { this.waitingForClickWp = null; this.onEvent({ type: 'wp-erreicht', waypoint: waypoint }); this._playNextAfterGap(); } else { // Audio noch nicht fertig (Pre-Click): merken für später this.preClicks.add(waypoint); } } else { this.playUiSound('ui/click-fail.mp3'); // Sprach-Hinweis (r_nav_plan_wrong_*) wird vom Game in die Sprach-Pipe // gequeued — Engine selbst macht das nicht (war damals direkter Trigger // im onPlanClick, bleibt dort). } }; // Skip-To-Plan-Done (2026-06-01): wird bei Klick auf letzten Wegpunkt gerufen. // Schneller Spieler-Pfad: laufendes Audio abbrechen, alle noch ausstehenden // Wegpunkt-Audios entfernen, direkt zu r_nav_plan_done (oder naechstem Non-WP-Audio). HeliAudioEngine.prototype._skipToFinal = function() { // Aktuelles Audio fade-out this.fadeOutCurrent(180); // Bereinige Queue: alle noch nicht gespielten r_wp_geo_-Slots rauswerfen, // alle anderen (z.B. r_nav_plan_done, r_mission_start_*) behalten. var remaining = []; for (var i = this.currentSlotIdx + 1; i < this.audioQueue.length; i++) { var slot = this.audioQueue[i]; if (slot && slot.audioId && slot.audioId.indexOf('r_wp_geo_') !== 0) { remaining.push(slot); } } this.audioQueue = remaining; this.currentSlotIdx = -1; this.waitingForClickWp = null; this.preClicks.clear(); var self = this; // 250 ms warten, damit Fade-Out durch ist und Click-OK-Sound ausgespielt setTimeout(function(){ self._playNext(); }, 250); }; // === UI-Sound-Layer: parallel zur Sprach-Pipe =============== HeliAudioEngine.prototype.playUiSound = function(path) { var self = this; this._loadBuffer(path).then(function(buf){ if (!buf || self.stopped) return; var src = self.audioCtx.createBufferSource(); src.buffer = buf; var gain = self.audioCtx.createGain(); gain.gain.value = 0.8; src.connect(gain); gain.connect(self.audioCtx.destination); src.start(0); }).catch(function(){}); }; // === Stop & Cleanup ========================================= HeliAudioEngine.prototype.stop = function() { this.stopped = true; if (this.currentSource) { try { this.currentSource.stop(); } catch(_){} this.currentSource = null; } this.audioQueue.length = 0; this.eventQueue.length = 0; }; // === Sprach-Audio sofort abbrechen (z.B. bei Phase-Wechsel) = HeliAudioEngine.prototype.fadeOutCurrent = function(ms) { ms = ms || 180; if (!this.currentGain || !this.audioCtx) return; var t = this.audioCtx.currentTime; this.currentGain.gain.cancelScheduledValues(t); this.currentGain.gain.setValueAtTime(this.currentGain.gain.value, t); this.currentGain.gain.linearRampToValueAtTime(0, t + ms/1000); var src = this.currentSource; setTimeout(function(){ try { src.stop(); } catch(_){} }, ms + 20); this.currentSource = null; this.currentGain = null; }; global.HeliAudioEngine = HeliAudioEngine; })(typeof window !== 'undefined' ? window : this);