Files

368 lines
12 KiB
JavaScript

/**
* TaskMate shared sound engine.
*
* Single home for every completion sound, so the child card, the admin panel
* and the config-flow preview module all behave identically. Before this
* existed the six synthesised sounds were copy-pasted into two files that had
* already drifted apart (one still knew only about a long-gone "fart"/"fart_long"
* pair while the real list had moved to fart1..fart10).
*
* Three kinds of sound:
* synthesised — generated on the fly via Web Audio API, no files needed
* built-in files — the CC0 fart mp3s shipped in this www folder
* custom — user-uploaded, referenced as "custom:<file>" (#856)
*
* A custom sound is resolved through a lookup table the caller passes in (the
* `custom_sounds` attribute of sensor.taskmate_chores), because its URL is
* signed server-side — a bare Audio() carries no bearer token and would 401
* against the auth-gated serve view.
*
* Mirrors window.__taskmate_design: exposed globally, no ES module imports.
*/
(function () {
"use strict";
// Base URL for files shipped inside the integration's www folder. Registered
// by frontend.py as a static path. NOT /local/, which maps to config/www and
// is where these files have never lived — that mismatch silently 404'd every
// fart sound until #856.
const FILE_BASE = "/taskmate";
const CUSTOM_PREFIX = "custom:";
const FART_COUNT = 10;
// Built-in names, mirroring COMPLETION_SOUND_OPTIONS in const.py.
const BUILTIN = [
"none", "coin", "levelup", "fanfare", "chime", "powerup", "undo",
"fart1", "fart2", "fart3", "fart4", "fart5", "fart6", "fart7",
"fart8", "fart9", "fart10", "fart_random",
];
// One shared AudioContext per page. Browsers cap how many a page may create,
// and each card minting its own used to leak one per card instance.
let audioContext = null;
function getAudioContext() {
if (!audioContext) {
const Ctor = window.AudioContext || window.webkitAudioContext;
if (!Ctor) return null;
audioContext = new Ctor();
}
// Autoplay policy: a context created before any user gesture starts
// suspended and stays silent until resumed.
if (audioContext.state === "suspended") audioContext.resume();
return audioContext;
}
function playCoinSound(ctx, startTime) {
const masterGain = ctx.createGain();
masterGain.connect(ctx.destination);
masterGain.gain.value = 0.3;
// First tone (E6)
const osc1 = ctx.createOscillator();
const gain1 = ctx.createGain();
osc1.connect(gain1);
gain1.connect(masterGain);
osc1.frequency.value = 1318.5; // E6
osc1.type = 'square';
gain1.gain.setValueAtTime(0.5, startTime);
gain1.gain.exponentialRampToValueAtTime(0.01, startTime + 0.1);
osc1.start(startTime);
osc1.stop(startTime + 0.1);
// Second tone (B6) - higher
const osc2 = ctx.createOscillator();
const gain2 = ctx.createGain();
osc2.connect(gain2);
gain2.connect(masterGain);
osc2.frequency.value = 1975.5; // B6
osc2.type = 'square';
gain2.gain.setValueAtTime(0.5, startTime + 0.08);
gain2.gain.exponentialRampToValueAtTime(0.01, startTime + 0.25);
osc2.start(startTime + 0.08);
osc2.stop(startTime + 0.25);
}
/**
* Level up sound - triumphant ascending arpeggio
*/
function playLevelUpSound(ctx, startTime) {
const masterGain = ctx.createGain();
masterGain.connect(ctx.destination);
masterGain.gain.value = 0.25;
// C major arpeggio going up: C5, E5, G5, C6
const notes = [523.25, 659.25, 783.99, 1046.5];
const duration = 0.12;
notes.forEach((freq, i) => {
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.connect(gain);
gain.connect(masterGain);
osc.frequency.value = freq;
osc.type = 'square';
const noteStart = startTime + i * duration;
gain.gain.setValueAtTime(0.6, noteStart);
gain.gain.exponentialRampToValueAtTime(0.01, noteStart + duration + 0.1);
osc.start(noteStart);
osc.stop(noteStart + duration + 0.15);
});
// Final sustained chord
const chordNotes = [523.25, 659.25, 783.99]; // C major chord
const chordStart = startTime + notes.length * duration;
chordNotes.forEach((freq) => {
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.connect(gain);
gain.connect(masterGain);
osc.frequency.value = freq;
osc.type = 'triangle';
gain.gain.setValueAtTime(0.3, chordStart);
gain.gain.exponentialRampToValueAtTime(0.01, chordStart + 0.5);
osc.start(chordStart);
osc.stop(chordStart + 0.55);
});
}
/**
* Fanfare sound - celebratory trumpet-like fanfare
*/
function playFanfareSound(ctx, startTime) {
const masterGain = ctx.createGain();
masterGain.connect(ctx.destination);
masterGain.gain.value = 0.2;
// Fanfare pattern: G4, G4, G4, E4, G4, C5 (classic celebration pattern)
const pattern = [
{ freq: 392.00, duration: 0.1, delay: 0 }, // G4
{ freq: 392.00, duration: 0.1, delay: 0.12 }, // G4
{ freq: 392.00, duration: 0.15, delay: 0.24 }, // G4
{ freq: 329.63, duration: 0.15, delay: 0.42 }, // E4
{ freq: 392.00, duration: 0.15, delay: 0.6 }, // G4
{ freq: 523.25, duration: 0.4, delay: 0.78 }, // C5 (long final note)
];
pattern.forEach(({ freq, duration, delay }) => {
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.connect(gain);
gain.connect(masterGain);
osc.frequency.value = freq;
osc.type = 'sawtooth';
const noteStart = startTime + delay;
gain.gain.setValueAtTime(0.5, noteStart);
gain.gain.setValueAtTime(0.5, noteStart + duration * 0.8);
gain.gain.exponentialRampToValueAtTime(0.01, noteStart + duration);
osc.start(noteStart);
osc.stop(noteStart + duration + 0.05);
});
}
/**
* Chime sound - simple pleasant bell chime
*/
function playChimeSound(ctx, startTime) {
const masterGain = ctx.createGain();
masterGain.connect(ctx.destination);
masterGain.gain.value = 0.3;
// Bell-like sound using multiple harmonics
const fundamental = 880; // A5
const harmonics = [1, 2, 3, 4.2]; // Slight inharmonicity for bell-like quality
harmonics.forEach((harmonic, i) => {
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.connect(gain);
gain.connect(masterGain);
osc.frequency.value = fundamental * harmonic;
osc.type = 'sine';
// Higher harmonics decay faster
const amplitude = 0.5 / (i + 1);
const decayTime = 0.8 / (i + 1);
gain.gain.setValueAtTime(amplitude, startTime);
gain.gain.exponentialRampToValueAtTime(0.001, startTime + decayTime);
osc.start(startTime);
osc.stop(startTime + decayTime + 0.1);
});
}
/**
* Power up sound - ascending sweep with sparkle
*/
function playPowerUpSound(ctx, startTime) {
const masterGain = ctx.createGain();
masterGain.connect(ctx.destination);
masterGain.gain.value = 0.25;
// Ascending sweep
const osc1 = ctx.createOscillator();
const gain1 = ctx.createGain();
osc1.connect(gain1);
gain1.connect(masterGain);
osc1.type = 'sawtooth';
osc1.frequency.setValueAtTime(200, startTime);
osc1.frequency.exponentialRampToValueAtTime(1200, startTime + 0.3);
gain1.gain.setValueAtTime(0.4, startTime);
gain1.gain.exponentialRampToValueAtTime(0.01, startTime + 0.35);
osc1.start(startTime);
osc1.stop(startTime + 0.4);
// Sparkle notes at the end
const sparkleNotes = [1318.5, 1567.98, 1975.5]; // E6, G6, B6
sparkleNotes.forEach((freq, i) => {
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.connect(gain);
gain.connect(masterGain);
osc.frequency.value = freq;
osc.type = 'sine';
const noteStart = startTime + 0.25 + i * 0.05;
gain.gain.setValueAtTime(0.3, noteStart);
gain.gain.exponentialRampToValueAtTime(0.01, noteStart + 0.2);
osc.start(noteStart);
osc.stop(noteStart + 0.25);
});
}
/**
* Undo sound - sad descending "womp womp" style
* Two descending tones that sound disappointed/sad
*/
function playUndoSound(ctx, startTime) {
const masterGain = ctx.createGain();
masterGain.connect(ctx.destination);
masterGain.gain.value = 0.25;
// First "womp" - descending tone
const osc1 = ctx.createOscillator();
const gain1 = ctx.createGain();
osc1.connect(gain1);
gain1.connect(masterGain);
osc1.type = 'triangle';
osc1.frequency.setValueAtTime(311.13, startTime); // Eb4
osc1.frequency.exponentialRampToValueAtTime(233.08, startTime + 0.25); // Bb3
gain1.gain.setValueAtTime(0.6, startTime);
gain1.gain.exponentialRampToValueAtTime(0.3, startTime + 0.2);
gain1.gain.exponentialRampToValueAtTime(0.01, startTime + 0.3);
osc1.start(startTime);
osc1.stop(startTime + 0.35);
// Second "womp" - even lower descending tone (the sad part)
const osc2 = ctx.createOscillator();
const gain2 = ctx.createGain();
osc2.connect(gain2);
gain2.connect(masterGain);
osc2.type = 'triangle';
osc2.frequency.setValueAtTime(233.08, startTime + 0.3); // Bb3
osc2.frequency.exponentialRampToValueAtTime(155.56, startTime + 0.7); // Eb3
gain2.gain.setValueAtTime(0.5, startTime + 0.3);
gain2.gain.exponentialRampToValueAtTime(0.25, startTime + 0.55);
gain2.gain.exponentialRampToValueAtTime(0.01, startTime + 0.75);
osc2.start(startTime + 0.3);
osc2.stop(startTime + 0.8);
// Optional: add a subtle low vibrato for extra sadness
const osc3 = ctx.createOscillator();
const gain3 = ctx.createGain();
osc3.connect(gain3);
gain3.connect(masterGain);
osc3.type = 'sine';
osc3.frequency.setValueAtTime(116.54, startTime + 0.5); // Bb2 (sub bass)
gain3.gain.setValueAtTime(0.15, startTime + 0.5);
gain3.gain.exponentialRampToValueAtTime(0.01, startTime + 0.8);
osc3.start(startTime + 0.5);
osc3.stop(startTime + 0.85);
}
/**
* Play an audio file by URL. Used for the shipped fart mp3s and for
* user-uploaded custom sounds.
*/
function playUrl(url) {
try {
const audio = new Audio(url);
audio.volume = 1.0;
audio.play().catch(() => { /* autoplay blocked until a user gesture */ });
} catch (e) { /* no Audio support */ }
}
function playFile(filename) {
playUrl(`${FILE_BASE}/${filename}`);
}
/** True for a well-formed "custom:<32hex>.<ext>" value. Mirrors sounds.is_custom_sound. */
function isCustom(name) {
return typeof name === "string"
&& name.startsWith(CUSTOM_PREFIX)
&& /^[0-9a-f]{32}\.(mp3|ogg|wav|m4a)$/.test(name.slice(CUSTOM_PREFIX.length));
}
/**
* Resolve a custom sound value to its signed URL via the lookup table.
* `customSounds` is the list delivered on sensor.taskmate_chores.
*/
function customUrl(name, customSounds) {
if (!isCustom(name) || !Array.isArray(customSounds)) return null;
const hit = customSounds.find(s => s && s.value === name);
return (hit && hit.url) || null;
}
/**
* Play a completion sound by name.
*
* @param {string} name - built-in name, or "custom:<file>"
* @param {Array} customSounds - optional lookup table for custom sounds
*/
function play(name, customSounds) {
if (!name || name === "none") return;
if (isCustom(name)) {
const url = customUrl(name, customSounds);
// A dangling reference (sound deleted, or a card whose sensor data
// predates the upload) stays silent rather than playing the wrong thing.
if (url) playUrl(url);
return;
}
if (name === "fart_random") {
playFile(`fart${Math.floor(Math.random() * FART_COUNT) + 1}.mp3`);
return;
}
if (/^fart([1-9]|10)$/.test(name)) {
playFile(`${name}.mp3`);
return;
}
const ctx = getAudioContext();
if (!ctx) return;
const now = ctx.currentTime;
try {
switch (name) {
case "coin": playCoinSound(ctx, now); break;
case "levelup": playLevelUpSound(ctx, now); break;
case "fanfare": playFanfareSound(ctx, now); break;
case "chime": playChimeSound(ctx, now); break;
case "powerup": playPowerUpSound(ctx, now); break;
case "undo": playUndoSound(ctx, now); break;
default: playCoinSound(ctx, now); break;
}
} catch (e) { /* a failed sound must never break a completion */ }
}
window.__taskmate_sounds = { BUILTIN, CUSTOM_PREFIX, play, isCustom, customUrl, getAudioContext };
})();