Drop the scratch probes, and stop them coming back

_audioprobe, _captureprobe, _decodetest, _encprobe, _exporttest, _muxprobe
and _playtest were one-off harnesses for debugging the decode/encode/mux
path. They were untracked working-tree files until `git add -A` in 17a583a
swept them into that commit — my mistake, not a deliberate decision.

Removed, and _*.html added to .gitignore so a broad `git add` cannot pick up
the next batch either.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Dejvino 2026-08-06 20:09:56 +02:00
parent 8f5ff3a239
commit c92c398d18
8 changed files with 4 additions and 594 deletions

View File

@ -2,3 +2,7 @@ node_modules
dist dist
.vite .vite
out out
# Scratch probes: one-off harnesses for debugging decode/encode/mux issues.
# They are throwaway by nature and were committed once by accident.
_*.html

View File

@ -1,52 +0,0 @@
<!DOCTYPE html>
<html>
<head><title>audio probe</title></head>
<body>
<div id="out">running…</div>
<script type="module">
const out = document.getElementById('out');
const log = (m) => { out.textContent += m + '\n'; };
for (const sr of [44100, 48000]) {
try {
const s = await AudioEncoder.isConfigSupported({ codec: 'opus', sampleRate: sr, numberOfChannels: 2, bitrate: 160000 });
log(`opus @ ${sr}: supported=${s.supported}`);
} catch (e) { log(`opus @ ${sr}: threw ${e.message}`); }
}
for (const sr of [44100, 48000]) {
try {
const s = await AudioEncoder.isConfigSupported({ codec: 'mp4a.40.2', sampleRate: sr, numberOfChannels: 2, bitrate: 192000 });
log(`aac @ ${sr}: supported=${s.supported}`);
} catch (e) { log(`aac @ ${sr}: threw ${e.message}`); }
}
// Encode a 44100 stream with opus and inspect the output chunk metadata.
try {
const chunks = [];
const enc = new AudioEncoder({
output: (chunk, meta) => chunks.push({ ts: chunk.timestamp, dur: chunk.duration }),
error: (e) => log('enc error ' + e.message),
});
enc.configure({ codec: 'opus', sampleRate: 44100, numberOfChannels: 2, bitrate: 160000 });
const n = 44100; // 1 second
const data = new Float32Array(n * 2);
let td;
for (let i = 0; i < 10; i++) {
td = new AudioData({ format: 'f32', sampleRate: 44100, numberOfFrames: 4096, numberOfChannels: 2, timestamp: i * 4096 / 44100 * 1e6, data: data });
enc.encode(td); td.close();
}
await enc.flush();
log(`opus@44100 chunks: ${chunks.length}`);
if (chunks.length) {
const deltas = chunks.slice(1).map((c, i) => c.ts - chunks[i].ts);
log('first chunk ts/dur: ' + chunks[0].ts + '/' + chunks[0].dur);
log('deltas: ' + deltas.slice(0, 5).join(' '));
log('last ts: ' + chunks[chunks.length - 1].ts);
}
enc.close();
} catch (e) { log('opus@44100 encode threw: ' + e.message); }
log('DONE');
window.__AUDIO_PROBE__ = true;
</script>
</body>
</html>

View File

