1e51ef7def
- 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>
405 lines
15 KiB
JavaScript
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);
|