- Add mute-btn element to index.html outside all screen sections - Add .mute-btn CSS styles (fixed position, bottom-right, circular) - Wire mute button click handler to toggle audioEnabled in Settings - Initialize AudioContext on first user interaction (autoplay policy) - Fix lint issues: import ordering, non-null assertion
152 lines
3.5 KiB
TypeScript
152 lines
3.5 KiB
TypeScript
import { getDB } from "../app";
|
|
import { getSettings } from "../storage/db";
|
|
|
|
let audioCtx: AudioContext | null = null;
|
|
|
|
/**
|
|
* Creates or resumes AudioContext on first user interaction.
|
|
* Call from a user-interaction event handler (click, keydown) to comply
|
|
* with browser autoplay policy.
|
|
*/
|
|
export function initAudioOnInteraction(): void {
|
|
try {
|
|
if (!audioCtx) {
|
|
audioCtx = new AudioContext();
|
|
}
|
|
if (audioCtx.state === "suspended") {
|
|
audioCtx.resume();
|
|
}
|
|
} catch {
|
|
// AudioContext not supported — silent fallback
|
|
}
|
|
}
|
|
|
|
async function isAudioEnabled(): Promise<boolean> {
|
|
try {
|
|
const db = getDB();
|
|
const settings = await getSettings(db);
|
|
return settings?.audioEnabled !== false;
|
|
} catch {
|
|
return true; // default to enabled if settings unavailable
|
|
}
|
|
}
|
|
|
|
function ensureAudioCtx(): AudioContext | null {
|
|
if (!audioCtx) {
|
|
initAudioOnInteraction();
|
|
}
|
|
return audioCtx;
|
|
}
|
|
|
|
/**
|
|
* Short confirmation beep: 800Hz sine, 0.1s, gain 0.15.
|
|
*/
|
|
export async function playCorrectSound(): Promise<void> {
|
|
try {
|
|
if (!(await isAudioEnabled())) return;
|
|
const ctx = ensureAudioCtx();
|
|
if (!ctx) return;
|
|
|
|
const osc = ctx.createOscillator();
|
|
const gain = ctx.createGain();
|
|
|
|
osc.type = "sine";
|
|
osc.frequency.value = 800;
|
|
gain.gain.value = 0.15;
|
|
|
|
osc.connect(gain);
|
|
gain.connect(ctx.destination);
|
|
|
|
const now = ctx.currentTime;
|
|
// Ramp gain to 0 over last 0.02s for click-free cutoff
|
|
gain.gain.setValueAtTime(0.15, now);
|
|
gain.gain.linearRampToValueAtTime(0, now + 0.1);
|
|
|
|
osc.start(now);
|
|
osc.stop(now + 0.1);
|
|
} catch {
|
|
// Never throw to caller
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Ascending reward tone sequence: C5 E5 G5 C6, ~0.8s total.
|
|
*/
|
|
export async function playRewardSound(): Promise<void> {
|
|
try {
|
|
if (!(await isAudioEnabled())) return;
|
|
const ctx = ensureAudioCtx();
|
|
if (!ctx) return;
|
|
|
|
const notes = [523, 659, 784, 1047]; // C5, E5, G5, C6
|
|
const noteSpacing = 0.2;
|
|
const noteDuration = 0.15;
|
|
const now = ctx.currentTime;
|
|
|
|
for (const [i, freq] of notes.entries()) {
|
|
const osc = ctx.createOscillator();
|
|
const gain = ctx.createGain();
|
|
|
|
osc.type = "sine";
|
|
osc.frequency.value = freq;
|
|
gain.gain.value = 0.12;
|
|
|
|
osc.connect(gain);
|
|
gain.connect(ctx.destination);
|
|
|
|
const startTime = now + i * noteSpacing;
|
|
gain.gain.setValueAtTime(0.12, startTime);
|
|
gain.gain.linearRampToValueAtTime(0, startTime + noteDuration);
|
|
|
|
osc.start(startTime);
|
|
osc.stop(startTime + noteDuration);
|
|
}
|
|
} catch {
|
|
// Never throw to caller
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Forest element sound: filtered white noise, bandpass 3000Hz, 0.5s.
|
|
*/
|
|
export async function playForestElementSound(): Promise<void> {
|
|
try {
|
|
if (!(await isAudioEnabled())) return;
|
|
const ctx = ensureAudioCtx();
|
|
if (!ctx) return;
|
|
|
|
// Create white noise buffer
|
|
const bufferSize = ctx.sampleRate * 0.5; // 0.5 seconds
|
|
const buffer = ctx.createBuffer(1, bufferSize, ctx.sampleRate);
|
|
const data = buffer.getChannelData(0);
|
|
for (let i = 0; i < bufferSize; i++) {
|
|
data[i] = Math.random() * 2 - 1;
|
|
}
|
|
|
|
const source = ctx.createBufferSource();
|
|
source.buffer = buffer;
|
|
|
|
const filter = ctx.createBiquadFilter();
|
|
filter.type = "bandpass";
|
|
filter.frequency.value = 3000;
|
|
filter.Q.value = 0.5;
|
|
|
|
const gain = ctx.createGain();
|
|
gain.gain.value = 0.1;
|
|
|
|
source.connect(filter);
|
|
filter.connect(gain);
|
|
gain.connect(ctx.destination);
|
|
|
|
const now = ctx.currentTime;
|
|
// Ramp down gain over duration
|
|
gain.gain.setValueAtTime(0.1, now);
|
|
gain.gain.linearRampToValueAtTime(0, now + 0.5);
|
|
|
|
source.start(now);
|
|
source.stop(now + 0.5);
|
|
} catch {
|
|
// Never throw to caller
|
|
}
|
|
}
|