Files
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

405 lines
15 KiB
JavaScript

/**
* 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=<sessionStart>
// 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> → 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);