Better audio management (#10)

* add audio_session package

* `AudioManager` initial design

* try to integrate audio manager

* configure audio session

* `DisposeAware`

disposing if already published causes exception.

* organize

* guard flutter_webrtc calls

* websocket async fix

* use `ConnectionState` instead of `_isClosed` and `isReconnecting`

* change create audio track defaults

* update protos

* protocol 3 speaker updates

* fix exception

* emit `RoomDisconnectedEvent` only once

* fix exception

* keep track of local / remote audio tracks

* explicit types

* manage track state

* re-structure audio management

* change defaults

* unpublish all

* use experimental build

* change defaults

* configuring is optional

* call native `RTCAudioSession.setConfiguration`

* defaults adjustment

* iOS only for now

* use lib 92.4515.07

* clean up

* revert audio options for now

* remove pod source

* rename apple related audio

* `createListener` method

* organize native audio

* `SpeakingChangedEvent` only on `Participant`

* refactoring

* fix ios compile

* fix configure audio only for iOS logic

* minor fix & clean up

* change dispose logic

* format protos

* fix unpublishTrack

* `createListener` into a mixin

* simplify

* use flutter_webrtc master

* unpublish for example

* update ios icon

* android icon

* web icon

* favicon
This commit is contained in:
Hiroshi Horie
2021-10-01 02:47:18 +09:00
committed by GitHub
parent ec17907cdc
commit 001f688729
101 changed files with 1658 additions and 681 deletions
+90
View File
@@ -0,0 +1,90 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:meta/meta.dart';
import '../extensions.dart';
import '../logger.dart';
typedef OnDisposeFunc = Future<void> Function();
mixin _Disposer {
//
final _disposeFuncs = <OnDisposeFunc>[];
bool _isDisposed = false;
bool get isDisposed => _isDisposed;
// last added func will be called first when disposing
void onDispose(OnDisposeFunc func) => _disposeFuncs.add(func);
Future<bool> _dispose() async {
if (!_isDisposed) {
logger.fine('[${objectId}] dispose()');
_isDisposed = true;
if (_disposeFuncs.isNotEmpty) {
logger.fine('[$objectId] running ${_disposeFuncs.length} dispose funcs...');
// call dispose funcs in reverse order
for (final _func in _disposeFuncs.reversed) {
await _func();
}
_disposeFuncs.clear();
logger.fine('[$objectId] dispose complete.');
}
return true;
} else {
logger.warning('[$objectId] unnecessary dispose() called.');
return false;
}
}
}
abstract class Disposable with _Disposer {
@mustCallSuper
Future<bool> dispose() async {
return await _dispose();
}
}
abstract class DisposableChangeNotifier extends ChangeNotifier with _Disposer {
@override
Future<bool> dispose() async {
if (!isDisposed) super.dispose();
return await super._dispose();
}
@override
bool get hasListeners {
if (isDisposed) {
logger.warning('called hasListeners on a disposed ChangeNotifier');
return false;
}
return super.hasListeners;
}
@override
void addListener(VoidCallback listener) {
if (isDisposed) {
logger.warning('called addListener() on a disposed ChangeNotifier');
return;
}
super.addListener(listener);
}
@override
void notifyListeners() {
if (isDisposed) {
logger.warning('called notifyListeners() on a disposed ChangeNotifier');
return;
}
super.notifyListeners();
}
@override
void removeListener(VoidCallback listener) {
if (isDisposed) {
logger.warning('called removeListener() on a disposed ChangeNotifier');
return;
}
super.removeListener(listener);
}
}
+122
View File
@@ -0,0 +1,122 @@
// https://developer.apple.com/documentation/avfaudio/avaudiosession/category
import 'package:flutter/services.dart';
import '../logger.dart';
enum AppleAudioCategory {
soloAmbient,
playback,
record,
playAndRecord,
multiRoute,
}
// https://developer.apple.com/documentation/avfaudio/avaudiosession/categoryoptions
enum AppleAudioCategoryOption {
mixWithOthers, // Only playAndRecord, playback, or multiRoute.
duckOthers, // Only playAndRecord, playback, or multiRoute.
interruptSpokenAudioAndMixWithOthers,
allowBluetooth, // Only playAndRecord or record.
allowBluetoothA2DP,
allowAirPlay,
defaultToSpeaker,
}
// https://developer.apple.com/documentation/avfaudio/avaudiosession/mode
enum AppleAudioMode {
default_,
gameChat,
measurement,
moviePlayback,
spokenAudio,
videoChat,
videoRecording,
voiceChat,
voicePrompt,
}
extension AppleAudioCategoryExt on AppleAudioCategory {
String toStringValue() => <AppleAudioCategory, String>{
AppleAudioCategory.soloAmbient: 'soloAmbient',
AppleAudioCategory.playback: 'playback',
AppleAudioCategory.record: 'record',
AppleAudioCategory.playAndRecord: 'playAndRecord',
AppleAudioCategory.multiRoute: 'multiRoute',
}[this]!;
}
extension AppleAudioCategoryOptionExt on AppleAudioCategoryOption {
String toStringValue() => <AppleAudioCategoryOption, String>{
AppleAudioCategoryOption.mixWithOthers: 'mixWithOthers',
AppleAudioCategoryOption.duckOthers: 'duckOthers',
AppleAudioCategoryOption.interruptSpokenAudioAndMixWithOthers:
'interruptSpokenAudioAndMixWithOthers',
AppleAudioCategoryOption.allowBluetooth: 'allowBluetooth',
AppleAudioCategoryOption.allowBluetoothA2DP: 'allowBluetoothA2DP',
AppleAudioCategoryOption.allowAirPlay: 'allowAirPlay',
AppleAudioCategoryOption.defaultToSpeaker: 'defaultToSpeaker',
}[this]!;
}
extension AppleAudioModeExt on AppleAudioMode {
String toStringValue() => <AppleAudioMode, String>{
AppleAudioMode.default_: 'default',
AppleAudioMode.gameChat: 'gameChat',
AppleAudioMode.measurement: 'measurement',
AppleAudioMode.moviePlayback: 'moviePlayback',
AppleAudioMode.spokenAudio: 'spokenAudio',
AppleAudioMode.videoChat: 'videoChat',
AppleAudioMode.videoRecording: 'videoRecording',
AppleAudioMode.voiceChat: 'voiceChat',
AppleAudioMode.voicePrompt: 'voicePrompt',
}[this]!;
}
class NativeAudioConfiguration {
final AppleAudioCategory? appleAudioCategory;
final Set<AppleAudioCategoryOption>? appleAudioCategoryOptions;
final AppleAudioMode? appleAudioMode;
NativeAudioConfiguration({
// for iOS / Mac
this.appleAudioCategory,
this.appleAudioCategoryOptions,
this.appleAudioMode,
// Android options
// ...
});
Map<String, dynamic> toMap() => <String, dynamic>{
if (appleAudioCategory != null) 'appleAudioCategory': appleAudioCategory!.toStringValue(),
if (appleAudioCategoryOptions != null)
'appleAudioCategoryOptions':
appleAudioCategoryOptions!.map((e) => e.toStringValue()).toList(),
if (appleAudioMode != null) 'appleAudioMode': appleAudioMode!.toStringValue(),
};
NativeAudioConfiguration copyWith({
AppleAudioCategory? appleAudioCategory,
Set<AppleAudioCategoryOption>? appleAudioCategoryOptions,
AppleAudioMode? appleAudioMode,
}) =>
NativeAudioConfiguration(
appleAudioCategory: appleAudioCategory ?? this.appleAudioCategory,
appleAudioCategoryOptions: appleAudioCategoryOptions ?? this.appleAudioCategoryOptions,
appleAudioMode: appleAudioMode ?? this.appleAudioMode,
);
}
const _lkMethodChannel = MethodChannel('livekit_client');
Future<bool> configureNativeAudio(NativeAudioConfiguration configuration) async {
try {
final result = await _lkMethodChannel.invokeMethod<bool>(
'configureNativeAudio',
configuration.toMap(),
);
return result == true;
} catch (_) {
logger.warning('configureAudioSession did throw $_');
return false;
}
}
+65
View File
@@ -0,0 +1,65 @@
import 'dart:async';
import 'dart:io' as io;
import '../../logger.dart';
import '../websocket.dart';
import '../../extensions.dart';
Future<LiveKitWebSocketIO> lkWebSocketConnect(
Uri uri, [
WebSocketEventHandlers? options,
]) =>
LiveKitWebSocketIO.connect(uri, options);
class LiveKitWebSocketIO implements LiveKitWebSocket {
final io.WebSocket _ws;
final WebSocketEventHandlers? options;
late final StreamSubscription _subscription;
LiveKitWebSocketIO._(
this._ws, [
this.options,
]) {
_subscription = _ws.listen(
(dynamic data) => options?.onData?.call(data),
onDone: () => dispose(),
);
}
@override
Future<void> dispose() async {
await _subscription.cancel();
await _ws.close();
options?.onDispose?.call();
}
@override
void send(List<int> data) {
// 0 CONNECTING, 1 OPEN, 2 CLOSING, 3 CLOSED
if (_ws.readyState != 1) {
logger.fine('[$objectId] Tried to send data (readyState: ${_ws.readyState})');
return;
}
try {
_ws.add(data);
} catch (_) {
logger.fine('[$objectId] send did throw ${_}');
}
}
static Future<LiveKitWebSocketIO> connect(
Uri uri, [
WebSocketEventHandlers? options,
]) async {
logger.fine('[WebSocketIO] Connecting(uri: ${uri.toString()})...');
try {
final ws = await io.WebSocket.connect(uri.toString());
logger.fine('[WebSocketIO] Connected');
return LiveKitWebSocketIO._(ws, options);
} catch (_) {
logger.severe('[WebSocketIO] did throw ${_}');
throw WebSocketException.connect();
}
}
}
+54
View File
@@ -0,0 +1,54 @@
import 'dart:async';
// ignore: avoid_web_libraries_in_flutter
import 'dart:html' as html;
import 'dart:typed_data';
import '../websocket.dart';
Future<LiveKitWebSocketWeb> lkWebSocketConnect(
Uri uri, [
WebSocketEventHandlers? options,
]) =>
LiveKitWebSocketWeb.connect(uri, options);
class LiveKitWebSocketWeb implements LiveKitWebSocket {
final html.WebSocket _ws;
final WebSocketEventHandlers? options;
late final StreamSubscription _messageSubscription;
late final StreamSubscription _closeSubscription;
LiveKitWebSocketWeb._(
this._ws, [
this.options,
]) {
_ws.binaryType = 'arraybuffer';
_messageSubscription = _ws.onMessage.listen((_) {
dynamic _data = _.data is ByteBuffer ? _.data.asUint8List() : _.data;
options?.onData?.call(_data);
});
_closeSubscription = _ws.onClose.listen((_) => dispose());
}
@override
void send(List<int> data) => _ws.send(data);
@override
Future<void> dispose() async {
options?.onDispose?.call();
await _messageSubscription.cancel();
await _closeSubscription.cancel();
_ws.close();
}
static Future<LiveKitWebSocketWeb> connect(
Uri uri, [
WebSocketEventHandlers? options,
]) async {
final completer = Completer<LiveKitWebSocketWeb>();
final ws = html.WebSocket(uri.toString());
ws.onOpen.listen((_) => completer.complete(LiveKitWebSocketWeb._(ws, options)));
ws.onError.listen((_) => completer.completeError(WebSocketException.connect()));
return completer.future;
}
}
+41
View File
@@ -0,0 +1,41 @@
import 'platforms/io.dart' if (dart.library.html) 'platforms/web.dart';
class WebSocketException implements Exception {
final int code;
const WebSocketException._(this.code);
static WebSocketException unknown() => const WebSocketException._(0);
static WebSocketException connect() => const WebSocketException._(1);
@override
String toString() => {
WebSocketException.unknown(): 'Unknown error',
WebSocketException.connect(): 'Failed to connect',
}[this]!;
}
typedef WebSocketOnData = Function(dynamic data);
typedef WebSocketOnError = Function(dynamic error);
typedef WebSocketOnDispose = Function();
class WebSocketEventHandlers {
final WebSocketOnData? onData;
final WebSocketOnError? onError;
final WebSocketOnDispose? onDispose;
const WebSocketEventHandlers({
this.onData,
this.onError,
this.onDispose,
});
}
abstract class LiveKitWebSocket {
void send(List<int> data);
Future<void> dispose();
static Future<LiveKitWebSocket> connect(
Uri uri, [
WebSocketEventHandlers? options,
]) =>
lkWebSocketConnect(uri, options);
}