@ -1,128 +0,0 @@
<!DOCTYPE html>
<html>
<head><title>capture probe</title></head>
<body>
<canvas id="c" width="1" height="1"></canvas>
<pre id="out">running…</pre>
<script type="module">
import { Show } from '/src/Show.js';
import { FeatureTrack } from '/src/audio/FeatureTrack.js';
import { synthesizeSectioned } from '/src/audio/synth.js';
import { generateLook } from '/src/look/LookGenerator.js';
import { ArcDriver } from '/src/look/ArcDriver.js';
const out = document.getElementById('out');
const log = (m) => { out.textContent += m + '\n'; console.log(m); };
const q = new URLSearchParams(location.search);
const SECONDS = Number(q.get('seconds') || 10);
const WIDTH = Number(q.get('width') || 1920);
const HEIGHT = Number(q.get('height') || 1080);
const FPS = 60;
const BITRATE = 16_000_000;
// Real render path: the same Show the exporter drives.
const show = new Show({ canvas: document.getElementById('c'), width: 640, height: 360, fps: FPS });
const buffer = synthesizeSectioned({ bpm: 120, duration: SECONDS, changeAt: SECONDS / 2 });
const track = FeatureTrack.fromAudioBuffer(buffer, { fps: FPS });
show.track = track;
show.engine.timeline.setDuration(track.duration);
show.look = generateLook(track, { seed: 7 });
show.audioBuffer = buffer;
show.arc = new ArcDriver(show.look, track);
show.setSize(WIDTH, HEIGHT);
show.prime(0);
const TOTAL = show.frameCount;
log(`real Show · ${WIDTH}×${HEIGHT} @ ${FPS} · ${TOTAL} frames · bitrate ${BITRATE / 1e6} Mbps\n`);
/**
* Capture strategies. The question is whether handing the encoder a frame built
* straight from the WebGL canvas lets a later present() overwrite it before the
* encoder has read it — which would show up as fewer chunks out than frames in.
*/
const STRATEGIES = {
// What the exporter does today.
'webgl canvas direct': {
make: (canvas, init) => new VideoFrame(canvas, init),
},
// Force a pixel copy through a 2D canvas before the encoder sees it.
'2d canvas copy': {
setup() {
this.scratch = new OffscreenCanvas(WIDTH, HEIGHT);
this.ctx = this.scratch.getContext('2d', { willReadFrequently: false });
},
make(canvas, init) {
this.ctx.drawImage(canvas, 0, 0, WIDTH, HEIGHT);
return new VideoFrame(this.scratch, init);
},
},
// Same idea via createImageBitmap, which is async but explicitly a snapshot.
'createImageBitmap copy': {
async make(canvas, init) {
const bitmap = await createImageBitmap(canvas);
const frame = new VideoFrame(bitmap, init);
bitmap.close();
return frame;
},
},
};
async function run(label) {
const strategy = STRATEGIES[label];
if (strategy.setup) strategy.setup();
let emitted = 0;
let error = null;
const stamps = [];
const encoder = new VideoEncoder({
output: (chunk) => { emitted++; stamps.push(chunk.timestamp); },
error: (e) => { error = e; },
});
encoder.configure({
codec: 'avc1.640034',
width: WIDTH, height: HEIGHT, bitrate: BITRATE, framerate: FPS,
avc: { format: 'avc' },
});
show.engine.compositor.reset();
const t0 = performance.now();
let encoded = 0;
for (let i = 0; i < TOTAL; i++) {
if (error) break;
// Exactly the exporter's loop: render, present, capture, encode.
show.present(show.renderFrame(i));
const frame = await strategy.make(show.engine.renderer.canvas, {
timestamp: Math.round((i * 1e6) / FPS),
duration: Math.round(1e6 / FPS),
});
encoder.encode(frame, { keyFrame: i % (FPS * 2) === 0 });
frame.close();
encoded++;
if (i % 10 === 0) {
while (encoder.encodeQueueSize > 30) await new Promise((r) => setTimeout(r, 4));
await new Promise((r) => setTimeout(r, 0));
}
}
try { await encoder.flush(); } catch (e) { error = error || e; }
const ms = performance.now() - t0;
try { encoder.close(); } catch { /* already closed */ }
const period = 1e6 / FPS;
stamps.sort((a, b) => a - b);
const gaps = stamps.slice(1).map((t, k) => Math.round((t - stamps[k]) / period));
const maxGap = gaps.length ? Math.max(...gaps) : 0;
const verdict = error ? `ERROR ${error.message}`
: emitted === encoded ? 'OK'
: `DROPPED ${encoded - emitted} (${((1 - emitted / encoded) * 100).toFixed(0)}%) → plays at ${((emitted / encoded) * FPS).toFixed(1)} fps`;
log(`${label.padEnd(24)} in ${encoded} → out ${emitted} maxGap ${maxGap} ${(ms / 1000).toFixed(1)}s ${verdict}`);
}
for (const label of Object.keys(STRATEGIES)) await run(label);
log('\nDONE');
window.__DONE__ = true;
</script>
</body>
</html>

View File

