feat: promote linux native audio path

This commit is contained in:
Edison Jwa
2026-05-25 17:42:06 +09:00
parent c19de3a370
commit a2d686d9d0
73 changed files with 26156 additions and 467 deletions
@@ -171,7 +171,7 @@ List<PlatformCapability> _windowsCapabilities() => [
List<PlatformCapability> _linuxCapabilities() => [
const PlatformCapability(
feature: 'Voice capture',
description: 'PulseAudio/ALSA via cpal.',
description: 'PipeWire voice I/O with PulseAudio fallback.',
tier: CapabilityTier.supported,
),
const PlatformCapability(
+106
View File
@@ -226,6 +226,9 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
AndroidPermissionsService();
final IosPermissionsService _iosPermissions = IosPermissionsService();
final UiPreferencesService _uiPreferences = const UiPreferencesService();
Map<String, ClientPlaybackPreference> _clientPlaybackPrefs = const {};
final Map<BigInt, double> _appliedClientPlaybackVolumes = {};
String _clientPlaybackPrefsHost = '';
late final BackIntentService _backIntentService;
int _modalRouteDepth = 0;
@@ -314,6 +317,105 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
} catch (_) {}
}
Future<void> _ensureClientPlaybackPreferencesLoadedForCurrentHost() async {
final host = _hostCtl.text.trim().toLowerCase();
if (host.isEmpty) {
_clientPlaybackPrefsHost = '';
_clientPlaybackPrefs = const {};
_appliedClientPlaybackVolumes.clear();
return;
}
if (_clientPlaybackPrefsHost == host) return;
_appliedClientPlaybackVolumes.clear();
try {
_clientPlaybackPrefs = await _uiPreferences
.loadClientPlaybackPreferencesForServer(host);
_clientPlaybackPrefsHost = host;
} catch (_) {
_clientPlaybackPrefsHost = host;
_clientPlaybackPrefs = const {};
}
}
Future<void> _applyClientPlaybackPreferencesToSnapshot(
rust.BridgeSnapshot snap,
) async {
await _ensureClientPlaybackPreferencesLoadedForCurrentHost();
if (_clientPlaybackPrefs.isEmpty) return;
for (final client in snap.clients) {
if (client.id == snap.ownClientId ||
client.isServerQuery ||
client.uid.isEmpty) {
continue;
}
final pref = _clientPlaybackPrefs[client.uid];
if (pref == null) continue;
if (_appliedClientPlaybackVolumes[client.id] == pref.appliedVolume) {
continue;
}
try {
await rust.setClientVolume(
clientId: client.id,
volume: pref.appliedVolume,
);
_appliedClientPlaybackVolumes[client.id] = pref.appliedVolume;
} catch (_) {
// Best-effort: queues may not exist yet for silent users.
}
}
}
Future<void> _setClientPlaybackPreference(
rust.BridgeClient client,
ClientPlaybackPreference preference,
) async {
if (client.id == _snapshot?.ownClientId ||
client.isServerQuery ||
client.uid.isEmpty) {
return;
}
final host = _hostCtl.text.trim().toLowerCase();
if (host.isEmpty) return;
await _ensureClientPlaybackPreferencesLoadedForCurrentHost();
final nextPrefs = Map<String, ClientPlaybackPreference>.from(
_clientPlaybackPrefs,
);
if (preference.volume == 1.0 && !preference.muted) {
nextPrefs.remove(client.uid);
} else {
nextPrefs[client.uid] = preference;
}
if (mounted) {
setState(() => _clientPlaybackPrefs = nextPrefs);
} else {
_clientPlaybackPrefs = nextPrefs;
}
try {
await _uiPreferences.saveClientPlaybackPreference(
serverHost: host,
userUid: client.uid,
volume: preference.volume,
muted: preference.muted,
);
} catch (error) {
_recordUiDiagnostic('save client playback preference', error);
}
try {
await rust.setClientVolume(
clientId: client.id,
volume: preference.appliedVolume,
);
_appliedClientPlaybackVolumes[client.id] = preference.appliedVolume;
} catch (error) {
_recordUiDiagnostic('set client volume', error);
}
}
Future<void> _requestRecordAudioOnStartup() async {
try {
if (Platform.isAndroid) {
@@ -1482,6 +1584,7 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
void _applySnapshot(rust.BridgeSnapshot snap) {
_snapshot = snap;
unawaited(_applyClientPlaybackPreferencesToSnapshot(snap));
final own = ownClientSnapshotState(snap);
if (own == null) return;
_inputMuted = own.inputMuted;
@@ -2013,6 +2116,9 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
onJoinChannel: (ch) => _onJoinChannel(ch),
onJoinChannelWithPassword: (ch) =>
_onJoinChannel(ch, askForPassword: true),
clientPlaybackPreferences: _clientPlaybackPrefs,
onClientPlaybackPreferenceChanged:
_setClientPlaybackPreference,
onTs3ServerLink: _onTs3ServerLink,
);
final ownClientState = _ownClientState;
@@ -18,17 +18,19 @@ Future<void>? _storageInitFuture;
Future<void>? _vadBootstrapFuture;
StorageDirectoryProvider _storageDirectoryProvider =
getApplicationSupportDirectory;
StorageDirectoryProvider _vadModelDirectoryProvider =
getApplicationSupportDirectory;
StorageInitializer _storageInitializer = _defaultStorageInitializer;
Future<void> _defaultStorageInitializer(String dir) {
return rust.initStorage(dir: dir);
}
Future<File> _copyBundledAssetToDocuments({
Future<File> _copyBundledAssetToManagedDirectory({
required String assetPath,
required String fileName,
}) async {
final dir = await getApplicationDocumentsDirectory();
final dir = await _vadModelDirectoryProvider();
final file = File('${dir.path}/$fileName');
final data = await rootBundle.load(assetPath);
final bytes = data.buffer.asUint8List(data.offsetInBytes, data.lengthInBytes);
@@ -59,7 +61,7 @@ Future<void> configureBundledVadModels() async {
}
Future<void> _configureBundledVadModelsImpl() async {
final silero = await _copyBundledAssetToDocuments(
final silero = await _copyBundledAssetToManagedDirectory(
assetPath: _sileroVadAsset,
fileName: 'silero_vad.onnx',
);
@@ -124,12 +126,15 @@ Future<void> _wireStorageImpl() async {
@visibleForTesting
void debugResetStorageBootstrap({
StorageDirectoryProvider? storageDirectoryProvider,
StorageDirectoryProvider? vadModelDirectoryProvider,
StorageInitializer? storageInitializer,
}) {
_storageInitFuture = null;
_vadBootstrapFuture = null;
_storageDirectoryProvider =
storageDirectoryProvider ?? getApplicationSupportDirectory;
_vadModelDirectoryProvider =
vadModelDirectoryProvider ?? getApplicationSupportDirectory;
_storageInitializer = storageInitializer ?? _defaultStorageInitializer;
}
@@ -117,8 +117,20 @@ Future<StartupDependencyCheckResult> checkStartupDependencies() async {
final executableDir = File(_resolvedExecutableProvider()).parent.path;
final issues = <StartupDependencyIssue>[];
if (!_hasAnyLoadableLibrary(const ['libSDL2.so', 'libSDL2-2.0.so.0'])) {
issues.add(_buildSdlIssue(distro));
if (!_hasAnyLoadableLibrary(const [
'libpipewire-0.3.so.0',
'libpipewire-0.3.so',
])) {
issues.add(_buildPipeWireIssue(distro));
}
if (!_hasAnyLoadableLibrary(const [
'libpulse.so.0',
'libpulse.so',
'libpulse-simple.so.0',
'libpulse-simple.so',
])) {
issues.add(_buildPulseAudioIssue(distro));
}
if (!await _hasOnnxRuntime(executableDir: executableDir)) {
@@ -272,33 +284,76 @@ _LinuxArch _detectLinuxArch() {
};
}
StartupDependencyIssue _buildSdlIssue(_LinuxDistro distro) {
StartupDependencyIssue _buildPipeWireIssue(_LinuxDistro distro) {
final List<StartupInstallHint> hints = switch (distro) {
_LinuxDistro.debian => const <StartupInstallHint>[
StartupInstallHint(
label: 'Debian / Ubuntu',
command: 'sudo apt install libsdl2-2.0-0',
command: 'sudo apt install libpipewire-0.3-0',
),
],
_LinuxDistro.fedora => const <StartupInstallHint>[
StartupInstallHint(label: 'Fedora', command: 'sudo dnf install SDL2'),
StartupInstallHint(
label: 'Fedora',
command: 'sudo dnf install pipewire-libs',
),
],
_LinuxDistro.arch => const <StartupInstallHint>[
StartupInstallHint(label: 'Arch Linux', command: 'sudo pacman -S sdl2'),
StartupInstallHint(
label: 'Arch Linux',
command: 'sudo pacman -S pipewire',
),
],
_LinuxDistro.other => const <StartupInstallHint>[],
};
return StartupDependencyIssue(
id: 'linux-sdl2-runtime',
title: 'SDL2 runtime is missing',
id: 'linux-pipewire-runtime',
title: 'PipeWire runtime is missing',
summary:
'Chanora uses SDL2 for Linux audio playback. Without it, voice output will not work.',
'Chanora uses PipeWire as the primary Linux voice backend. Without it, Chanora will try the PulseAudio fallback.',
details: const [
'Install the SDL2 runtime package for your distribution.',
'Install the PipeWire runtime package for your distribution.',
'After installing it, restart Chanora and tap Recheck.',
],
severity: StartupDependencySeverity.required,
severity: StartupDependencySeverity.recommended,
installHints: hints,
);
}
StartupDependencyIssue _buildPulseAudioIssue(_LinuxDistro distro) {
final List<StartupInstallHint> hints = switch (distro) {
_LinuxDistro.debian => const <StartupInstallHint>[
StartupInstallHint(
label: 'Debian / Ubuntu',
command: 'sudo apt install libpulse0',
),
],
_LinuxDistro.fedora => const <StartupInstallHint>[
StartupInstallHint(
label: 'Fedora',
command: 'sudo dnf install pulseaudio-libs',
),
],
_LinuxDistro.arch => const <StartupInstallHint>[
StartupInstallHint(
label: 'Arch Linux',
command: 'sudo pacman -S libpulse',
),
],
_LinuxDistro.other => const <StartupInstallHint>[],
};
return StartupDependencyIssue(
id: 'linux-pulseaudio-runtime',
title: 'PulseAudio runtime is missing',
summary:
'Chanora uses PulseAudio as the Linux fallback voice backend when PipeWire is unavailable.',
details: const [
'Install the PulseAudio client library package for your distribution.',
'After installing it, restart Chanora and tap Recheck.',
],
severity: StartupDependencySeverity.recommended,
installHints: hints,
);
}
@@ -1,3 +1,5 @@
import 'dart:convert';
import 'package:shared_preferences/shared_preferences.dart';
class UiSettings {
@@ -7,10 +9,45 @@ class UiSettings {
final String nickname;
}
class ClientPlaybackPreference {
const ClientPlaybackPreference({this.volume = 1.0, this.muted = false});
final double volume;
final bool muted;
double get appliedVolume => muted ? 0.0 : volume;
ClientPlaybackPreference copyWith({double? volume, bool? muted}) {
return ClientPlaybackPreference(
volume: _clampVolume(volume ?? this.volume),
muted: muted ?? this.muted,
);
}
Map<String, Object> toJson() => {
'volume': _clampVolume(volume),
'muted': muted,
};
static ClientPlaybackPreference fromJson(Map<String, dynamic> json) {
final volume = json['volume'];
return ClientPlaybackPreference(
volume: _clampVolume(volume is num ? volume.toDouble() : 1.0),
muted: json['muted'] as bool? ?? false,
);
}
static double _clampVolume(double volume) {
if (!volume.isFinite) return 1.0;
return volume.clamp(0.0, 4.0);
}
}
class UiPreferencesService {
static const _hostKey = 'ui.host';
static const _nicknameKey = 'ui.nickname';
static const _permissionsExplainedKey = 'perms_explained';
static const _clientPlaybackPrefsKey = 'audio.client_playback_prefs';
const UiPreferencesService();
@@ -37,4 +74,80 @@ class UiPreferencesService {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_permissionsExplainedKey, true);
}
Future<Map<String, ClientPlaybackPreference>>
loadClientPlaybackPreferencesForServer(String serverHost) async {
final normalizedHost = _normalizeServerHost(serverHost);
if (normalizedHost.isEmpty) return const {};
final prefs = await SharedPreferences.getInstance();
final raw = prefs.getString(_clientPlaybackPrefsKey);
if (raw == null || raw.isEmpty) return const {};
final decoded = _decodeClientPlaybackPreferences(raw);
final result = <String, ClientPlaybackPreference>{};
for (final entry in decoded.entries) {
final key = entry.key;
final value = entry.value;
final (host, uid) = _splitCompositeKey(key);
if (host != normalizedHost ||
uid.isEmpty ||
value is! Map<String, dynamic>) {
continue;
}
result[uid] = ClientPlaybackPreference.fromJson(value);
}
return result;
}
Future<void> saveClientPlaybackPreference({
required String serverHost,
required String userUid,
double? volume,
bool? muted,
}) async {
final normalizedHost = _normalizeServerHost(serverHost);
final normalizedUid = userUid.trim();
if (normalizedHost.isEmpty || normalizedUid.isEmpty) return;
final prefs = await SharedPreferences.getInstance();
final current = await loadClientPlaybackPreferencesForServer(serverHost);
final previous = current[normalizedUid] ?? const ClientPlaybackPreference();
final updated = previous.copyWith(volume: volume, muted: muted);
final raw = prefs.getString(_clientPlaybackPrefsKey);
final decoded = _decodeClientPlaybackPreferences(raw);
final compositeKey = _compositeKey(normalizedHost, normalizedUid);
if (updated.volume == 1.0 && !updated.muted) {
decoded.remove(compositeKey);
} else {
decoded[compositeKey] = updated.toJson();
}
await prefs.setString(_clientPlaybackPrefsKey, jsonEncode(decoded));
}
String _compositeKey(String normalizedHost, String normalizedUid) {
return '$normalizedHost|$normalizedUid';
}
(String, String) _splitCompositeKey(String key) {
final index = key.indexOf('|');
if (index == -1) return ('', '');
return (key.substring(0, index), key.substring(index + 1));
}
static String _normalizeServerHost(String host) => host.trim().toLowerCase();
Map<String, dynamic> _decodeClientPlaybackPreferences(String? raw) {
if (raw == null || raw.isEmpty) return <String, dynamic>{};
try {
final decoded = jsonDecode(raw);
return decoded is Map<String, dynamic> ? decoded : <String, dynamic>{};
} catch (_) {
return <String, dynamic>{};
}
}
}
@@ -795,6 +795,9 @@ class BridgeClient {
/// Stable client id.
final BigInt id;
/// Stable TeamSpeak unique identifier.
final String uid;
/// Channel id the client is currently in.
final BigInt channel;
@@ -821,6 +824,7 @@ class BridgeClient {
const BridgeClient({
required this.id,
required this.uid,
required this.channel,
required this.name,
required this.inputMuted,
@@ -834,6 +838,7 @@ class BridgeClient {
@override
int get hashCode =>
id.hashCode ^
uid.hashCode ^
channel.hashCode ^
name.hashCode ^
inputMuted.hashCode ^
@@ -849,6 +854,7 @@ class BridgeClient {
other is BridgeClient &&
runtimeType == other.runtimeType &&
id == other.id &&
uid == other.uid &&
channel == other.channel &&
name == other.name &&
inputMuted == other.inputMuted &&
@@ -1751,18 +1751,19 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
BridgeClient dco_decode_bridge_client(dynamic raw) {
// Codec=Dco (DartCObject based), see doc to use other codecs
final arr = raw as List<dynamic>;
if (arr.length != 9)
throw Exception('unexpected arr length: expect 9 but see ${arr.length}');
if (arr.length != 10)
throw Exception('unexpected arr length: expect 10 but see ${arr.length}');
return BridgeClient(
id: dco_decode_u_64(arr[0]),
channel: dco_decode_u_64(arr[1]),
name: dco_decode_String(arr[2]),
inputMuted: dco_decode_bool(arr[3]),
outputMuted: dco_decode_bool(arr[4]),
isSpeaking: dco_decode_bool(arr[5]),
isServerQuery: dco_decode_bool(arr[6]),
talkPower: dco_decode_i_32(arr[7]),
talkPowerGranted: dco_decode_bool(arr[8]),
uid: dco_decode_String(arr[1]),
channel: dco_decode_u_64(arr[2]),
name: dco_decode_String(arr[3]),
inputMuted: dco_decode_bool(arr[4]),
outputMuted: dco_decode_bool(arr[5]),
isSpeaking: dco_decode_bool(arr[6]),
isServerQuery: dco_decode_bool(arr[7]),
talkPower: dco_decode_i_32(arr[8]),
talkPowerGranted: dco_decode_bool(arr[9]),
);
}
@@ -2352,6 +2353,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
BridgeClient sse_decode_bridge_client(SseDeserializer deserializer) {
// Codec=Sse (Serialization based), see doc to use other codecs
var var_id = sse_decode_u_64(deserializer);
var var_uid = sse_decode_String(deserializer);
var var_channel = sse_decode_u_64(deserializer);
var var_name = sse_decode_String(deserializer);
var var_inputMuted = sse_decode_bool(deserializer);
@@ -2362,6 +2364,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
var var_talkPowerGranted = sse_decode_bool(deserializer);
return BridgeClient(
id: var_id,
uid: var_uid,
channel: var_channel,
name: var_name,
inputMuted: var_inputMuted,
@@ -1,3 +1,5 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
@@ -6,10 +8,17 @@ import '../services/channel_spacer.dart';
import '../services/link_trust_service.dart';
import '../services/snapshot_state_mapper.dart';
import '../services/ts3_server_link.dart';
import '../services/ui_preferences_service.dart';
import '../src/rust/api.dart' as rust;
import 'bbcode_text.dart';
import 'talk_power_warning.dart';
typedef ClientPlaybackPreferenceChanged =
Future<void> Function(
rust.BridgeClient client,
ClientPlaybackPreference preference,
);
/// Connected-server snapshot with welcome text, channels, and clients.
class SnapshotView extends StatefulWidget {
/// Construct a snapshot view.
@@ -25,6 +34,8 @@ class SnapshotView extends StatefulWidget {
required this.canJoinVoiceChannel,
required this.onJoinChannel,
required this.onJoinChannelWithPassword,
this.clientPlaybackPreferences = const {},
this.onClientPlaybackPreferenceChanged,
this.onTs3ServerLink,
});
@@ -58,6 +69,12 @@ class SnapshotView extends StatefulWidget {
/// Join a password-protected channel.
final ValueChanged<rust.BridgeChannel> onJoinChannelWithPassword;
/// Persisted per-client playback preferences keyed by TeamSpeak UID.
final Map<String, ClientPlaybackPreference> clientPlaybackPreferences;
/// Apply an updated per-client playback preference.
final ClientPlaybackPreferenceChanged? onClientPlaybackPreferenceChanged;
/// Handle TeamSpeak server links embedded in server-provided text.
final Ts3ServerLinkHandler? onTs3ServerLink;
@@ -293,21 +310,41 @@ class _SnapshotViewState extends State<SnapshotView> {
],
)
: null;
final canAdjustPlayback = _canAdjustClientPlayback(client);
return Padding(
padding: EdgeInsets.only(
left: channelIndent + _userRowStartIndent,
right: 8,
),
child: AnimatedContainer(
duration: const Duration(milliseconds: 120),
curve: Curves.easeOut,
decoration: decoration,
child: ListTile(
dense: true,
visualDensity: VisualDensity.compact,
leading: status.icon,
title: Text(client.name, style: nameStyle),
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onLongPress: canAdjustPlayback
? () =>
unawaited(_showClientPlaybackSheet(client, withHaptic: true))
: null,
onSecondaryTap: canAdjustPlayback
? () =>
unawaited(_showClientPlaybackSheet(client, withHaptic: true))
: null,
child: AnimatedContainer(
duration: const Duration(milliseconds: 120),
curve: Curves.easeOut,
decoration: decoration,
child: ListTile(
dense: true,
visualDensity: VisualDensity.compact,
leading: status.icon,
title: Text(client.name, style: nameStyle),
trailing: canAdjustPlayback
? IconButton(
icon: const Icon(Icons.more_horiz),
tooltip: 'Playback options',
onPressed: () =>
unawaited(_showClientPlaybackSheet(client)),
)
: null,
),
),
),
);
@@ -352,6 +389,100 @@ class _SnapshotViewState extends State<SnapshotView> {
});
}
bool _canAdjustClientPlayback(rust.BridgeClient client) {
return widget.onClientPlaybackPreferenceChanged != null &&
client.id != widget.snapshot.ownClientId &&
!client.isServerQuery &&
client.uid.isNotEmpty;
}
ClientPlaybackPreference _clientPlaybackPreference(rust.BridgeClient client) {
return widget.clientPlaybackPreferences[client.uid] ??
const ClientPlaybackPreference();
}
Future<void> _showClientPlaybackSheet(
rust.BridgeClient client, {
bool withHaptic = false,
}) async {
if (!_canAdjustClientPlayback(client)) return;
final onChanged = widget.onClientPlaybackPreferenceChanged;
if (onChanged == null) return;
if (withHaptic) {
unawaited(HapticFeedback.selectionClick().catchError((_) {}));
}
if (!mounted) return;
var preference = _clientPlaybackPreference(client);
await showModalBottomSheet<void>(
context: context,
showDragHandle: true,
builder: (context) {
return StatefulBuilder(
builder: (context, setModalState) {
void updatePreference(ClientPlaybackPreference next) {
setModalState(() => preference = next);
unawaited(onChanged(client, next));
}
final volumePercent = (preference.volume * 100).round();
return SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
client.name,
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 4),
Text(
'Per-user playback',
style: Theme.of(context).textTheme.bodySmall,
),
const SizedBox(height: 12),
SwitchListTile.adaptive(
key: const Key('client-playback-mute-tile'),
contentPadding: EdgeInsets.zero,
title: const Text('Mute playback'),
subtitle: Text(
preference.muted
? 'Audio from this user is muted on this server.'
: 'Audio from this user plays at $volumePercent%.',
),
value: preference.muted,
onChanged: (muted) =>
updatePreference(preference.copyWith(muted: muted)),
),
const SizedBox(height: 8),
Text(
'Volume $volumePercent%',
style: Theme.of(context).textTheme.labelLarge,
),
Slider(
key: const Key('client-playback-volume-slider'),
min: 0.0,
max: 4.0,
divisions: 40,
label: '$volumePercent%',
value: preference.volume,
onChanged: (value) =>
updatePreference(preference.copyWith(volume: value)),
),
],
),
),
);
},
);
},
);
}
({Widget icon, bool isSpeaking}) _clientVoiceStatusIcon(
ThemeData theme,
rust.BridgeClient client,