kgames

KGames — free keyboard games for kids
git clone https://www.keyboard.games/code/kgames.git
Log | Files | Refs | README | LICENSE

audio.js (12380B)


      1 // Story Stamp — audio: recorded phonemes + words (Web Audio), speech synthesis
      2 // for everything else, and a few oscillator SFX.
      3 //
      4 // Recorded clips are listed in assets/manifest.js (generated by
      5 // tools/story-stamp-assets.py). Held sounds (/sss/, /mmm/, vowels) are long
      6 // recordings; a blend pass asks for a length and the clip is cut there with a
      7 // short fade — nothing is looped. A word with no recording is spoken by the
      8 // browser instead; phonemes are never synthesised (a synthetic "suh" would
      9 // break blending), so a phoneme with no clip is simply silent.
     10 (function (root) {
     11   'use strict';
     12 
     13   const SS = root.SS;
     14   const TEST = !!root.__TEST_MODE__;
     15   const scale = (ms) => (TEST ? ms * 0.1 : ms);   // test mode runs every wait at 10% speed
     16 
     17   class StoryStampAudio {
     18     constructor(assets, base) {
     19       this.assets  = assets || { phonemes: {}, words: {} };
     20       this.base    = base || 'assets/audio/';
     21       this.ctx     = null;
     22       this.ready   = false;
     23       this.buffers = {};        // key -> { buffer, offset } | null (failed)
     24       this.pending = {};        // key -> Promise
     25       this.log     = [];        // every clip played, for tests: { kind, id, len, t, at }
     26       this.voiceMuted = false;
     27       this.sfxMuted   = false;
     28       this._voice  = null;
     29       this._unlock = null;
     30       this._active = [];        // scheduled sources, so a blend can be cancelled
     31       this._timers = [];
     32     }
     33 
     34     // Create the context and preload the phoneme set. Safe to call repeatedly.
     35     init() {
     36       if (this.ctx) return;
     37       const AC = root.AudioContext || root.webkitAudioContext;
     38       if (!AC) return;
     39       try { this.ctx = new AC(); } catch (_) { return; }
     40       this.master = this.ctx.createGain();
     41       this.master.connect(this.ctx.destination);
     42       if (root.KGames && root.KGames.installAudioUnlock) {
     43         this._unlock = root.KGames.installAudioUnlock(() => [this.ctx], () => { this.ready = true; });
     44       } else {
     45         this.ready = true;
     46       }
     47       Object.keys(this.assets.phonemes || {}).forEach((id) => this.load('ph:' + id, this.assets.phonemes[id].file));
     48     }
     49 
     50     // ── loading ────────────────────────────────────────────────────────────
     51 
     52     load(key, file) {
     53       if (this.buffers[key] !== undefined) return Promise.resolve(this.buffers[key]);
     54       if (this.pending[key]) return this.pending[key];
     55       const url = this.base + file;
     56       const p = fetch(url)
     57         .then((r) => { if (!r.ok) throw new Error(r.status); return r.arrayBuffer(); })
     58         .then((ab) => new Promise((res, rej) => this.ctx.decodeAudioData(ab, res, rej)))
     59         .then((buffer) => {
     60           // Skip the encoder's leading silence so blends stay tight.
     61           const d = buffer.getChannelData(0);
     62           let i = 0;
     63           while (i < d.length && Math.abs(d[i]) < 0.012) i++;
     64           const entry = { buffer, offset: Math.max(0, i - 40) / buffer.sampleRate };
     65           this.buffers[key] = entry;
     66           return entry;
     67         })
     68         .catch(() => { this.buffers[key] = null; return null; });
     69       this.pending[key] = p;
     70       return p;
     71     }
     72 
     73     hasWord(w)    { return !!(this.assets.words && this.assets.words[w]); }
     74     hasPhoneme(p) { return !!(this.assets.phonemes && this.assets.phonemes[p]); }
     75 
     76     // Natural length of a clip in seconds (for timeline planning).
     77     phonemeDuration(p) {
     78       const e = this.buffers['ph:' + p];
     79       if (e) return e.buffer.duration - e.offset;
     80       const m = this.assets.phonemes && this.assets.phonemes[p];
     81       return m ? m.dur : 0.25;
     82     }
     83     phonemeDurations() {
     84       const out = {};
     85       Object.keys(SS.PHONEMES).forEach((p) => { out[p] = this.phonemeDuration(p); });
     86       return out;
     87     }
     88 
     89     preloadWord(w) {
     90       if (!this.ctx || !this.hasWord(w)) return Promise.resolve(null);
     91       return this.load('w:' + w, this.assets.words[w].file);
     92     }
     93 
     94     // ── playback ───────────────────────────────────────────────────────────
     95 
     96     _now() { return this.ctx ? this.ctx.currentTime : 0; }
     97 
     98     // Play one recorded phoneme. `len` (seconds) shortens a held sound; stops
     99     // and unstretchable sounds always play whole. Returns the seconds it takes.
    100     playPhoneme(id, opts) {
    101       opts = opts || {};
    102       const delay = opts.delay || 0;
    103       const nat = this.phonemeDuration(id);
    104       const len = SS.phonemeLength(id, opts.len == null ? nat : opts.len, { [id]: nat });
    105       // `at` is when the clip will sound (log order is schedule order, not play order)
    106       this.log.push({ kind: 'phoneme', id, len, t: Date.now(), at: Date.now() + scale(delay * 1000) });
    107       if (!this.ctx) return len;
    108       const entry = this.buffers['ph:' + id];
    109       if (!entry) return len;
    110       const at = this._now() + delay;
    111       const src = this.ctx.createBufferSource();
    112       src.buffer = entry.buffer;
    113       const g = this.ctx.createGain();
    114       src.connect(g); g.connect(this.master);
    115       const fadeOut = len < nat - 0.01 ? 0.045 : 0.02;
    116       g.gain.setValueAtTime(1, at);
    117       g.gain.setValueAtTime(1, at + Math.max(0, len - fadeOut));
    118       g.gain.linearRampToValueAtTime(0.0001, at + len);
    119       src.start(at, entry.offset);
    120       src.stop(at + len + 0.01);
    121       this._active.push(src);
    122       return len;
    123     }
    124 
    125     // Play the recorded word, or speak it. Resolves when it is (about) done.
    126     playWord(w, opts) {
    127       opts = opts || {};
    128       this.log.push({ kind: 'word', id: w, t: Date.now(), at: Date.now() + scale((opts.delay || 0) * 1000) });
    129       if (this.ctx && this.hasWord(w)) {
    130         return this.preloadWord(w).then((entry) => {
    131           if (!entry) return this.speak(w, opts.voice);
    132           const at = this._now() + (opts.delay || 0);
    133           const src = this.ctx.createBufferSource();
    134           src.buffer = entry.buffer;
    135           src.connect(this.master);
    136           src.start(at, entry.offset);
    137           this._active.push(src);
    138           const dur = entry.buffer.duration - entry.offset;
    139           return this._sleep(((opts.delay || 0) + dur) * 1000);
    140         });
    141       }
    142       return this.speak(w, opts.voice);
    143     }
    144 
    145     // All the sounds of a word with clear gaps (used to re-demonstrate).
    146     playSegmented(units, opts) {
    147       opts = opts || {};
    148       const gap = opts.gap == null ? 0.35 : opts.gap;
    149       const len = opts.len == null ? 0.45 : opts.len;
    150       let t = 0;
    151       units.forEach((unit, ui) => {
    152         if (opts.onUnit) this._timer(() => opts.onUnit(ui), t * 1000);
    153         unit.forEach((p) => { t += this.playPhoneme(p, { delay: t, len }) + 0.02; });
    154         if (ui < units.length - 1) t += gap;
    155       });
    156       return this._sleep(t * 1000 + 100);
    157     }
    158 
    159     // Sounding out: run SS.blendTimeline and fire onEvent for each event at
    160     // the right moment (visuals hang off these). The word itself is never
    161     // played — the child says it. Resolves when the last pass has faded.
    162     blend(units, onEvent) {
    163       const durations = this.phonemeDurations();
    164       const events = SS.blendTimeline(units, durations);
    165       let last = 0;
    166       events.forEach((ev) => {
    167         if (ev.kind === 'phoneme') this.playPhoneme(ev.id, { delay: ev.t, len: ev.len });
    168         if (onEvent) this._timer(() => onEvent(ev), ev.t * 1000);
    169         last = Math.max(last, ev.t);
    170       });
    171       return this._sleep(last * 1000).then(() => events);
    172     }
    173 
    174     // Stop every scheduled clip and pending callback (e.g. leaving a screen).
    175     cancel() {
    176       this._active.forEach((s) => { try { s.stop(); } catch (_) {} });
    177       this._active = [];
    178       this._timers.forEach(clearTimeout);
    179       this._timers = [];
    180       try { if (root.speechSynthesis) root.speechSynthesis.cancel(); } catch (_) {}
    181     }
    182 
    183     // ── speech synthesis ───────────────────────────────────────────────────
    184 
    185     _pickVoice() {
    186       if (this._voice || !root.speechSynthesis) return this._voice;
    187       let voices = [];
    188       try { voices = root.speechSynthesis.getVoices() || []; } catch (_) {}
    189       const en = voices.filter((v) => /^en[-_]/i.test(v.lang));
    190       const prefer = en.find((v) => /child|kid|samantha|karen|moira|google us english/i.test(v.name));
    191       this._voice = prefer || en.find((v) => /^en[-_]US/i.test(v.lang)) || en[0] || null;
    192       return this._voice;
    193     }
    194 
    195     // Speak text. Resolves when finished, or after an estimate if the browser
    196     // never reports the end (headless / no voices installed).
    197     speak(text, voice) {
    198       this.log.push({ kind: 'speak', id: text, t: Date.now(), at: Date.now() });
    199       const est = Math.min(6000, 350 + 65 * text.length);
    200       if (TEST) return this._sleep(0);
    201       if (this.voiceMuted) return this._sleep(150);          // muted: keep the flow moving
    202       if (!root.speechSynthesis) return this._sleep(est);    // no TTS at all: leave time to read
    203       return new Promise((resolve) => {
    204         let done = false;
    205         const finish = () => { if (!done) { done = true; resolve(); } };
    206         try {
    207           const u = new SpeechSynthesisUtterance(text);
    208           const v = this._pickVoice();
    209           if (v) u.voice = v;
    210           u.lang  = (v && v.lang) || 'en-US';
    211           u.pitch = voice && voice.pitch != null ? voice.pitch : 1.05;
    212           u.rate  = voice && voice.rate  != null ? voice.rate  : 0.95;
    213           u.onend = finish; u.onerror = finish;
    214           root.speechSynthesis.cancel();
    215           root.speechSynthesis.speak(u);
    216           setTimeout(finish, est + 2500);
    217         } catch (_) { finish(); }
    218       });
    219     }
    220 
    221     // ── SFX (oscillators; incidental, not the taught audio) ───────────────
    222 
    223     _tone(freq, dur, type, gain, when, slideTo) {
    224       if (!this.ctx || this.sfxMuted) return;
    225       const at = this._now() + (when || 0);
    226       const o = this.ctx.createOscillator();
    227       const g = this.ctx.createGain();
    228       o.type = type || 'sine';
    229       o.frequency.setValueAtTime(freq, at);
    230       if (slideTo) o.frequency.exponentialRampToValueAtTime(slideTo, at + dur);
    231       g.gain.setValueAtTime(0.0001, at);
    232       g.gain.exponentialRampToValueAtTime(gain || 0.15, at + 0.01);
    233       g.gain.exponentialRampToValueAtTime(0.0001, at + dur);
    234       o.connect(g); g.connect(this.master);
    235       o.start(at); o.stop(at + dur + 0.02);
    236     }
    237     _noise(dur, gain, when) {
    238       if (!this.ctx || this.sfxMuted) return;
    239       const at = this._now() + (when || 0);
    240       const n = Math.floor(this.ctx.sampleRate * dur);
    241       const buf = this.ctx.createBuffer(1, n, this.ctx.sampleRate);
    242       const d = buf.getChannelData(0);
    243       for (let i = 0; i < n; i++) d[i] = (Math.random() * 2 - 1) * (1 - i / n);
    244       const src = this.ctx.createBufferSource(); src.buffer = buf;
    245       const g = this.ctx.createGain(); g.gain.setValueAtTime(gain || 0.1, at);
    246       src.connect(g); g.connect(this.master); src.start(at);
    247     }
    248     stampLetter() { this._tone(180, 0.09, 'square', 0.05, 0, 90); this._noise(0.05, 0.05); }
    249     bigStamp()    { this._tone(90, 0.22, 'triangle', 0.25, 0, 45); this._noise(0.12, 0.12); }
    250     tick()        { this._tone(900, 0.05, 'square', 0.04); }
    251     wrong()       { this._tone(200, 0.25, 'triangle', 0.12, 0, 150); }
    252     ding()        { this._tone(880, 0.35, 'sine', 0.12); this._tone(1320, 0.5, 'sine', 0.08, 0.08); }
    253     sparkle()     { [0, 0.07, 0.14, 0.21].forEach((d, i) => this._tone(1200 + i * 300, 0.25, 'sine', 0.07, d)); }
    254     fanfare()     { [523, 659, 784, 1046].forEach((f, i) => this._tone(f, 0.3, 'triangle', 0.1, i * 0.11)); }
    255     whoosh()      { this._noise(0.25, 0.06); }
    256     pageFlip()    { this._noise(0.08, 0.05); this._noise(0.06, 0.04, 0.09); }
    257     slide()       { this._tone(300, 0.3, 'sine', 0.05, 0, 600); }
    258 
    259     // ── utils ──────────────────────────────────────────────────────────────
    260     _timer(fn, ms) { const id = setTimeout(fn, scale(ms)); this._timers.push(id); return id; }
    261     _sleep(ms)     { return new Promise((r) => this._timer(r, ms)); }
    262   }
    263 
    264   root.StoryStampAudio = StoryStampAudio;
    265 }(window));