@ -1,85 +0,0 @@
<!DOCTYPE html>
<html>
<head><title>decode test</title></head>
<body>
<div id="out">running…</div>
<script type="module">
import { hashFrame } from '/src/engine/hash.js';
const out = document.getElementById('out');
const log = (m) => { out.textContent += m + '\n'; };
const resp = await fetch('/export.mp4');
const blob = await resp.blob();
const frames = [];
function findBox(buf, start, type) {
let p = start;
while (p + 8 <= buf.length) {
let size = (buf[p] << 24) | (buf[p+1] << 16) | (buf[p+2] << 8) | buf[p+3];
const t = String.fromCharCode(buf[p+4], buf[p+5], buf[p+6], buf[p+7]);
const hdr = size === 1 ? 16 : 8;
const boxEnd = size === 1 ? p + Number(((buf[p+8] * 4294967296) + ((buf[p+9] << 24) | (buf[p+10] << 16) | (buf[p+11] << 8) | buf[p+12]))) : p + size;
if (boxEnd < p + hdr || boxEnd > buf.length) break;
if (t === type) return { data: p + hdr, end: boxEnd };
if (['moov','trak','mdia','minf','stbl','stsd'].includes(t)) {
const r = findBox(buf, p + hdr, type);
if (r) return r;
}
p = boxEnd;
}
return null;
}
const buf2 = new Uint8Array(await blob.arrayBuffer());
const avcC = findBox(buf2, 0, 'avcC');
let description = null;
if (avcC) {
description = buf2.slice(avcC.data, avcC.end);
log(`found avcC (${description.length} bytes)`);
} else {
log('no avcC box found');
}
const decoder = new VideoDecoder({
output: (frame) => { frames.push(frame); },
error: (e) => log('DECODER ERROR: ' + e.message),
});
decoder.configure({ codec: 'avc1.640034', description });
const reader = blob.stream().getReader();
let buf = new Uint8Array(0);
while (true) {
const { done, value } = await reader.read();
if (done) break;
const next = new Uint8Array(buf.length + value.length);
next.set(buf); next.set(value, buf.length);
buf = next;
}
const chunk = new EncodedVideoChunk({ type: 'key', timestamp: 0, duration: 0, data: buf });
decoder.decode(chunk);
await decoder.flush();
log(`decoded ${frames.length} frames`);
// hash a small downscaled copy of each frame
const canvas = new OffscreenCanvas(64, 36);
const ctx = canvas.getContext('2d');
const hashes = [];
for (const f of frames) {
ctx.drawImage(f, 0, 0, 64, 36);
const img = ctx.getImageData(0, 0, 64, 36);
hashes.push(hashFrame(img.data));
f.close();
}
let dupRuns = 0, maxRun = 0, curRun = 1;
for (let i = 1; i < hashes.length; i++) {
if (hashes[i] === hashes[i-1]) { curRun++; }
else { if (curRun > 1) dupRuns++; maxRun = Math.max(maxRun, curRun); curRun = 1; }
}
maxRun = Math.max(maxRun, curRun);
const distinct = new Set(hashes).size;
log(`distinct frames: ${distinct} / ${hashes.length}`);
log(`duplicate runs: ${dupRuns}, longest run: ${maxRun}`);
log('DONE');
window.__DECODE_DONE__ = true;
</script>
</body>
</html>

View File

