tarinai/js/audio.js
2026-06-21 22:29:00 +09:00

342 lines
19 KiB
JavaScript

"use strict";
// Sound IDs are intentionally stable. To replace a synthesized placeholder with a file,
// add a path to samplePaths using the same ID, for example:
// sfx_genkotsu_impact: "assets/sounds/sfx_genkotsu_impact.mp3"
const audio = {
enabled: true,
ctx: null,
samples: {},
sampleLast: {},
activeVoices: 0,
maxConcurrent: 96,
failedSamples: {},
lastPlayedId: "",
lastPlayedAt: 0,
soundPackVersion: "inline",
masterGainNode: null,
compressorNode: null,
_synthIdGain: 1,
idGain: {},
idMinGap: {},
categoryEnabled: { voice: false, notify: true, ops: true },
categoryGain: {},
samplePaths: {},
soundCategories: {},
soundMap: {},
applySoundPack(pack = window.TARINAI_SOUND_PACK) {
if (!pack || typeof pack !== "object") return false;
this.soundPackVersion = String(pack.version || "custom");
if (pack.categoryGain && typeof pack.categoryGain === "object") this.categoryGain = { ...this.categoryGain, ...pack.categoryGain };
if (pack.idGain && typeof pack.idGain === "object") this.idGain = { ...this.idGain, ...pack.idGain };
if (pack.idMinGap && typeof pack.idMinGap === "object") this.idMinGap = { ...this.idMinGap, ...pack.idMinGap };
if (pack.samplePaths && typeof pack.samplePaths === "object") this.samplePaths = { ...this.samplePaths, ...pack.samplePaths };
if (pack.categories && typeof pack.categories === "object") this.soundCategories = { ...this.soundCategories, ...pack.categories };
if (pack.soundMap && typeof pack.soundMap === "object") this.soundMap = { ...this.soundMap, ...pack.soundMap };
this._samplesReady = false;
this.samples = {};
return true;
},
ensure() {
const AudioClass = window.AudioContext || window.webkitAudioContext;
if (!AudioClass) return null;
if (!this.ctx) this.ctx = new AudioClass();
if (!this.masterGainNode) {
const ctx = this.ctx;
this.masterGainNode = ctx.createGain();
this.masterGainNode.gain.setValueAtTime(1.85, ctx.currentTime);
if (typeof ctx.createDynamicsCompressor === "function") {
this.compressorNode = ctx.createDynamicsCompressor();
this.compressorNode.threshold.value = -18;
this.compressorNode.knee.value = 24;
this.compressorNode.ratio.value = 8;
this.compressorNode.attack.value = 0.002;
this.compressorNode.release.value = 0.18;
this.masterGainNode.connect(this.compressorNode).connect(ctx.destination);
} else {
this.masterGainNode.connect(ctx.destination);
}
}
if (this.ctx.state === "suspended") this.ctx.resume().catch?.(() => {});
this.initSamples();
return this.ctx;
},
outputNode() {
this.ensure();
return this.masterGainNode || this.ctx?.destination;
},
loadSettings() {
try {
const raw = localStorage.getItem("tarinai_audio_settings_v2");
if (!raw) return;
const parsed = JSON.parse(raw);
if (typeof parsed.enabled === "boolean") this.enabled = parsed.enabled;
if (parsed.categories && typeof parsed.categories === "object") {
for (const key of Object.keys(this.categoryEnabled)) {
if (typeof parsed.categories[key] === "boolean") this.categoryEnabled[key] = parsed.categories[key];
}
}
} catch (_) {}
},
saveSettings() {
try {
localStorage.setItem("tarinai_audio_settings_v2", JSON.stringify({
enabled: Boolean(this.enabled),
categories: { ...this.categoryEnabled },
}));
} catch (_) {}
},
initSamples() {
if (this._samplesReady) return;
this._samplesReady = true;
for (const [key, path] of Object.entries(this.samplePaths)) {
if (!path) continue;
const a = new Audio(path);
a.preload = "auto";
a.volume = this.sampleVolumeFor(key);
a.addEventListener("error", () => {
this.failedSamples[key] = path;
window.TarinaiEvents?.emit("audio:sample-error", { id: key, path });
}, { once: true });
this.samples[key] = a;
}
},
categoryFor(id, fallback = "ops") {
return this.soundMap[id] || fallback;
},
categoryOn(category = "ops") {
return Boolean(this.categoryEnabled[category] ?? true);
},
gainFor(category = "ops", base = 1) {
return Math.max(0.1, Number(base) || 1) * (this.categoryGain?.[category] ?? 1);
},
sampleVolumeFor(key = "") {
const category = this.categoryFor(key, key.startsWith("voice") ? "voice" : "ops");
const base = category === "voice" ? 0.95 : 1.0;
return Math.min(1, base * Math.max(1, (this.categoryGain?.[category] ?? 1) * 0.35) * (this.idGain?.[key] ?? 1));
},
distanceGain(opts = {}) {
if (!opts || opts.category === "notify" || !Number.isFinite(opts.x) || !Number.isFinite(opts.y)) return 1;
const w = window.world;
if (!w || !w.worldToScreen) return 1;
const p = w.worldToScreen(opts.x, opts.y);
const margin = 140;
const vw = w.viewportW || w.w || 1000;
const vh = w.viewportH || w.h || 720;
if (p.x < -margin || p.y < -margin || p.x > vw + margin || p.y > vh + margin) return 0;
const cx = vw / 2, cy = vh / 2;
const d = Math.hypot(p.x - cx, p.y - cy);
return clamp(1 - d / Math.max(vw, vh) * 0.65, 0.35, 1);
},
beginVoice(dur = 0.1) {
this.activeVoices = (this.activeVoices || 0) + 1;
setTimeout(() => { this.activeVoices = Math.max(0, (this.activeVoices || 0) - 1); }, Math.max(60, dur * 1000 + 80));
},
setCategory(category, on) {
if (!(category in this.categoryEnabled)) return;
this.categoryEnabled[category] = Boolean(on);
this.saveSettings();
},
toggleCategory(category) {
if (!(category in this.categoryEnabled)) return false;
this.setCategory(category, !this.categoryEnabled[category]);
return this.categoryEnabled[category];
},
setEnabled(on) {
this.enabled = Boolean(on);
if (this.enabled) this.ensure();
this.saveSettings();
return this.enabled;
},
toggle() {
return this.setEnabled(!this.enabled);
},
canPlay(id, category = null, minGap = 0.04) {
if (!this.enabled) return false;
const cat = category || this.categoryFor(id);
if (!this.categoryOn(cat)) return false;
if ((this.activeVoices || 0) >= (this.maxConcurrent || 16) && cat !== "notify") return false;
const gap = Math.max(Number(minGap) || 0, this.idMinGap?.[id] ?? 0);
const t = performance.now() / 1000;
if (t - (this.sampleLast[id] || 0) < gap) return false;
this.sampleLast[id] = t;
return true;
},
playSample(key, minGap = 0.16, category = null) {
const base = this.samples[key];
if (!base) return false;
const cat = category || this.categoryFor(key, key.startsWith("voice") ? "voice" : "ops");
if (!this.canPlay(key, cat, minGap)) return false;
this.lastPlayedId = key;
this.lastPlayedAt = performance.now() / 1000;
window.TarinaiEvents?.emit("audio:play", { id: key, category: cat, synthetic: false });
this.ensure();
const node = base.cloneNode(true);
node.volume = Math.min(1, base.volume * (this.categoryGain?.[cat] ?? 1) * (this.idGain?.[key] ?? 1));
this.beginVoice(Math.max(0.08, Number(node.duration) || 0.16));
node.play().catch(() => {});
return true;
},
tone(freq, dur = 0.08, type = "sine", gain = 0.035, slide = 0, category = "ops") {
if (!this.enabled || !this.categoryOn(category)) return;
const ctx = this.ensure();
if (!ctx) return;
if ((this.activeVoices || 0) >= (this.maxConcurrent || 16) && category !== "notify") return;
this.beginVoice(dur);
gain = Math.min(0.95, Math.max(0.0002, gain * (this.categoryGain?.[category] ?? 1) * (this._synthIdGain || 1)));
const t0 = ctx.currentTime;
const osc = ctx.createOscillator();
const g = ctx.createGain();
osc.type = type;
osc.frequency.setValueAtTime(freq, t0);
if (slide) osc.frequency.linearRampToValueAtTime(Math.max(36, freq + slide), t0 + dur);
g.gain.setValueAtTime(0.0001, t0);
g.gain.exponentialRampToValueAtTime(Math.max(0.0002, gain), t0 + Math.min(0.018, dur * 0.35));
g.gain.exponentialRampToValueAtTime(0.0001, t0 + dur);
osc.connect(g).connect(this.outputNode() || ctx.destination);
osc.start(t0);
osc.stop(t0 + dur + 0.03);
},
noise(dur = 0.08, gain = 0.030, category = "ops", filterFreq = 900) {
if (!this.enabled || !this.categoryOn(category)) return;
const ctx = this.ensure();
if (!ctx) return;
if ((this.activeVoices || 0) >= (this.maxConcurrent || 16) && category !== "notify") return;
this.beginVoice(dur);
gain = Math.min(0.90, Math.max(0.0002, gain * (this.categoryGain?.[category] ?? 1) * (this._synthIdGain || 1)));
const length = Math.max(1, Math.floor(ctx.sampleRate * dur));
const buffer = ctx.createBuffer(1, length, ctx.sampleRate);
const data = buffer.getChannelData(0);
for (let i = 0; i < length; i++) data[i] = (Math.random() * 2 - 1) * Math.pow(1 - i / length, 1.5);
const src = ctx.createBufferSource();
const filter = ctx.createBiquadFilter();
const g = ctx.createGain();
filter.type = "lowpass";
filter.frequency.value = filterFreq;
g.gain.setValueAtTime(gain, ctx.currentTime);
g.gain.exponentialRampToValueAtTime(0.0001, ctx.currentTime + dur);
src.buffer = buffer;
src.connect(filter).connect(g).connect(this.outputNode() || ctx.destination);
src.start();
},
play(id, opts = {}) {
const category = opts.category || this.categoryFor(id, "ops");
const minGap = opts.minGap ?? 0.10;
if (!this.canPlay(id, category, minGap)) return false;
this.lastPlayedId = id;
this.lastPlayedAt = performance.now() / 1000;
window.TarinaiEvents?.emit("audio:play", { id, category, synthetic: !this.samples[id] });
if (this.samples[id]) {
const base = this.samples[id];
this.ensure();
const node = base.cloneNode(true);
node.volume = Math.min(1, base.volume * (this.categoryGain?.[category] ?? 1) * (this.idGain?.[id] ?? 1));
this.beginVoice(Math.max(0.08, Number(node.duration) || 0.16));
node.play().catch(() => {});
return true;
}
this.synth(id, category);
return true;
},
synth(id, category) {
const prevSynthGain = this._synthIdGain || 1;
this._synthIdGain = Math.max(0.25, this.idGain?.[id] ?? 1);
const t = (ms, fn) => setTimeout(() => {
const prev = this._synthIdGain || 1;
this._synthIdGain = Math.max(0.25, this.idGain?.[id] ?? 1);
try { fn(); } finally { this._synthIdGain = prev; }
}, ms);
try {
switch (id) {
case "sfx_notify": case "sfx_log_push":
this.tone(880, 0.055, "sine", 0.022, 80, category); t(62, () => this.tone(1180, 0.075, "triangle", 0.020, -40, category)); break;
case "sfx_ui_click": this.tone(520, 0.035, "square", 0.010, 80, category); break;
case "sfx_ui_fold": this.tone(420, 0.045, "triangle", 0.012, -130, category); break;
case "sfx_place": this.tone(360, 0.055, "triangle", 0.020, -80, category); break;
case "sfx_delete": this.noise(0.06, 0.018, category, 1600); this.tone(740, 0.045, "sine", 0.012, -240, category); break;
case "sfx_poke": this.tone(180, 0.085, "sawtooth", 0.023, -70, category); break;
case "sfx_grab": this.tone(300, 0.050, "triangle", 0.018, 90, category); break;
case "sfx_drop": this.tone(220, 0.060, "triangle", 0.018, -80, category); break;
case "sfx_water_hose": this.noise(0.13, 0.018, category, 2400); break;
case "sfx_water_clean": this.noise(0.08, 0.014, category, 3200); this.tone(1120, 0.040, "sine", 0.009, 180, category); break;
case "sfx_drink": this.tone(720, 0.045, "sine", 0.012, -120, category); t(42, () => this.tone(620, 0.040, "sine", 0.010, -80, category)); break;
case "sfx_ball_hit": this.tone(260, 0.065, "triangle", 0.020, 120, category); break;
case "sfx_stone_impact": this.tone(110, 0.080, "sawtooth", 0.032, -50, category); this.noise(0.05, 0.018, category, 600); break;
case "sfx_firecracker_fuse": this.noise(0.18, 0.018, category, 5200); break;
case "sfx_firecracker_explode": this.tone(90, 0.120, "sawtooth", 0.060, -30, category); t(42, () => this.tone(55, 0.200, "square", 0.040, -20, category)); this.noise(0.16, 0.032, category, 900); break;
case "sfx_genkotsu_fall": this.tone(760, 0.20, "sawtooth", 0.018, -500, category); this.noise(0.16, 0.018, category, 1800); break;
case "sfx_genkotsu_impact": this.tone(64, 0.20, "square", 0.070, -18, category); this.noise(0.18, 0.040, category, 720); break;
case "sfx_ground_rumble": this.tone(46, 0.34, "sawtooth", 0.032, -8, category); t(120, () => this.tone(39, 0.32, "triangle", 0.026, 6, category)); break;
case "sfx_grass": this.noise(0.08, 0.011, category, 3000); break;
case "sfx_zunchi": this.tone(96, 0.070, "triangle", 0.014, -24, category); this.noise(0.05, 0.010, category, 460); break;
case "sfx_ant_drag": this.noise(0.12, 0.012, category, 1800); this.tone(210, 0.040, "triangle", 0.010, -70, category); break;
case "sfx_ant_die": this.tone(150, 0.038, "square", 0.012, -55, category); break;
case "sfx_ant_nest": this.noise(0.11, 0.018, category, 780); this.tone(170, 0.060, "triangle", 0.014, -40, category); break;
case "sfx_queen_ant": this.tone(240, 0.085, "triangle", 0.025, -40, category); t(70, () => this.noise(0.08, 0.014, category, 1300)); break;
case "sfx_birth": this.tone(520, 0.08, "sine", 0.028, 100, category); t(70, () => this.tone(760, 0.10, "triangle", 0.024, 80, category)); break;
case "sfx_death": this.tone(150, 0.22, "sine", 0.028, -70, category); break;
case "sfx_damage_soft": this.tone(300, 0.055, "triangle", 0.018, -90, category); break;
case "sfx_damage_heavy": this.tone(120, 0.090, "sawtooth", 0.028, -70, category); break;
case "sfx_fight_hit": this.tone(110, 0.055, "square", 0.018, 70, category); t(35, () => this.tone(90, 0.045, "sawtooth", 0.014, -40, category)); break;
case "sfx_fight_finish": this.tone(150, 0.090, "triangle", 0.024, -60, category); t(80, () => this.tone(260, 0.070, "sine", 0.016, 60, category)); break;
case "sfx_sleep": this.tone(320, 0.12, "sine", 0.016, -90, category); break;
case "sfx_wake": this.tone(650, 0.055, "triangle", 0.017, 170, category); break;
case "sfx_disease": this.tone(210, 0.11, "sawtooth", 0.020, -130, category); this.noise(0.07, 0.012, category, 700); break;
case "sfx_heal": this.tone(720, 0.060, "sine", 0.018, 150, category); t(55, () => this.tone(960, 0.075, "triangle", 0.018, 130, category)); break;
case "sfx_phase": this.tone(440, 0.06, "sine", 0.018, 60, category); break;
default: this.tone(440, 0.06, "sine", 0.016, 0, category);
}
} finally {
this._synthIdGain = prevSynthGain;
}
},
bubble(text) {
const s = String(text || "");
if (s.includes("\u3082\u3050")) return this.playSample("sfx_eat", 0.20, "voice") || this.play("sfx_eat", { category: "voice", minGap: 0.20 });
if (s.includes("\u3076\u308a")) return this.playSample("voice_poop", 0.28, "voice") || this.play("sfx_zunchi", { category: "ops", minGap: 0.28 });
if (s.includes("\u306f\u3063")) return this.playSample("voice_stress", 0.80, "voice") || this.play("sfx_damage_soft", { category: "voice", minGap: 0.80 });
if (s.includes("Zzz")) return this.playSample("sfx_sleep", 1.25, "voice") || this.play("sfx_sleep", { category: "voice", minGap: 1.25 });
if (s.includes("\u306f\u3041") || s.includes("\u306f\u3042")) return this.playSample("voice_flee", 0.70, "voice") || this.play("sfx_damage_heavy", { category: "voice", minGap: 0.70 });
if (s.includes("\u306f\u3046") || s.includes("\u306f\u3045")) return this.playSample("voice_hau", 0.45, "voice") || this.play("sfx_wake", { category: "voice", minGap: 0.45 });
return false;
},
eat() { if (!this.playSample("sfx_eat", 0.20, "voice")) this.play("sfx_eat", { category: "voice", minGap: 0.20 }); },
poke() { this.play("sfx_poke", { category: "ops", minGap: 0.07 }); },
birth() { this.play("sfx_birth", { category: "voice", minGap: 0.18 }); },
death() { this.play("sfx_death", { category: "voice", minGap: 0.20 }); },
fight() { this.play("sfx_fight_hit", { category: "voice", minGap: 0.09 }); },
fightFinish() { this.play("sfx_fight_finish", { category: "voice", minGap: 0.40 }); },
explode() { if (!this.playSample("sfx_firecracker_explode", 0.30, "ops")) this.play("sfx_firecracker_explode", { category: "ops", minGap: 0.30 }); },
phase() { this.play("sfx_phase", { category: "notify", minGap: 1.0 }); },
notify() { this.play("sfx_notify", { category: "notify", minGap: 0.12 }); },
uiClick() { this.play("sfx_ui_click", { category: "ops", minGap: 0.035 }); },
uiFold() { this.play("sfx_ui_fold", { category: "ops", minGap: 0.08 }); },
place(type = "") {
if (type === "genkotsu") return this.play("sfx_genkotsu_fall", { category: "ops", minGap: 0.10 });
if (type === "firecracker") return this.play("sfx_firecracker_fuse", { category: "ops", minGap: 0.18 });
if (type === "grass") return this.play("sfx_grass", { category: "ops", minGap: 0.08 });
if (type === "zunchi") return this.play("sfx_zunchi", { category: "ops", minGap: 0.12 });
if (type === "ant_nest" || type === "nest_box") return this.play("sfx_ant_nest", { category: "ops", minGap: 0.12 });
return this.play("sfx_place", { category: "ops", minGap: 0.08 });
},
delete() { this.play("sfx_delete", { category: "ops", minGap: 0.08 }); },
grab() { this.play("sfx_grab", { category: "ops", minGap: 0.08 }); },
drop() { this.play("sfx_drop", { category: "ops", minGap: 0.08 }); },
waterHose() { this.play("sfx_water_hose", { category: "ops", minGap: 0.20 }); },
waterClean() { this.play("sfx_water_clean", { category: "ops", minGap: 0.22 }); },
ballHit() { this.play("sfx_ball_hit", { category: "ops", minGap: 0.08 }); },
stoneImpact() { this.play("sfx_stone_impact", { category: "ops", minGap: 0.18 }); },
genkotsuImpact() { this.play("sfx_genkotsu_impact", { category: "ops", minGap: 0.20 }); setTimeout(() => this.play("sfx_ground_rumble", { category: "ops", minGap: 0.20 }), 80); },
damage(amount = 0) { this.play(amount >= 18 ? "sfx_damage_heavy" : "sfx_damage_soft", { category: "voice", minGap: amount >= 18 ? 0.16 : 0.09 }); },
disease() { this.play("sfx_disease", { category: "voice", minGap: 0.55 }); },
heal() { this.play("sfx_heal", { category: "voice", minGap: 0.40 }); },
antDrag() { this.play("sfx_ant_drag", { category: "voice", minGap: 0.50 }); },
antDie() { this.play("sfx_ant_die", { category: "voice", minGap: 0.18 }); },
antNest() { this.play("sfx_ant_nest", { category: "ops", minGap: 0.28 }); },
queenAnt() { this.play("sfx_queen_ant", { category: "voice", minGap: 0.80 }); },
};
audio.applySoundPack?.(window.TARINAI_SOUND_PACK);
audio.loadSettings();
window.TarinaiEvents?.on?.("audio:test", ev => audio.play(ev.detail?.id || "sfx_notify", ev.detail || {}));
window.TarinaiAudio = audio;