"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, lastPlayedId: "", masterGainNode: null, _synthIdGain: 1, idGain: {}, idMinGap: {}, categoryEnabled: { voice: false, notify: true, ops: true }, categoryGain: {}, samplePaths: {}, soundCategories: {}, soundMap: {}, _startupAudioDampenStarted: false, _startupAudioDampenUntil: 0, _currentPlayGainScale: 1, applySoundPack(pack = window.TARINAI_SOUND_PACK) { if (!pack || typeof pack !== "object") return false; 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(0.0001, ctx.currentTime); this.masterGainNode.gain.linearRampToValueAtTime(0.78, ctx.currentTime + 0.18); if (typeof ctx.createDynamicsCompressor === "function") { const compressor = ctx.createDynamicsCompressor(); compressor.threshold.value = -18; compressor.knee.value = 24; compressor.ratio.value = 8; compressor.attack.value = 0.002; compressor.release.value = 0.18; this.masterGainNode.connect(compressor).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); this.samples[key] = a; } }, categoryFor(id, fallback = "ops") { return this.soundMap[id] || fallback; }, categoryOn(category = "ops") { return Boolean(this.categoryEnabled[category] ?? true); }, 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)); }, 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(); }, 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; }, startupGainScale() { const now = performance.now() / 1000; if (!this._startupAudioDampenStarted) { this._startupAudioDampenStarted = true; this._startupAudioDampenUntil = now + 0.85; return 0.26; } if (now < (this._startupAudioDampenUntil || 0)) return 0.42; return 1; }, 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; window.TarinaiEvents.emit("audio:play", { id: key, category: cat, synthetic: false }); this.ensure(); const playScale = this.startupGainScale(); const node = base.cloneNode(true); node.volume = Math.min(1, base.volume * (this.categoryGain?.[cat] ?? 1) * (this.idGain?.[key] ?? 1) * playScale); 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) * (this._currentPlayGainScale || 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) * (this._currentPlayGainScale || 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; window.TarinaiEvents.emit("audio:play", { id, category, synthetic: !this.samples[id] }); const playScale = this.startupGainScale(); 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) * playScale); this.beginVoice(Math.max(0.08, Number(node.duration) || 0.16)); node.play().catch(() => {}); return true; } const prevPlayScale = this._currentPlayGainScale || 1; this._currentPlayGainScale = playScale; try { this.synth(id, category); } finally { this._currentPlayGainScale = prevPlayScale; } 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": 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_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("\u3076\u308B\u3076\u308B")) return this.playSample("voice_temp_cold", 1.20, "voice") || this.play("voice_temp_cold", { category: "voice", minGap: 1.20 }); if (s.includes("\u3042\u3061\u3085\u3044")) return this.playSample("voice_temp_hot", 1.20, "voice") || this.play("voice_temp_hot", { category: "voice", minGap: 1.20 }); 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 }); if (s.includes("\u307d\u304b")) return this.playSample("voice_sunbath", 0.95, "voice") || this.play("sfx_heal", { category: "voice", minGap: 0.95 }); 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" || type === "pipe") 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 }); }, shoot() { this.play("sfx_shoot", { category: "ops", minGap: 0.045 }); }, boltAction() { this.play("sfx_bolt_action", { category: "ops", minGap: 0.10 }); }, 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.TarinaiAudio = audio;