@ -1,131 +0,0 @@
<!DOCTYPE html>
<html>
<head><title>encoder probe</title></head>
<body>
<pre id="out">running…</pre>
<canvas id="c" width="1920" height="1080" style="display:none"></canvas>
<script type="module">
const out = document.getElementById('out');
const log = (m) => { out.textContent += m + '\n'; console.log(m); };
// A cheap animated source. The point is to test the ENCODER, so the frames must
// be genuinely different (a static source would let the encoder coast) but must
// cost nothing to produce — no shaders, no Show, no GPU readback.
const canvas = document.getElementById('c');
const ctx = canvas.getContext('2d');
function drawFrame(i) {
ctx.fillStyle = `hsl(${(i * 3) % 360} 60% 12%)`;
ctx.fillRect(0, 0, 1920, 1080);
ctx.fillStyle = `hsl(${(i * 7) % 360} 80% 60%)`;
for (let k = 0; k < 40; k++) {
const x = ((i * 11 + k * 137) % 1920);
const y = ((i * 5 + k * 271) % 1080);
ctx.fillRect(x, y, 90, 90);
}
ctx.fillStyle = '#fff';
ctx.font = '120px monospace';
ctx.fillText(String(i), 60, 200);
}
const FPS = 60;
const FRAMES = 600;
const WIDTH = 1920, HEIGHT = 1080, BITRATE = 16_000_000;
const MAX_QUEUE = 8;
function waitForQueue(encoder) {
if (encoder.encodeQueueSize < MAX_QUEUE) return Promise.resolve();
return new Promise((resolve) => {
const onDequeue = () => {
if (encoder.encodeQueueSize < MAX_QUEUE) {
encoder.removeEventListener('dequeue', onDequeue);
resolve();
}
};
encoder.addEventListener('dequeue', onDequeue);
});
}
async function run(label, extra) {
const config = {
codec: 'avc1.640034',
width: WIDTH, height: HEIGHT, bitrate: BITRATE, framerate: FPS,
avc: { format: 'avc' },
...extra,
};
let support;
try {
support = await VideoEncoder.isConfigSupported(config);
} catch (e) {
log(`${label.padEnd(34)} isConfigSupported threw: ${e.message}`);
return;
}
if (!support.supported) {
log(`${label.padEnd(34)} UNSUPPORTED`);
return;
}
let outputs = 0;
let keyframes = 0;
const stamps = [];
let error = null;
const encoder = new VideoEncoder({
output: (chunk) => {
outputs++;
if (chunk.type === 'key') keyframes++;
stamps.push(chunk.timestamp);
},
error: (e) => { error = e; },
});
encoder.configure(config);
const t0 = performance.now();
let encoded = 0;
for (let i = 0; i < FRAMES; i++) {
if (error) break;
drawFrame(i);
await waitForQueue(encoder);
const frame = new VideoFrame(canvas, {
timestamp: Math.round((i * 1e6) / FPS),
duration: Math.round(1e6 / FPS),
});
encoder.encode(frame, { keyFrame: i % (FPS * 2) === 0 });
frame.close();
encoded++;
if (i % 10 === 0) await new Promise((r) => setTimeout(r, 0));
}
try { await encoder.flush(); } catch (e) { error = error || e; }
const ms = performance.now() - t0;
try { encoder.close(); } catch { /* already closed */ }
// Do NOT sort: the order chunks ARRIVE in is the whole point. WebCodecs
// delivers in decode order, so a timestamp that goes backwards means the
// encoder emitted B-frames — and a muxer handed presentation timestamps as
// if they were decode timestamps will reject exactly those chunks.
const period = 1e6 / FPS;
const backwards = stamps.filter((t, i) => i > 0 && t < stamps[i - 1]).length;
const sorted = stamps.slice().sort((a, b) => a - b);
const gaps = sorted.slice(1).map((t, i) => Math.round((t - sorted[i]) / period));
const maxGap = gaps.length ? Math.max(...gaps) : 0;
const verdict = error ? `ERROR ${error.message}`
: backwards ? `REORDERED — ${backwards} chunks arrived out of order (B-frames)`
: outputs === encoded ? 'OK'
: `DROPPED ${encoded - outputs} (${((1 - outputs / encoded) * 100).toFixed(0)}%)`;
log(`${label.padEnd(34)} in ${encoded} → out ${outputs} keys ${keyframes} maxGap ${maxGap} reordered ${backwards} ${(ms / 1000).toFixed(1)}s ${verdict}`);
}
log(`source ${WIDTH}×${HEIGHT} @ ${FPS} · ${FRAMES} frames · bitrate ${BITRATE / 1e6} Mbps\n`);
await run('default (no hints)', {});
await run('prefer-hardware', { hardwareAcceleration: 'prefer-hardware' });
await run('prefer-software', { hardwareAcceleration: 'prefer-software' });
await run('latency=realtime', { latencyMode: 'realtime' });
await run('prefer-hardware + realtime', { hardwareAcceleration: 'prefer-hardware', latencyMode: 'realtime' });
await run('prefer-software + realtime', { hardwareAcceleration: 'prefer-software', latencyMode: 'realtime' });
await run('baseline avc1.42003e', { codec: 'avc1.42003e' });
log('\nDONE');
window.__DONE__ = true;
</script>
</body>
</html>

View File

