content/media/webaudio/test/test_mixingRules.html

Fri, 16 Jan 2015 04:50:19 +0100

author
Michael Schloh von Bennewitz <michael@schloh.com>
date
Fri, 16 Jan 2015 04:50:19 +0100
branch
TOR_BUG_9701
changeset 13
44a2da4a2ab2
permissions
-rw-r--r--

Replace accessor implementation with direct member state manipulation, by
request https://trac.torproject.org/projects/tor/ticket/9701#comment:32

michael@0 1 <!DOCTYPE html>
michael@0 2 <html>
michael@0 3 <head>
michael@0 4 <title>Testcase for AudioNode channel up-mix/down-mix rules</title>
michael@0 5 <script type="text/javascript" src="/tests/SimpleTest/SimpleTest.js"></script>
michael@0 6 <script type="text/javascript" src="webaudio.js"></script>
michael@0 7 <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" />
michael@0 8 </head>
michael@0 9
michael@0 10 <body>
michael@0 11
michael@0 12 <script>
michael@0 13
michael@0 14 // This test is based on http://src.chromium.org/viewvc/blink/trunk/LayoutTests/webaudio/audionode-channel-rules.html
michael@0 15
michael@0 16 var context = null;
michael@0 17 var sp = null;
michael@0 18 var renderNumberOfChannels = 8;
michael@0 19 var singleTestFrameLength = 8;
michael@0 20 var testBuffers;
michael@0 21
michael@0 22 // A list of connections to an AudioNode input, each of which is to be used in one or more specific test cases.
michael@0 23 // Each element in the list is a string, with the number of connections corresponding to the length of the string,
michael@0 24 // and each character in the string is from '1' to '8' representing a 1 to 8 channel connection (from an AudioNode output).
michael@0 25 // For example, the string "128" means 3 connections, having 1, 2, and 8 channels respectively.
michael@0 26 var connectionsList = [];
michael@0 27 for (var i = 1; i <= 8; ++i) {
michael@0 28 connectionsList.push(i.toString());
michael@0 29 for (var j = 1; j <= 8; ++j) {
michael@0 30 connectionsList.push(i.toString() + j.toString());
michael@0 31 }
michael@0 32 }
michael@0 33
michael@0 34 // A list of mixing rules, each of which will be tested against all of the connections in connectionsList.
michael@0 35 var mixingRulesList = [
michael@0 36 {channelCount: 1, channelCountMode: "max", channelInterpretation: "speakers"},
michael@0 37 {channelCount: 2, channelCountMode: "clamped-max", channelInterpretation: "speakers"},
michael@0 38 {channelCount: 3, channelCountMode: "clamped-max", channelInterpretation: "speakers"},
michael@0 39 {channelCount: 4, channelCountMode: "clamped-max", channelInterpretation: "speakers"},
michael@0 40 {channelCount: 5, channelCountMode: "clamped-max", channelInterpretation: "speakers"},
michael@0 41 {channelCount: 6, channelCountMode: "clamped-max", channelInterpretation: "speakers"},
michael@0 42 {channelCount: 7, channelCountMode: "clamped-max", channelInterpretation: "speakers"},
michael@0 43 {channelCount: 2, channelCountMode: "explicit", channelInterpretation: "speakers"},
michael@0 44 {channelCount: 3, channelCountMode: "explicit", channelInterpretation: "speakers"},
michael@0 45 {channelCount: 4, channelCountMode: "explicit", channelInterpretation: "speakers"},
michael@0 46 {channelCount: 5, channelCountMode: "explicit", channelInterpretation: "speakers"},
michael@0 47 {channelCount: 6, channelCountMode: "explicit", channelInterpretation: "speakers"},
michael@0 48 {channelCount: 7, channelCountMode: "explicit", channelInterpretation: "speakers"},
michael@0 49 {channelCount: 8, channelCountMode: "explicit", channelInterpretation: "speakers"},
michael@0 50 {channelCount: 1, channelCountMode: "max", channelInterpretation: "discrete"},
michael@0 51 {channelCount: 2, channelCountMode: "clamped-max", channelInterpretation: "discrete"},
michael@0 52 {channelCount: 3, channelCountMode: "clamped-max", channelInterpretation: "discrete"},
michael@0 53 {channelCount: 4, channelCountMode: "clamped-max", channelInterpretation: "discrete"},
michael@0 54 {channelCount: 5, channelCountMode: "clamped-max", channelInterpretation: "discrete"},
michael@0 55 {channelCount: 6, channelCountMode: "clamped-max", channelInterpretation: "discrete"},
michael@0 56 {channelCount: 3, channelCountMode: "explicit", channelInterpretation: "discrete"},
michael@0 57 {channelCount: 4, channelCountMode: "explicit", channelInterpretation: "discrete"},
michael@0 58 {channelCount: 5, channelCountMode: "explicit", channelInterpretation: "discrete"},
michael@0 59 {channelCount: 6, channelCountMode: "explicit", channelInterpretation: "discrete"},
michael@0 60 {channelCount: 7, channelCountMode: "explicit", channelInterpretation: "discrete"},
michael@0 61 {channelCount: 8, channelCountMode: "explicit", channelInterpretation: "discrete"},
michael@0 62 ];
michael@0 63
michael@0 64 var numberOfTests = mixingRulesList.length * connectionsList.length;
michael@0 65
michael@0 66 // Create an n-channel buffer, with all sample data zero except for a shifted impulse.
michael@0 67 // The impulse position depends on the channel index.
michael@0 68 // For example, for a 4-channel buffer:
michael@0 69 // channel0: 1 0 0 0 0 0 0 0
michael@0 70 // channel1: 0 1 0 0 0 0 0 0
michael@0 71 // channel2: 0 0 1 0 0 0 0 0
michael@0 72 // channel3: 0 0 0 1 0 0 0 0
michael@0 73 function createTestBuffer(numberOfChannels) {
michael@0 74 var buffer = context.createBuffer(numberOfChannels, singleTestFrameLength, context.sampleRate);
michael@0 75 for (var i = 0; i < numberOfChannels; ++i) {
michael@0 76 var data = buffer.getChannelData(i);
michael@0 77 data[i] = 1;
michael@0 78 }
michael@0 79 return buffer;
michael@0 80 }
michael@0 81
michael@0 82 // Discrete channel interpretation mixing:
michael@0 83 // https://dvcs.w3.org/hg/audio/raw-file/tip/webaudio/specification.html#UpMix
michael@0 84 // up-mix by filling channels until they run out then ignore remaining dest channels.
michael@0 85 // down-mix by filling as many channels as possible, then dropping remaining source channels.
michael@0 86 function discreteSum(sourceBuffer, destBuffer) {
michael@0 87 if (sourceBuffer.length != destBuffer.length) {
michael@0 88 is(sourceBuffer.length, destBuffer.length, "source and destination buffers should have the same length");
michael@0 89 }
michael@0 90
michael@0 91 var numberOfChannels = Math.min(sourceBuffer.numberOfChannels, destBuffer.numberOfChannels);
michael@0 92 var length = sourceBuffer.length;
michael@0 93
michael@0 94 for (var c = 0; c < numberOfChannels; ++c) {
michael@0 95 var source = sourceBuffer.getChannelData(c);
michael@0 96 var dest = destBuffer.getChannelData(c);
michael@0 97 for (var i = 0; i < length; ++i) {
michael@0 98 dest[i] += source[i];
michael@0 99 }
michael@0 100 }
michael@0 101 }
michael@0 102
michael@0 103 // Speaker channel interpretation mixing:
michael@0 104 // https://dvcs.w3.org/hg/audio/raw-file/tip/webaudio/specification.html#UpMix
michael@0 105 function speakersSum(sourceBuffer, destBuffer)
michael@0 106 {
michael@0 107 var numberOfSourceChannels = sourceBuffer.numberOfChannels;
michael@0 108 var numberOfDestinationChannels = destBuffer.numberOfChannels;
michael@0 109 var length = destBuffer.length;
michael@0 110
michael@0 111 if ((numberOfDestinationChannels == 2 && numberOfSourceChannels == 1) ||
michael@0 112 (numberOfDestinationChannels == 4 && numberOfSourceChannels == 1)) {
michael@0 113 // Handle mono -> stereo/Quad case (summing mono channel into both left and right).
michael@0 114 var source = sourceBuffer.getChannelData(0);
michael@0 115 var destL = destBuffer.getChannelData(0);
michael@0 116 var destR = destBuffer.getChannelData(1);
michael@0 117
michael@0 118 for (var i = 0; i < length; ++i) {
michael@0 119 destL[i] += source[i];
michael@0 120 destR[i] += source[i];
michael@0 121 }
michael@0 122 } else if ((numberOfDestinationChannels == 4 && numberOfSourceChannels == 2) ||
michael@0 123 (numberOfDestinationChannels == 6 && numberOfSourceChannels == 2)) {
michael@0 124 // Handle stereo -> Quad/5.1 case (summing left and right channels into the output's left and right).
michael@0 125 var sourceL = sourceBuffer.getChannelData(0);
michael@0 126 var sourceR = sourceBuffer.getChannelData(1);
michael@0 127 var destL = destBuffer.getChannelData(0);
michael@0 128 var destR = destBuffer.getChannelData(1);
michael@0 129
michael@0 130 for (var i = 0; i < length; ++i) {
michael@0 131 destL[i] += sourceL[i];
michael@0 132 destR[i] += sourceR[i];
michael@0 133 }
michael@0 134 } else if (numberOfDestinationChannels == 1 && numberOfSourceChannels == 2) {
michael@0 135 // Handle stereo -> mono case. output += 0.5 * (input.L + input.R).
michael@0 136 var sourceL = sourceBuffer.getChannelData(0);
michael@0 137 var sourceR = sourceBuffer.getChannelData(1);
michael@0 138 var dest = destBuffer.getChannelData(0);
michael@0 139
michael@0 140 for (var i = 0; i < length; ++i) {
michael@0 141 dest[i] += 0.5 * (sourceL[i] + sourceR[i]);
michael@0 142 }
michael@0 143 } else if (numberOfDestinationChannels == 1 && numberOfSourceChannels == 4) {
michael@0 144 // Handle Quad -> mono case. output += 0.25 * (input.L + input.R + input.SL + input.SR).
michael@0 145 var sourceL = sourceBuffer.getChannelData(0);
michael@0 146 var sourceR = sourceBuffer.getChannelData(1);
michael@0 147 var sourceSL = sourceBuffer.getChannelData(2);
michael@0 148 var sourceSR = sourceBuffer.getChannelData(3);
michael@0 149 var dest = destBuffer.getChannelData(0);
michael@0 150
michael@0 151 for (var i = 0; i < length; ++i) {
michael@0 152 dest[i] += 0.25 * (sourceL[i] + sourceR[i] + sourceSL[i] + sourceSR[i]);
michael@0 153 }
michael@0 154 } else if (numberOfDestinationChannels == 2 && numberOfSourceChannels == 4) {
michael@0 155 // Handle Quad -> stereo case. outputLeft += 0.5 * (input.L + input.SL),
michael@0 156 // outputRight += 0.5 * (input.R + input.SR).
michael@0 157 var sourceL = sourceBuffer.getChannelData(0);
michael@0 158 var sourceR = sourceBuffer.getChannelData(1);
michael@0 159 var sourceSL = sourceBuffer.getChannelData(2);
michael@0 160 var sourceSR = sourceBuffer.getChannelData(3);
michael@0 161 var destL = destBuffer.getChannelData(0);
michael@0 162 var destR = destBuffer.getChannelData(1);
michael@0 163
michael@0 164 for (var i = 0; i < length; ++i) {
michael@0 165 destL[i] += 0.5 * (sourceL[i] + sourceSL[i]);
michael@0 166 destR[i] += 0.5 * (sourceR[i] + sourceSR[i]);
michael@0 167 }
michael@0 168 } else if (numberOfDestinationChannels == 6 && numberOfSourceChannels == 4) {
michael@0 169 // Handle Quad -> 5.1 case. outputLeft += (inputL, inputR, 0, 0, inputSL, inputSR)
michael@0 170 var sourceL = sourceBuffer.getChannelData(0);
michael@0 171 var sourceR = sourceBuffer.getChannelData(1);
michael@0 172 var sourceSL = sourceBuffer.getChannelData(2);
michael@0 173 var sourceSR = sourceBuffer.getChannelData(3);
michael@0 174 var destL = destBuffer.getChannelData(0);
michael@0 175 var destR = destBuffer.getChannelData(1);
michael@0 176 var destSL = destBuffer.getChannelData(4);
michael@0 177 var destSR = destBuffer.getChannelData(5);
michael@0 178
michael@0 179 for (var i = 0; i < length; ++i) {
michael@0 180 destL[i] += sourceL[i];
michael@0 181 destR[i] += sourceR[i];
michael@0 182 destSL[i] += sourceSL[i];
michael@0 183 destSR[i] += sourceSR[i];
michael@0 184 }
michael@0 185 } else if (numberOfDestinationChannels == 6 && numberOfSourceChannels == 1) {
michael@0 186 // Handle mono -> 5.1 case, sum mono channel into center.
michael@0 187 var source = sourceBuffer.getChannelData(0);
michael@0 188 var dest = destBuffer.getChannelData(2);
michael@0 189
michael@0 190 for (var i = 0; i < length; ++i) {
michael@0 191 dest[i] += source[i];
michael@0 192 }
michael@0 193 } else if (numberOfDestinationChannels == 1 && numberOfSourceChannels == 6) {
michael@0 194 // Handle 5.1 -> mono.
michael@0 195 var sourceL = sourceBuffer.getChannelData(0);
michael@0 196 var sourceR = sourceBuffer.getChannelData(1);
michael@0 197 var sourceC = sourceBuffer.getChannelData(2);
michael@0 198 // skip LFE for now, according to current spec.
michael@0 199 var sourceSL = sourceBuffer.getChannelData(4);
michael@0 200 var sourceSR = sourceBuffer.getChannelData(5);
michael@0 201 var dest = destBuffer.getChannelData(0);
michael@0 202
michael@0 203 for (var i = 0; i < length; ++i) {
michael@0 204 dest[i] += 0.7071 * (sourceL[i] + sourceR[i]) + sourceC[i] + 0.5 * (sourceSL[i] + sourceSR[i]);
michael@0 205 }
michael@0 206 } else if (numberOfDestinationChannels == 2 && numberOfSourceChannels == 6) {
michael@0 207 // Handle 5.1 -> stereo.
michael@0 208 var sourceL = sourceBuffer.getChannelData(0);
michael@0 209 var sourceR = sourceBuffer.getChannelData(1);
michael@0 210 var sourceC = sourceBuffer.getChannelData(2);
michael@0 211 // skip LFE for now, according to current spec.
michael@0 212 var sourceSL = sourceBuffer.getChannelData(4);
michael@0 213 var sourceSR = sourceBuffer.getChannelData(5);
michael@0 214 var destL = destBuffer.getChannelData(0);
michael@0 215 var destR = destBuffer.getChannelData(1);
michael@0 216
michael@0 217 for (var i = 0; i < length; ++i) {
michael@0 218 destL[i] += sourceL[i] + 0.7071 * (sourceC[i] + sourceSL[i]);
michael@0 219 destR[i] += sourceR[i] + 0.7071 * (sourceC[i] + sourceSR[i]);
michael@0 220 }
michael@0 221 } else if (numberOfDestinationChannels == 4 && numberOfSourceChannels == 6) {
michael@0 222 // Handle 5.1 -> Quad.
michael@0 223 var sourceL = sourceBuffer.getChannelData(0);
michael@0 224 var sourceR = sourceBuffer.getChannelData(1);
michael@0 225 var sourceC = sourceBuffer.getChannelData(2);
michael@0 226 // skip LFE for now, according to current spec.
michael@0 227 var sourceSL = sourceBuffer.getChannelData(4);
michael@0 228 var sourceSR = sourceBuffer.getChannelData(5);
michael@0 229 var destL = destBuffer.getChannelData(0);
michael@0 230 var destR = destBuffer.getChannelData(1);
michael@0 231 var destSL = destBuffer.getChannelData(2);
michael@0 232 var destSR = destBuffer.getChannelData(3);
michael@0 233
michael@0 234 for (var i = 0; i < length; ++i) {
michael@0 235 destL[i] += sourceL[i] + 0.7071 * sourceC[i];
michael@0 236 destR[i] += sourceR[i] + 0.7071 * sourceC[i];
michael@0 237 destSL[i] += sourceSL[i];
michael@0 238 destSR[i] += sourceSR[i];
michael@0 239 }
michael@0 240 } else {
michael@0 241 // Fallback for unknown combinations.
michael@0 242 discreteSum(sourceBuffer, destBuffer);
michael@0 243 }
michael@0 244 }
michael@0 245
michael@0 246 function scheduleTest(testNumber, connections, channelCount, channelCountMode, channelInterpretation) {
michael@0 247 var mixNode = context.createGain();
michael@0 248 mixNode.channelCount = channelCount;
michael@0 249 mixNode.channelCountMode = channelCountMode;
michael@0 250 mixNode.channelInterpretation = channelInterpretation;
michael@0 251 mixNode.connect(sp);
michael@0 252
michael@0 253 for (var i = 0; i < connections.length; ++i) {
michael@0 254 var connectionNumberOfChannels = connections.charCodeAt(i) - "0".charCodeAt(0);
michael@0 255
michael@0 256 var source = context.createBufferSource();
michael@0 257 // Get a buffer with the right number of channels, converting from 1-based to 0-based index.
michael@0 258 var buffer = testBuffers[connectionNumberOfChannels - 1];
michael@0 259 source.buffer = buffer;
michael@0 260 source.connect(mixNode);
michael@0 261
michael@0 262 // Start at the right offset.
michael@0 263 var sampleFrameOffset = testNumber * singleTestFrameLength;
michael@0 264 var time = sampleFrameOffset / context.sampleRate;
michael@0 265 source.start(time);
michael@0 266 }
michael@0 267 }
michael@0 268
michael@0 269 function computeNumberOfChannels(connections, channelCount, channelCountMode) {
michael@0 270 if (channelCountMode == "explicit")
michael@0 271 return channelCount;
michael@0 272
michael@0 273 var computedNumberOfChannels = 1; // Must have at least one channel.
michael@0 274
michael@0 275 // Compute "computedNumberOfChannels" based on all the connections.
michael@0 276 for (var i = 0; i < connections.length; ++i) {
michael@0 277 var connectionNumberOfChannels = connections.charCodeAt(i) - "0".charCodeAt(0);
michael@0 278 computedNumberOfChannels = Math.max(computedNumberOfChannels, connectionNumberOfChannels);
michael@0 279 }
michael@0 280
michael@0 281 if (channelCountMode == "clamped-max")
michael@0 282 computedNumberOfChannels = Math.min(computedNumberOfChannels, channelCount);
michael@0 283
michael@0 284 return computedNumberOfChannels;
michael@0 285 }
michael@0 286
michael@0 287 function checkTestResult(renderedBuffer, testNumber, connections, channelCount, channelCountMode, channelInterpretation) {
michael@0 288 var computedNumberOfChannels = computeNumberOfChannels(connections, channelCount, channelCountMode);
michael@0 289
michael@0 290 // Create a zero-initialized silent AudioBuffer with computedNumberOfChannels.
michael@0 291 var destBuffer = context.createBuffer(computedNumberOfChannels, singleTestFrameLength, context.sampleRate);
michael@0 292
michael@0 293 // Mix all of the connections into the destination buffer.
michael@0 294 for (var i = 0; i < connections.length; ++i) {
michael@0 295 var connectionNumberOfChannels = connections.charCodeAt(i) - "0".charCodeAt(0);
michael@0 296 var sourceBuffer = testBuffers[connectionNumberOfChannels - 1]; // convert from 1-based to 0-based index
michael@0 297
michael@0 298 if (channelInterpretation == "speakers") {
michael@0 299 speakersSum(sourceBuffer, destBuffer);
michael@0 300 } else if (channelInterpretation == "discrete") {
michael@0 301 discreteSum(sourceBuffer, destBuffer);
michael@0 302 } else {
michael@0 303 ok(false, "Invalid channel interpretation!");
michael@0 304 }
michael@0 305 }
michael@0 306
michael@0 307 // Validate that destBuffer matches the rendered output.
michael@0 308 // We need to check the rendered output at a specific sample-frame-offset corresponding
michael@0 309 // to the specific test case we're checking for based on testNumber.
michael@0 310
michael@0 311 var sampleFrameOffset = testNumber * singleTestFrameLength;
michael@0 312 for (var c = 0; c < renderNumberOfChannels; ++c) {
michael@0 313 var renderedData = renderedBuffer.getChannelData(c);
michael@0 314 for (var frame = 0; frame < singleTestFrameLength; ++frame) {
michael@0 315 var renderedValue = renderedData[frame + sampleFrameOffset];
michael@0 316
michael@0 317 var expectedValue = 0;
michael@0 318 if (c < destBuffer.numberOfChannels) {
michael@0 319 var expectedData = destBuffer.getChannelData(c);
michael@0 320 expectedValue = expectedData[frame];
michael@0 321 }
michael@0 322
michael@0 323 if (Math.abs(renderedValue - expectedValue) > 1e-4) {
michael@0 324 var s = "connections: " + connections + ", " + channelCountMode;
michael@0 325
michael@0 326 // channelCount is ignored in "max" mode.
michael@0 327 if (channelCountMode == "clamped-max" || channelCountMode == "explicit") {
michael@0 328 s += "(" + channelCount + ")";
michael@0 329 }
michael@0 330
michael@0 331 s += ", " + channelInterpretation + ". ";
michael@0 332
michael@0 333 var message = s + "rendered: " + renderedValue + " expected: " + expectedValue + " channel: " + c + " frame: " + frame;
michael@0 334 is(renderedValue, expectedValue, message);
michael@0 335 }
michael@0 336 }
michael@0 337 }
michael@0 338 }
michael@0 339
michael@0 340 function checkResult(event) {
michael@0 341 var buffer = event.inputBuffer;
michael@0 342
michael@0 343 // Sanity check result.
michael@0 344 ok(buffer.length != numberOfTests * singleTestFrameLength ||
michael@0 345 buffer.numberOfChannels != renderNumberOfChannels, "Sanity check");
michael@0 346
michael@0 347 // Check all the tests.
michael@0 348 var testNumber = 0;
michael@0 349 for (var m = 0; m < mixingRulesList.length; ++m) {
michael@0 350 var mixingRules = mixingRulesList[m];
michael@0 351 for (var i = 0; i < connectionsList.length; ++i, ++testNumber) {
michael@0 352 checkTestResult(buffer, testNumber, connectionsList[i], mixingRules.channelCount, mixingRules.channelCountMode, mixingRules.channelInterpretation);
michael@0 353 }
michael@0 354 }
michael@0 355
michael@0 356 sp.onaudioprocess = null;
michael@0 357 SimpleTest.finish();
michael@0 358 }
michael@0 359
michael@0 360 SimpleTest.waitForExplicitFinish();
michael@0 361 function runTest() {
michael@0 362 // Create 8-channel offline audio context.
michael@0 363 // Each test will render 8 sample-frames starting at sample-frame position testNumber * 8.
michael@0 364 var totalFrameLength = numberOfTests * singleTestFrameLength;
michael@0 365 context = new AudioContext();
michael@0 366 var nextPowerOfTwo = 256;
michael@0 367 while (nextPowerOfTwo < totalFrameLength) {
michael@0 368 nextPowerOfTwo *= 2;
michael@0 369 }
michael@0 370 sp = context.createScriptProcessor(nextPowerOfTwo, renderNumberOfChannels);
michael@0 371
michael@0 372 // Set destination to discrete mixing.
michael@0 373 sp.channelCount = renderNumberOfChannels;
michael@0 374 sp.channelCountMode = "explicit";
michael@0 375 sp.channelInterpretation = "discrete";
michael@0 376
michael@0 377 // Create test buffers from 1 to 8 channels.
michael@0 378 testBuffers = new Array();
michael@0 379 for (var i = 0; i < renderNumberOfChannels; ++i) {
michael@0 380 testBuffers[i] = createTestBuffer(i + 1);
michael@0 381 }
michael@0 382
michael@0 383 // Schedule all the tests.
michael@0 384 var testNumber = 0;
michael@0 385 for (var m = 0; m < mixingRulesList.length; ++m) {
michael@0 386 var mixingRules = mixingRulesList[m];
michael@0 387 for (var i = 0; i < connectionsList.length; ++i, ++testNumber) {
michael@0 388 scheduleTest(testNumber, connectionsList[i], mixingRules.channelCount, mixingRules.channelCountMode, mixingRules.channelInterpretation);
michael@0 389 }
michael@0 390 }
michael@0 391
michael@0 392 // Render then check results.
michael@0 393 sp.onaudioprocess = checkResult;
michael@0 394 }
michael@0 395
michael@0 396 runTest();
michael@0 397
michael@0 398 </script>
michael@0 399
michael@0 400 </body>
michael@0 401 </html>

mercurial