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));