jev-8ball/public/js/audio.js

129 lines
3.6 KiB
JavaScript

/**
* Procedural audio: everything is synthesised with the Web Audio API, so the page
* ships no audio assets. Every method is a no-op when muted or when Web Audio is
* unavailable, which keeps callers free of guards.
*/
export class OracleAudio {
constructor(storageKey = 'jev_muted') {
this.storageKey = storageKey;
this.context = null;
this.muted = readMuted(storageKey);
}
init() {
if (!this.context) {
const Ctor = window.AudioContext || window.webkitAudioContext;
if (!Ctor) return null;
try {
this.context = new Ctor();
} catch (e) {
return null;
}
}
if (this.context.state === 'suspended') this.context.resume().catch(() => {});
return this.context;
}
toggleMute() {
this.muted = !this.muted;
try {
localStorage.setItem(this.storageKey, String(this.muted));
} catch (e) {
/* storage may be unavailable; mute still applies for this session */
}
return this.muted;
}
/** One shaped sine/saw blip with an exponential decay. */
tone({ frequency, type = 'sine', duration = 0.12, gain = 0.12, delay = 0, sweepTo, filter }) {
if (this.muted) return;
const context = this.init();
if (!context) return;
try {
const start = context.currentTime + delay;
const osc = context.createOscillator();
const amp = context.createGain();
osc.type = type;
osc.frequency.setValueAtTime(frequency, start);
if (sweepTo) osc.frequency.exponentialRampToValueAtTime(sweepTo, start + duration);
amp.gain.setValueAtTime(gain, start);
amp.gain.exponentialRampToValueAtTime(0.0001, start + duration);
let tail = osc;
if (filter) {
const biquad = context.createBiquadFilter();
biquad.type = 'lowpass';
biquad.frequency.setValueAtTime(filter.from, start);
biquad.frequency.linearRampToValueAtTime(filter.to, start + duration);
osc.connect(biquad);
tail = biquad;
}
tail.connect(amp);
amp.connect(context.destination);
osc.start(start);
osc.stop(start + duration + 0.02);
} catch (e) {
/* a failed blip must never break the interaction */
}
}
/** Rising metallic sweep while the vessel is being shaken. */
roll(durationSeconds = 1.2) {
this.tone({
frequency: 58,
sweepTo: 132,
type: 'sawtooth',
duration: durationSeconds * 0.7,
gain: 0.07,
filter: { from: 420, to: 1100 }
});
this.tone({
frequency: 44,
sweepTo: 36,
type: 'triangle',
duration: durationSeconds,
gain: 0.05
});
}
/** Chord that lands with the verdict, coloured by sentiment. */
reveal(sentiment = 'affirmative') {
const chords = {
affirmative: [
[440.0, 0],
[554.37, 0.075],
[659.25, 0.15]
],
negative: [
[329.63, 0],
[277.18, 0.1],
[196.0, 0.22]
],
neutral: [
[392.0, 0],
[466.16, 0.11]
]
};
const notes = chords[sentiment] || chords.neutral;
const type = sentiment === 'negative' ? 'sawtooth' : 'triangle';
for (const [frequency, delay] of notes) {
this.tone({ frequency, type, duration: 0.3, gain: 0.11, delay });
}
}
/** Short confirmation used when an answer is copied. */
blip() {
this.tone({ frequency: 880, type: 'square', duration: 0.06, gain: 0.05 });
}
error() {
this.tone({ frequency: 180, sweepTo: 120, type: 'square', duration: 0.18, gain: 0.05 });
}
}
function readMuted(storageKey) {
try {
return localStorage.getItem(storageKey) === 'true';
} catch (e) {
return false;
}
}