[dart:html] Fix decodeAudioData to use both syntaxes
Closes https://github.com/dart-lang/sdk/issues/47520
decodeAudioData has an older callback-based syntax and newer
Promise-based syntax. In order to be consistent with the method
signature as well as be able to use both syntaxes, this CL
provides an API that can handle both.
Change-Id: I875defcfec9e429496a1ac9866f1b53d204eff69
Reviewed-on: https://dart-review.googlesource.com/c/sdk/+/221744
Reviewed-by: Riley Porter <rileyporter@google.com>
Reviewed-by: Sigmund Cherem <sigmund@google.com>
Commit-Queue: Srujan Gaddam <srujzs@google.com>
diff --git a/sdk/lib/web_audio/dart2js/web_audio_dart2js.dart b/sdk/lib/web_audio/dart2js/web_audio_dart2js.dart
index a7db2d5..29544d8 100644
--- a/sdk/lib/web_audio/dart2js/web_audio_dart2js.dart
+++ b/sdk/lib/web_audio/dart2js/web_audio_dart2js.dart
@@ -225,29 +225,89 @@
}
}
- @JSName('decodeAudioData')
- Future<AudioBuffer> _decodeAudioData(ByteBuffer audioData,
- [DecodeSuccessCallback? successCallback,
- DecodeErrorCallback? errorCallback]) native;
-
Future<AudioBuffer> decodeAudioData(ByteBuffer audioData,
[DecodeSuccessCallback? successCallback,
DecodeErrorCallback? errorCallback]) {
- if (successCallback != null && errorCallback != null) {
- return _decodeAudioData(audioData, successCallback, errorCallback);
+ // Both callbacks need to be provided if they're being used.
+ assert((successCallback == null) == (errorCallback == null));
+ // `decodeAudioData` can exist either in the older callback syntax or the
+ // newer `Promise`-based syntax that also accepts callbacks. In the former,
+ // we synthesize a `Future` to be consistent.
+ // For more details:
+ // https://developer.mozilla.org/en-US/docs/Web/API/BaseAudioContext/decodeAudioData
+ // https://www.w3.org/TR/webaudio/#dom-baseaudiocontext-decodeaudiodata
+ final completer = Completer<Object>();
+ var errorInCallbackIsNull = false;
+
+ void success(AudioBuffer decodedData) {
+ completer.complete(decodedData);
+ successCallback!.call(decodedData);
}
- var completer = new Completer<AudioBuffer>();
- _decodeAudioData(audioData, (value) {
- completer.complete(value);
- }, (error) {
- if (error == null) {
- completer.completeError('');
+ final nullErrorString =
+ '[AudioContext.decodeAudioData] completed with a null error.';
+
+ void error(DomException? error) {
+ // Safari has a bug where it may return null for the error callback. In
+ // the case where the Safari version still returns a `Promise` and the
+ // error is not null after the `Promise` is finished, the error callback
+ // is called instead in the `Promise`'s `catch` block. Otherwise, and in
+ // the case where a `Promise` is not returned by the API at all, the
+ // callback never gets called (for backwards compatibility, it can not
+ // accept null). Instead, the `Future` completes with a custom string,
+ // indicating that null was given.
+ // https://github.com/mdn/webaudio-examples/issues/5
+ if (error != null) {
+ // Note that we `complete` and not `completeError`. This is to make sure
+ // that errors in the `Completer` are not thrown if the call gets back
+ // a `Promise`.
+ completer.complete(error);
+ errorCallback!.call(error);
} else {
- completer.completeError(error);
+ completer.complete(nullErrorString);
+ errorInCallbackIsNull = true;
}
+ }
+
+ var decodeResult;
+ if (successCallback == null) {
+ decodeResult =
+ JS("creates:AudioBuffer;", "#.decodeAudioData(#)", this, audioData);
+ } else {
+ decodeResult = JS(
+ "creates:AudioBuffer;",
+ "#.decodeAudioData(#, #, #)",
+ this,
+ audioData,
+ convertDartClosureToJS(success, 1),
+ convertDartClosureToJS(error, 1));
+ }
+
+ if (decodeResult != null) {
+ // Promise-based syntax.
+ return promiseToFuture<AudioBuffer>(decodeResult).catchError((error) {
+ // If the error was null in the callback, but no longer is now that the
+ // `Promise` is finished, call the error callback. If it's still null,
+ // throw the error string. This is to handle the aforementioned bug in
+ // Safari.
+ if (errorInCallbackIsNull) {
+ if (error != null) {
+ errorCallback?.call(error);
+ } else {
+ throw nullErrorString;
+ }
+ }
+ throw error;
+ });
+ }
+
+ // Callback-based syntax. We use the above completer to synthesize a
+ // `Future` from the callback values. Since we don't use `completeError`
+ // above, `then` is used to simulate an error.
+ return completer.future.then((value) {
+ if (value is AudioBuffer) return value;
+ throw value;
});
- return completer.future;
}
}
// Copyright (c) 2012, the Dart project authors. Please see the AUTHORS file
diff --git a/tests/lib/html/audiocontext_test.dart b/tests/lib/html/audiocontext_test.dart
index 8316130..a091790 100644
--- a/tests/lib/html/audiocontext_test.dart
+++ b/tests/lib/html/audiocontext_test.dart
@@ -7,6 +7,7 @@
import 'dart:typed_data';
import 'dart:web_audio';
+import 'package:async_helper/async_helper.dart';
import 'package:expect/minitest.dart';
main() {
@@ -95,5 +96,74 @@
expect(oscillator.type, equals('triangle'));
}
});
+
+ asyncTest(() async {
+ if (AudioContext.supported) {
+ final audioSourceUrl = "/root_dart/tests/lib/html/small.mp3";
+
+ Future<void> requestAudioDecode(
+ {bool triggerDecodeError: false,
+ DecodeSuccessCallback? successCallback,
+ DecodeErrorCallback? errorCallback}) async {
+ HttpRequest audioRequest = HttpRequest();
+ audioRequest.open("GET", audioSourceUrl, async: true);
+ audioRequest.responseType = "arraybuffer";
+ var completer = new Completer<void>();
+ audioRequest.onLoad.listen((_) {
+ ByteBuffer audioData = audioRequest.response;
+ if (triggerDecodeError) audioData = Uint8List.fromList([]).buffer;
+ context
+ .decodeAudioData(audioData, successCallback, errorCallback)
+ .then((_) {
+ completer.complete();
+ }).catchError((e) {
+ completer.completeError(e);
+ });
+ });
+ audioRequest.send();
+ return completer.future;
+ }
+
+ // Decode successfully without callback.
+ await requestAudioDecode();
+
+ // Decode successfully with callback. Use counter to make sure it's only
+ // called once.
+ var successCallbackCalled = 0;
+ await requestAudioDecode(
+ successCallback: (_) {
+ successCallbackCalled += 1;
+ },
+ errorCallback: (_) {});
+ expect(successCallbackCalled, 1);
+
+ // Fail decode without callback.
+ try {
+ await requestAudioDecode(triggerDecodeError: true);
+ fail('Expected decode failure.');
+ } catch (_) {}
+
+ // Fail decode with callback.
+ var errorCallbackCalled = 0;
+ try {
+ await requestAudioDecode(
+ triggerDecodeError: true,
+ successCallback: (_) {},
+ errorCallback: (_) {
+ errorCallbackCalled += 1;
+ });
+ fail('Expected decode failure.');
+ } catch (e) {
+ // Safari may return a null error. Assuming Safari is version >= 14.1,
+ // the Future should complete with a string error if the error
+ // callback never gets called.
+ if (errorCallbackCalled == 0) {
+ expect(e is String, true);
+ } else {
+ expect(errorCallbackCalled, 1);
+ }
+ }
+ }
+ });
});
}
diff --git a/tests/lib/html/small.mp3 b/tests/lib/html/small.mp3
new file mode 100644
index 0000000..3fcc88b
--- /dev/null
+++ b/tests/lib/html/small.mp3
Binary files differ
diff --git a/tests/lib_2/html/audiocontext_test.dart b/tests/lib_2/html/audiocontext_test.dart
index b66e54b..396cf39 100644
--- a/tests/lib_2/html/audiocontext_test.dart
+++ b/tests/lib_2/html/audiocontext_test.dart
@@ -1,3 +1,6 @@
+// Copyright (c) 2022, the Dart project authors. Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
// @dart = 2.9
import 'dart:async';
@@ -5,6 +8,7 @@
import 'dart:typed_data';
import 'dart:web_audio';
+import 'package:async_helper/async_helper.dart';
import 'package:expect/minitest.dart';
main() {
@@ -93,5 +97,74 @@
expect(oscillator.type, equals('triangle'));
}
});
+
+ asyncTest(() async {
+ if (AudioContext.supported) {
+ final audioSourceUrl = "/root_dart/tests/lib_2/html/small.mp3";
+
+ Future<void> requestAudioDecode(
+ {bool triggerDecodeError: false,
+ DecodeSuccessCallback successCallback,
+ DecodeErrorCallback errorCallback}) async {
+ HttpRequest audioRequest = HttpRequest();
+ audioRequest.open("GET", audioSourceUrl, async: true);
+ audioRequest.responseType = "arraybuffer";
+ var completer = new Completer<void>();
+ audioRequest.onLoad.listen((_) {
+ ByteBuffer audioData = audioRequest.response;
+ if (triggerDecodeError) audioData = Uint8List.fromList([]).buffer;
+ context
+ .decodeAudioData(audioData, successCallback, errorCallback)
+ .then((_) {
+ completer.complete();
+ }).catchError((e) {
+ completer.completeError(e);
+ });
+ });
+ audioRequest.send();
+ return completer.future;
+ }
+
+ // Decode successfully without callback.
+ await requestAudioDecode();
+
+ // Decode successfully with callback. Use counter to make sure it's only
+ // called once.
+ var successCallbackCalled = 0;
+ await requestAudioDecode(
+ successCallback: (_) {
+ successCallbackCalled += 1;
+ },
+ errorCallback: (_) {});
+ expect(successCallbackCalled, 1);
+
+ // Fail decode without callback.
+ try {
+ await requestAudioDecode(triggerDecodeError: true);
+ fail('Expected decode failure.');
+ } catch (_) {}
+
+ // Fail decode with callback.
+ var errorCallbackCalled = 0;
+ try {
+ await requestAudioDecode(
+ triggerDecodeError: true,
+ successCallback: (_) {},
+ errorCallback: (_) {
+ errorCallbackCalled += 1;
+ });
+ fail('Expected decode failure.');
+ } catch (e) {
+ // Safari may return a null error. Assuming Safari is version >= 14.1,
+ // the Future should complete with a string error if the error
+ // callback never gets called.
+ if (errorCallbackCalled == 0) {
+ expect(e is String, true);
+ } else {
+ expect(errorCallbackCalled, 1);
+ }
+ }
+ }
+ });
});
}
diff --git a/tests/lib_2/html/small.mp3 b/tests/lib_2/html/small.mp3
new file mode 100644
index 0000000..3fcc88b
--- /dev/null
+++ b/tests/lib_2/html/small.mp3
Binary files differ
diff --git a/tools/dom/templates/html/impl/impl_AudioContext.darttemplate b/tools/dom/templates/html/impl/impl_AudioContext.darttemplate
index d716afd..77f860d 100644
--- a/tools/dom/templates/html/impl/impl_AudioContext.darttemplate
+++ b/tools/dom/templates/html/impl/impl_AudioContext.darttemplate
@@ -41,28 +41,88 @@
}
}
- @JSName('decodeAudioData')
- Future$#NULLSAFECAST(<AudioBuffer>) _decodeAudioData(ByteBuffer audioData,
- [DecodeSuccessCallback$NULLABLE successCallback,
- DecodeErrorCallback$NULLABLE errorCallback]) native;
-
Future<AudioBuffer> decodeAudioData(ByteBuffer audioData,
[DecodeSuccessCallback$NULLABLE successCallback,
DecodeErrorCallback$NULLABLE errorCallback]) {
- if (successCallback != null && errorCallback != null) {
- return _decodeAudioData(audioData, successCallback, errorCallback);
+ // Both callbacks need to be provided if they're being used.
+ assert((successCallback == null) == (errorCallback == null));
+ // `decodeAudioData` can exist either in the older callback syntax or the
+ // newer `Promise`-based syntax that also accepts callbacks. In the former,
+ // we synthesize a `Future` to be consistent.
+ // For more details:
+ // https://developer.mozilla.org/en-US/docs/Web/API/BaseAudioContext/decodeAudioData
+ // https://www.w3.org/TR/webaudio/#dom-baseaudiocontext-decodeaudiodata
+ final completer = Completer<Object>();
+ var errorInCallbackIsNull = false;
+
+ void success(AudioBuffer decodedData) {
+ completer.complete(decodedData);
+ successCallback$NULLASSERT.call(decodedData);
}
- var completer = new Completer<AudioBuffer>();
- _decodeAudioData(audioData, (value) {
- completer.complete(value);
- }, (error) {
- if (error == null) {
- completer.completeError('');
+ final nullErrorString =
+ '[AudioContext.decodeAudioData] completed with a null error.';
+
+ void error(DomException$NULLABLE error) {
+ // Safari has a bug where it may return null for the error callback. In
+ // the case where the Safari version still returns a `Promise` and the
+ // error is not null after the `Promise` is finished, the error callback
+ // is called instead in the `Promise`'s `catch` block. Otherwise, and in
+ // the case where a `Promise` is not returned by the API at all, the
+ // callback never gets called (for backwards compatibility, it can not
+ // accept null). Instead, the `Future` completes with a custom string,
+ // indicating that null was given.
+ // https://github.com/mdn/webaudio-examples/issues/5
+ if (error != null) {
+ // Note that we `complete` and not `completeError`. This is to make sure
+ // that errors in the `Completer` are not thrown if the call gets back
+ // a `Promise`.
+ completer.complete(error);
+ errorCallback$NULLASSERT.call(error);
} else {
- completer.completeError(error);
+ completer.complete(nullErrorString);
+ errorInCallbackIsNull = true;
}
+ }
+
+ var decodeResult;
+ if (successCallback == null) {
+ decodeResult =
+ JS("creates:AudioBuffer;", "#.decodeAudioData(#)", this, audioData);
+ } else {
+ decodeResult = JS(
+ "creates:AudioBuffer;",
+ "#.decodeAudioData(#, #, #)",
+ this,
+ audioData,
+ convertDartClosureToJS(success, 1),
+ convertDartClosureToJS(error, 1));
+ }
+
+ if (decodeResult != null) {
+ // Promise-based syntax.
+ return promiseToFuture<AudioBuffer>(decodeResult).catchError((error) {
+ // If the error was null in the callback, but no longer is now that the
+ // `Promise` is finished, call the error callback. If it's still null,
+ // throw the error string. This is to handle the aforementioned bug in
+ // Safari.
+ if (errorInCallbackIsNull) {
+ if (error != null) {
+ errorCallback?.call(error);
+ } else {
+ throw nullErrorString;
+ }
+ }
+ throw error;
+ });
+ }
+
+ // Callback-based syntax. We use the above completer to synthesize a
+ // `Future` from the callback values. Since we don't use `completeError`
+ // above, `then` is used to simulate an error.
+ return completer.future.then((value) {
+ if (value is AudioBuffer) return value;
+ throw value;
});
- return completer.future;
}
}