@ -1,43 +0,0 @@
<!DOCTYPE html>
<html>
<head><title>export test</title></head>
<body>
<canvas id="c" width="1" height="1"></canvas>
<div id="out">running…</div>
<script type="module">
import { Show } from '/src/Show.js';
import { FeatureTrack } from '/src/audio/FeatureTrack.js';
import { synthesizeSectioned } from '/src/audio/synth.js';
import { generateLook } from '/src/look/LookGenerator.js';
import { Exporter, PRESETS } from '/src/export/Exporter.js';
const out = document.getElementById('out');
const log = (m) => { out.textContent += m + '\n'; };
const fps = 60;
const show = new Show({ canvas: document.getElementById('c'), width: 640, height: 360, fps });
const buffer = synthesizeSectioned({ bpm: 120, duration: 8, changeAt: 4 });
const track = FeatureTrack.fromAudioBuffer(buffer, { fps });
show.track = track;
show.engine.timeline.setDuration(track.duration);
show.look = generateLook(track, { seed: 7 });
show.audioBuffer = buffer;show.arc = new (await import('/src/look/ArcDriver.js')).ArcDriver(show.look, track);
log(`frameCount ${show.frameCount} · fps ${fps} · duration ${track.duration.toFixed(2)}s`);
// Override size so we don't pay 720p cost in swiftshader.
const ex = new Exporter(show);
try {
const start = performance.now();
const blob = await ex.export({ preset: '720p', frameRange: [0, show.frameCount], onProgress: (p) => {} });
const ms = performance.now() - start;
log(`export done in ${(ms/1000).toFixed(1)}s, ${(blob.size/1e6).toFixed(2)} MB`);
window.__EXPORT_BLOB__ = blob;
} catch (e) {
log('EXPORT THREW: ' + e.stack);
}
log('DONE');
window.__EXPORT_DONE__ = true;
</script>
</body>
</html>

View File

@ -1,95 +0,0 @@
<!DOCTYPE html>
<html>
<head><title>mux probe</title></head>
<body>
<canvas id="c" width="1" height="1"></canvas>
<pre id="out">running…</pre>
<script type="module">
import { Show } from '/src/Show.js';
import { FeatureTrack } from '/src/audio/FeatureTrack.js';
import { synthesizeSectioned } from '/src/audio/synth.js';
import { generateLook } from '/src/look/LookGenerator.js';
import { Exporter } from '/src/export/Exporter.js';
const out = document.getElementById('out');
const log = (m) => { out.textContent += m + '\n'; console.log(m); };
// ---- minimal mp4 box walker: report per-track sample counts and stts ----
function probe(ab) {
const buf = new DataView(ab);
const u8 = new Uint8Array(ab);
const str = (o, n) => String.fromCharCode(...u8.subarray(o, o + n));
const report = [];
let cur = null;
function walk(start, end) {
let off = start;
while (off + 8 <= end) {
let size = buf.getUint32(off);
const type = str(off + 4, 4);
let hdr = 8;
if (size === 1) { size = Number(buf.getBigUint64(off + 8)); hdr = 16; }
if (size === 0) size = end - off;
if (['moov', 'trak', 'mdia', 'minf', 'stbl'].includes(type)) {
if (type === 'trak') { cur = {}; report.push(cur); }
walk(off + hdr, off + size);
} else {
const s = off + hdr;
if (type === 'mdhd' && cur) {
cur.timescale = buf.getUint32(s + 12);
cur.duration = buf.getUint32(s + 16);
}
if (type === 'hdlr' && cur) cur.kind = str(s + 8, 4);
if (type === 'stsd' && cur) cur.format = str(s + 12, 4);
if (type === 'stts' && cur) {
const n = buf.getUint32(s + 4);
let samples = 0, total = 0;
const rows = [];
for (let i = 0; i < n; i++) {
const c = buf.getUint32(s + 8 + i * 8);
const d = buf.getUint32(s + 12 + i * 8);
rows.push([c, d]); samples += c; total += c * d;
}
cur.stts = { entries: n, samples, total, rows: rows.slice(0, 12) };
}
if (type === 'stsz' && cur) cur.sampleCount = buf.getUint32(s + 8);
}
off += size;
}
}
walk(0, ab.byteLength);
return report;
}
const q = new URLSearchParams(location.search);
const PRESET = q.get('preset') || '720p';
const SECONDS = Number(q.get('seconds') || 8);
const fps = 60;
const show = new Show({ canvas: document.getElementById('c'), width: 640, height: 360, fps });
const buffer = synthesizeSectioned({ bpm: 120, duration: SECONDS, changeAt: SECONDS / 2 });
const track = FeatureTrack.fromAudioBuffer(buffer, { fps });
show.track = track;
show.engine.timeline.setDuration(track.duration);
show.look = generateLook(track, { seed: 7 });
show.audioBuffer = buffer;
show.arc = new (await import('/src/look/ArcDriver.js')).ArcDriver(show.look, track);
log(`frameCount ${show.frameCount} · fps ${fps} · audio ${buffer.sampleRate}Hz ${buffer.numberOfChannels}ch ${buffer.duration.toFixed(2)}s`);
const ex = new Exporter(show);
try {
const t0 = performance.now();
const blob = await ex.export({ preset: PRESET, onProgress: () => {} });
log(`export ${(performance.now() - t0) / 1000 | 0}s · ${(blob.size / 1e6).toFixed(2)} MB · warnings: ${JSON.stringify(ex.warnings)}`);
log(`frameStats ${JSON.stringify(ex.frameStats)}`);
const rows = probe(await blob.arrayBuffer());
for (const r of rows) log(JSON.stringify(r));
log(`EXPECTED video samples: ${show.frameCount}`);
} catch (e) {
log('THREW: ' + e.stack);
}
log('DONE');
window.__DONE__ = true;
</script>
</body>
</html>

