<!doctype html>
<html>
  <head>
    <title>
      Test Handling of Event Insertion
    </title>
    <script src="/resources/testharness.js"></script>
    <script src="/resources/testharnessreport.js"></script>
    <script src="/webaudio/resources/audit-util.js"></script>
    <script src="/webaudio/resources/audit.js"></script>
    <script src="/webaudio/resources/audio-param.js"></script>
  </head>
  <body>
    <script id="layout-test-code">
      let audit = Audit.createTaskRunner();

      // Use a power of two for the sample rate so there's no round-off in
      // computing time from frame.
      let sampleRate = 16384;

      audit.define(
          {label: 'Insert same event at same time'}, (task, should) => {
            // Context for testing.
            let context = new OfflineAudioContext(
                {length: 16384, sampleRate: sampleRate});

            // The source node to use.  Automations will be scheduled here.
            let src = new ConstantSourceNode(context, {offset: 0});
            src.connect(context.destination);

            // An array of tests to be done.  Each entry specifies the event
            // type and the event time.  The events are inserted in the order
            // given (in |values|), and the second event should be inserted
            // after the first one, as required by the spec.
            let testCases = [
              {
                event: 'setValueAtTime',
                frame: RENDER_QUANTUM_FRAMES,
                values: [99, 1],
                outputTestFrame: RENDER_QUANTUM_FRAMES,
                expectedOutputValue: 1
              },
              {
                event: 'linearRampToValueAtTime',
                frame: 2 * RENDER_QUANTUM_FRAMES,
                values: [99, 2],
                outputTestFrame: 2 * RENDER_QUANTUM_FRAMES,
                expectedOutputValue: 2
              },
              {
                event: 'exponentialRampToValueAtTime',
                frame: 3 * RENDER_QUANTUM_FRAMES,
                values: [99, 3],
                outputTestFrame: 3 * RENDER_QUANTUM_FRAMES,
                expectedOutputValue: 3
              },
              {
                event: 'setValueCurveAtTime',
                frame: 3 * RENDER_QUANTUM_FRAMES,
                values: [[3, 4]],
                extraArgs: RENDER_QUANTUM_FRAMES / context.sampleRate,
                outputTestFrame: 4 * RENDER_QUANTUM_FRAMES,
                expectedOutputValue: 4
              },
              {
                event: 'setValueAtTime',
                frame: 5 * RENDER_QUANTUM_FRAMES - 1,
                values: [99, 1, 5],
                outputTestFrame: 5 * RENDER_QUANTUM_FRAMES,
                expectedOutputValue: 5
              }
            ];

            testCases.forEach(entry => {
              entry.values.forEach(value => {
                let eventTime = entry.frame / context.sampleRate;
                let message = eventToString(
                    entry.event, value, eventTime, entry.extraArgs);
                // This is mostly to print out the event that is getting
                // inserted.  It should never ever throw.
                should(() => {
                  src.offset[entry.event](value, eventTime, entry.extraArgs);
                }, message).notThrow();
              });
            });

            src.start();

            context.startRendering()
                .then(audioBuffer => {
                  let audio = audioBuffer.getChannelData(0);

                  // Look through the test cases to figure out what the correct
                  // output values should be.
                  testCases.forEach(entry => {
                    let expected = entry.expectedOutputValue;
                    let frame = entry.outputTestFrame;
                    let time = frame / context.sampleRate;
                    should(
                        audio[frame], `Output at frame ${frame} (time ${time})`)
                        .beEqualTo(expected);
                  });
                })
                .then(() => task.done());
          });

      audit.define(
          {
            label: 'Linear + Expo',
            description: 'Different events at same time'
          },
          (task, should) => {
            // Should be a linear ramp up to the event time, and after a
            // constant value because the exponential ramp has ended.
            let testCase = [
              {event: 'linearRampToValueAtTime', value: 2, relError: 0},
              {event: 'setValueAtTime', value: 99},
              {event: 'exponentialRampToValueAtTime', value: 3},
            ];
            let eventFrame = 2 * RENDER_QUANTUM_FRAMES;
            let prefix = 'Linear+Expo: ';

            testEventInsertion(prefix, should, eventFrame, testCase)
                .then(expectConstant(prefix, should, eventFrame, testCase))
                .then(() => task.done());
          });

      audit.define(
          {
            label: 'Expo + Linear',
            description: 'Different events at same time',
          },
          (task, should) => {
            // Should be an exponential ramp up to the event time, and after a
            // constant value because the linear ramp has ended.
            let testCase = [
              {
                event: 'exponentialRampToValueAtTime',
                value: 3,
                relError: 4.2533e-6
              },
              {event: 'setValueAtTime', value: 99},
              {event: 'linearRampToValueAtTime', value: 2},
            ];
            let eventFrame = 2 * RENDER_QUANTUM_FRAMES;
            let prefix = 'Expo+Linear: ';

            testEventInsertion(prefix, should, eventFrame, testCase)
                .then(expectConstant(prefix, should, eventFrame, testCase))
                .then(() => task.done());
          });

      audit.define(
          {
            label: 'Linear + SetTarget',
            description: 'Different events at same time',
          },
          (task, should) => {
            // Should be a linear ramp up to the event time, and then a
            // decaying value.
            let testCase = [
              {event: 'linearRampToValueAtTime', value: 3, relError: 0},
              {event: 'setValueAtTime', value: 100},
              {event: 'setTargetAtTime', value: 0, extraArgs: 0.1},
            ];
            let eventFrame = 2 * RENDER_QUANTUM_FRAMES;
            let prefix = 'Linear+SetTarget: ';

            testEventInsertion(prefix, should, eventFrame, testCase)
                .then(audioBuffer => {
                  let audio = audioBuffer.getChannelData(0);
                  let prefix = 'Linear+SetTarget: ';
                  let eventTime = eventFrame / sampleRate;
                  let expectedValue = methodMap[testCase[0].event](
                      (eventFrame - 1) / sampleRate, 1, 0, testCase[0].value,
                      eventTime);
                  should(
                      audio[eventFrame - 1],
                      prefix +
                          `At time ${
                                     (eventFrame - 1) / sampleRate
                                   } (frame ${eventFrame - 1}) output`)
                      .beCloseTo(
                          expectedValue,
                          {threshold: testCase[0].relError || 0});

                  // The setValue should have taken effect
                  should(
                      audio[eventFrame],
                      prefix +
                          `At time ${eventTime} (frame ${eventFrame}) output`)
                      .beEqualTo(testCase[1].value);

                  // The final event is setTarget.  Compute the expected output.
                  let actual = audio.slice(eventFrame);
                  let expected = new Float32Array(actual.length);
                  for (let k = 0; k < expected.length; ++k) {
                    let t = (eventFrame + k) / sampleRate;
                    expected[k] = audioParamSetTarget(
                        t, testCase[1].value, eventTime, testCase[2].value,
                        testCase[2].extraArgs);
                  }
                  should(
                      actual,
                      prefix +
                          `At time ${eventTime} (frame ${
                                                         eventFrame
                                                       }) and later`)
                      .beCloseToArray(expected, {relativeThreshold: 2.6694e-7});
                })
                .then(() => task.done());
          });

      audit.define(
          {
            label: 'Multiple linear ramps at the same time',
            description: 'Verify output'
          },
          (task, should) => {
            testMultipleSameEvents(should, {
              method: 'linearRampToValueAtTime',
              prefix: 'Multiple linear ramps: ',
              threshold: 0
            }).then(() => task.done());
          });

      audit.define(
          {
            label: 'Multiple exponential ramps at the same time',
            description: 'Verify output'
          },
          (task, should) => {
            testMultipleSameEvents(should, {
              method: 'exponentialRampToValueAtTime',
              prefix: 'Multiple exponential ramps: ',
              threshold: 5.3924e-7
            }).then(() => task.done());
          });

      audit.run();

      // Takes a list of |testCases| consisting of automation methods and
      // schedules them to occur at |eventFrame|. |prefix| is a prefix for
      // messages produced by |should|.
      //
      // Each item in |testCases| is a dictionary with members:
      //   event     - the name of automation method to be inserted,
      //   value     - the value for the event,
      //   extraArgs - extra arguments if the event needs more than the value
      //               and time (such as setTargetAtTime).
      function testEventInsertion(prefix, should, eventFrame, testCases) {
        let context = new OfflineAudioContext(
            {length: 4 * RENDER_QUANTUM_FRAMES, sampleRate: sampleRate});

        // The source node to use.  Automations will be scheduled here.
        let src = new ConstantSourceNode(context, {offset: 0});
        src.connect(context.destination);

        // Initialize value to 1 at the beginning.
        src.offset.setValueAtTime(1, 0);

        // Test automations have this event time.
        let eventTime = eventFrame / context.sampleRate;

        // Sanity check that context is long enough for the test
        should(
            eventFrame < context.length,
            prefix + 'Context length is long enough for the test')
            .beTrue();

        // Automations to be tested.  The first event should be the actual
        // output up to the event time.  The last event should be the final
        // output from the event time and onwards.
        testCases.forEach(entry => {
          should(
              () => {
                src.offset[entry.event](
                    entry.value, eventTime, entry.extraArgs);
              },
              prefix +
                  eventToString(
                      entry.event, entry.value, eventTime, entry.extraArgs))
              .notThrow();
        });

        src.start();

        return context.startRendering();
      }

      // Verify output of test where the final value of the automation is
      // expected to be constant.
      function expectConstant(prefix, should, eventFrame, testCases) {
        return audioBuffer => {
          let audio = audioBuffer.getChannelData(0);

          let eventTime = eventFrame / sampleRate;

          // Compute the expected value of the first automation one frame before
          // the event time.  This is a quick check that the correct automation
          // was done.
          let expectedValue = methodMap[testCases[0].event](
              (eventFrame - 1) / sampleRate, 1, 0, testCases[0].value,
              eventTime);
          should(
              audio[eventFrame - 1],
              prefix +
                  `At time ${
                             (eventFrame - 1) / sampleRate
                           } (frame ${eventFrame - 1}) output`)
              .beCloseTo(expectedValue, {threshold: testCases[0].relError});

          // The last event scheduled is expected to set the value for all
          // future times.  Verify that the output has the expected value.
          should(
              audio.slice(eventFrame),
              prefix +
                  `At time ${eventTime} (frame ${
                                                 eventFrame
                                               }) and later, output`)
              .beConstantValueOf(testCases[testCases.length - 1].value);
        };
      }

      // Test output when two events of the same time are scheduled at the same
      // time.
      function testMultipleSameEvents(should, options) {
        let {method, prefix, threshold} = options;

        // Context for testing.
        let context =
            new OfflineAudioContext({length: 16384, sampleRate: sampleRate});

        let src = new ConstantSourceNode(context);
        src.connect(context.destination);

        let initialValue = 1;

        // Informative print
        should(() => {
          src.offset.setValueAtTime(initialValue, 0);
        }, prefix + `setValueAtTime(${initialValue}, 0)`).notThrow();

        let frame = 64;
        let time = frame / context.sampleRate;
        let values = [2, 7, 10];

        // Schedule two events of the same type at the same time, but with
        // different values.

        values.forEach(value => {
          // Informative prints to show what we're doing in this test.
          should(
              () => {
                src.offset[method](value, time);
              },
              prefix +
                  eventToString(
                      method,
                      value,
                      time,
                      ))
              .notThrow();
        })

        src.start();

        return context.startRendering().then(audioBuffer => {
          let actual = audioBuffer.getChannelData(0);

          // The output should be a ramp from time 0 to the event time.  But we
          // only verify the value just before the event time, which should be
          // fairly close to values[0].  (But compute the actual expected value
          // to be sure.)
          let expected = methodMap[method](
              (frame - 1) / context.sampleRate, initialValue, 0, values[0],
              time);
          should(actual[frame - 1], prefix + `Output at frame ${frame - 1}`)
              .beCloseTo(expected, {threshold: threshold, precision: 3});

          // Any other values shouldn't show up in the output.  Only the value
          // from last event should appear.  We only check the value at the
          // event time.
          should(
              actual[frame], prefix + `Output at frame ${frame} (${time} sec)`)
              .beEqualTo(values[values.length - 1]);
        });
      }

      // Convert an automation method to a string for printing.
      function eventToString(method, value, time, extras) {
        let string = method + '(';
        string += (value instanceof Array) ? `[${value}]` : value;
        string += ', ' + time;
        if (extras) {
          string += ', ' + extras;
        }
        string += ')';
        return string;
      }

      // Map between the automation method name and a function that computes the
      // output value of the automation method.
      const methodMap = {
        linearRampToValueAtTime: audioParamLinearRamp,
        exponentialRampToValueAtTime: audioParamExponentialRamp,
        setValueAtTime: (t, v) => v
      };
    </script>
  </body>
</html>