View File

@ -1,60 +0,0 @@
<!DOCTYPE html>
<html>
<head><title>play test</title></head>
<body>
<video id="v" controls playsinline></video>
<div id="out">running…</div>
<script type="module">
const out = document.getElementById('out');
const log = (m) => { out.textContent += m + '\n'; };
const v = document.getElementById('v');
v.src = '/export.mp4';
const times = [];
const stamp = (mediaTime, now) => times.push({ mt: mediaTime, t: now });
function track() {
v.requestVideoFrameCallback((now, md) => {
stamp(md.mediaTime, now);
track();
});
}
track();
let audioTimes = [];
const ap = (e) => { if (audioTimes.length < 200) audioTimes.push({ ct: v.currentTime, t: performance.now() }); };
setInterval(ap, 30);
v.onplaying = () => {
log('playing…');
setTimeout(async () => {
const dur = v.duration;
log(`video.duration = ${dur.toFixed(3)}s`);
await new Promise((r) => setTimeout(r, 2500));
const end = performance.now();
const pts = times.filter((x) => x.t <= end);
const deltas = pts.slice(1).map((x, i) => x.mt - pts[i].mt);
log(`presented frames in window: ${pts.length}`);
const mean = deltas.reduce((a, b) => a + b, 0) / deltas.length;
const variance = deltas.reduce((a, b) => a + (b - mean) ** 2, 0) / deltas.length;
log(`mean inter-frame mediaTime delta: ${mean.toFixed(5)}s (60fps would be ${(1/60).toFixed(5)})`);
log(`variance: ${variance.toFixed(8)}`);
log(`min/max: ${Math.min(...deltas).toFixed(5)} / ${Math.max(...deltas).toFixed(5)}`);
// A/V sync: compare media time progression to wall time
if (pts.length > 10) {
const wallSpan = (pts[pts.length-1].t - pts[0].t) / 1000;
const mediaSpan = pts[pts.length-1].mt - pts[0].mt;
log(`wall ${wallSpan.toFixed(2)}s vs media ${mediaSpan.toFixed(2)}s → play rate ${(mediaSpan/wallSpan).toFixed(3)}`);
}
const aEnd = audioTimes[audioTimes.length-1];
const aStart = audioTimes[0];
if (aStart && aEnd) {
log(`audio: currentTime ${aStart.ct.toFixed(2)}→${aEnd.ct.toFixed(2)} over ${((aEnd.t-aStart.t)/1000).toFixed(2)}s → rate ${((aEnd.ct-aStart.ct)/((aEnd.t-aStart.t)/1000)).toFixed(3)}`);
}
log('DONE');
window.__PLAY_DONE__ = true;
}, 500);
};
await new Promise((resolve, reject) => { v.onerror = () => reject(new Error('video error')); setTimeout(resolve, 30000); });
try { await v.play(); } catch (e) { log('play() threw: ' + e.message); }
</script>
</body>
</html>