neuron-explorer
Qwen3.5‑4B 전용 조향형 뉴런 탐색기, 모든 MLP 뉴런을 밀어 결과를 보관·탐색
앱에 음성·영상 통화·오디오룸·스트리밍·AI 에이전트 추가, Flutter·Web·Android·iOS SDK 제공, 뷰티 필터 무료
Add voice calls, video calls, live audio rooms, live streaming and AI agents to your app. Flutter, Web, Android and iOS SDKs. Beauty filter included free.
개발 도구Dart · KotlinGitHub @assajeeb온라인운영자 미확인
써 보셨어요? 한 번만 눌러 주세요. 로그인은 필요 없어요.
아직 감지된 업데이트가 없습니다. GitHub 릴리스가 나오면 여기에 보입니다.
개인 계정의 공개 저장소 소유자는 GitHub로 로그인해 운영자를 확인하고 소개를 직접 관리할 수 있습니다. 관리자 승인은 필요하지 않습니다.
로그인한 뒤 확인 파일을 만들어 이 저장소의 기본 브랜치에 30분 안에 커밋해 주세요. 다른 브랜치나 포크의 파일은 인정하지 않습니다. 파일을 올린 뒤 아래에서 확인하면 관리자 승인 없이 관리할 수 있습니다. 자격을 유지하려면 파일을 남겨 두세요.
로그인이 필요하면 로그인 화면으로 이동합니다. 로그인 후 이곳으로 돌아와 원하는 확인 버튼을 다시 눌러 주세요.
프로젝트 폴더에서 /nomorevibe verify를 실행하면 소유권을 확인하고 소개를 직접 관리할 수 있습니다.
1분 만에 확인하기Real-time video and audio rooms for Flutter on Gravix Cloud, Gravity Compile's white-label RTC platform. One package, one import: the RTC client is vendored in-tree, with an app-facing room service on top.
저장소 README에서 가져옴
README 전체를 불러오는 중입니다. 아래는 저장된 발췌입니다.
Real-time video and audio rooms for Flutter on Gravix Cloud, Gravity
Compile's white-label RTC platform. One package, one import: the RTC client is
vendored in-tree, with an app-facing room service on top.
Platforms: Android and iOS. Room music is complete on Android; on iOS it
plays (mic volume and ducking are Android-only) and is not yet verified on a device.README 전문 보기 ↗Real-time video and audio rooms for Flutter on Gravix Cloud, Gravity Compile's white-label RTC platform. One package, one import: the RTC client is vendored in-tree, with an app-facing room service on top.
flutter pub add gravix_rtc
Platforms: Android and iOS. Room music is complete on Android; on iOS it plays (mic volume and ducking are Android-only) and is not yet verified on a device.
GravixRoomService — the one-stop room/call controller: connect /
disconnect / reconnect-with-token, mic & camera, remote mute, active speakers,
camera-facing sync, audio-session and interruption handling, and an automatic
data-saver for hosts on weak uplinks. Observables are ValueNotifiers.GravixTokenProvider: a ready
token, a callback, or the URL of your backend; cached (expiry-aware) and
timed out. See doc/MIGRATION_TOKEN_PROVIDER.md.gravixStartRegionMeasurement measures the regions
once at app start (the list is kept across launches in shared preferences) and
joins go straight to the fastest one; or regionProbe: true races the token's
region list at join time, with a fallback ladder and a decision cache.GravixRoomService(reconnectPolicy: …) /
RoomOptions(reconnectPolicy: …): your own back-off and give-up rule
(DefaultReconnectPolicy is the SDK table).ConnectOptions(joinRetries: …), on
GravixRoomService(connectOptions: …) or room.connect); never on a refusal
(token, room full) or after a leave. isJoining, joinRetry / onJoinRetry (RoomJoinRetryEvent) for the
UI. joinRetries: 0 is the single attempt of 0.4.12.RoomOptions(recovery: GravixRecoveryOptions(...)) /
GravixRoomService(recovery: ...): nearest-region failover, a resume that does
not wait on an idle publisher, early stall detection, a no-video backstop,
participants kept across a rejoin. room.connectedUrl / connectedRegion /
RoomConnectedUrlChangedEvent say where the session really is. See
Recovery after a network drop.lastConnectionReport, split region / first-audio
reports, and an opt-in step-by-step join timeline
(connect(joinTimeline: …), doc/JOIN_TIMELINE.md).GravixRoomService(analytics: GravixAnalytics(url: …)) or connect(analyticsUrl: …) uploads one report
per join to a Gravix analytics collector; fire-and-forget, never delays or
fails a join.GravixBeautyEffect: looks, face
reshape, makeup, stickers and 3D effects on the camera, built in. Removable
(~13 MB); third-party beauty SDKs plug into the same hook. See
Video effects.GravixJoin: warm() a room on screen (token +
the server's pre-connect), prepare() at the tap (the connect starts now),
attach() from the room screen (the same connect), switchTo() from room to
room (never two sessions), with the "app left before the room opened" and
"screen never shown" cases handled. For apps that drive Room directly. See
Fast join and doc/FAST_JOIN.md.prewarm() fetches the token, picks the region and warms DNS/TLS
and the audio session while the room list is on screen.GravixAudioRouting.v2), audio-first mode for
2G/EDGE (GravixAudioFirst), room music mixed into the mic (GravixRoomMusic,
Android and iOS; see Music mixer).GravixRoomView, GravixAudioOnlyFallback,
GravixPublishPresets.GravixCloudBackend, which posts the API
secret from the device) is not part of gravix_rtc; use
GravixTokenProvider — see doc/MIGRATION_TOKEN_PROVIDER.md.Full guides: Gravix Cloud docs · Live streaming quick start.
flutter pub add gravix_rtc.{room, identity, name, can_publish} as JSON to your token
endpoint, which answers {token, url}.GravixTokenProvider.endpoint +
GravixRoomService.connectWithTokenProvider do the request and the connect.import 'package:gravix_rtc/gravix_rtc.dart';
final room = GravixRoomService();
final tokens = GravixTokenProvider.endpoint(
Uri.parse('https://api.example.com/rtc/token'), // your server
headers: {'Authorization': 'Bearer $session'},
);
final ok = await room.connectWithTokenProvider(
tokenProvider: tokens,
request: GravixTokenRequest(
room: roomId,
identity: uid,
name: name,
canPublish: isHost,
),
publishMic: isHost,
enableVideo: isVideoRoom,
);
if (!ok) return;
room.activeSpeakers.addListener(() { /* seat glow */ });
room.onRemoteVideoTrack = (uid, track) { /* VideoTrackRenderer(track) */ };
// Leave / tear down.
await room.disconnect();
await room.dispose();
Signing tokens on the device (no token request per join). GravixTokenProvider.local
signs each token in the app with your App Certificate (the same token the npm
gravix-rtc-token package makes): no round trip to your server on any join. The
certificate is then on the device, so anyone who extracts it can sign tokens for any
user, room and grant of your app until you rotate it in the console. Deliver it only to
signed-in users from your own server, and never commit it to source control.
final tokens = GravixTokenProvider.local(
appId: config.appId,
appCertificate: config.appCertificate, // from your server, after sign-in
url: config.url, // your region signalling url
);
GravixTokenBuilder.create(appId, appCertificate, GravixTokenOptions(...)) signs one
token directly.
A token you already hold works too: room.connect(url: url, token: token), with
the url from the same {token, url} response.
Optional, once at app start: gravixStartRegionMeasurement(regionsUrl: …)
measures the regions so joins go straight to the fastest one.
For an app that drives the core Room (0.4.19, opt-in):
final join = GravixJoin(
tokenProvider: GravixTokenProvider.callback(myTokenCall, reuse: GravixTokenReuse.fastJoin),
);
final request = GravixTokenRequest(room: roomId, identity: uid);
join.warm(request, role: GravixStandbyRole.visible, rank: i); // a room on screen
join.prepare(request, createRoom: () => Room(roomOptions: myOptions)); // the tap
final room = await join.attach(request); // the room screen: the same connect
join.switchTo(nextRequest); // from room to room
await join.leave(); // the real exit
A join is abandoned (its room left) when the app goes to the background before
the room screen took it, or before that screen was ever shown, and after 20 s
without a screen. Details and the per-piece APIs: doc/FAST_JOIN.md.
final room = GravixRoomService(videoEffect: GravixBeautyEffect(licenseToken: effectsToken));
await room.setVideoEffectParams({'look': 'glow'}); // a built-in look
await room.setVideoEffectParams({'lookBytes': gfxBytes}); // a Gravix Studio .gfx
await room.setVideoEffectParams({'reshape': {'faceSlim': 0.3}}); // tone / region / reshape
The effect opens the camera itself and the room publishes its picture as the
camera track; setCameraEnabled, switchCamera and disconnect work as before.
effectsToken comes from your server (the Gravix console issues it for App IDs
with effects enabled); without a valid token the effects run with a watermark.
For the preview widget and overlays use
(room.videoEffect as GravixBeautyEffect).camera with GravixPreview. On iOS
and web the room uses its normal camera.
An app that does not use beauty effects can strip the native SDK, in
android/app/build.gradle.kts:
configurations.all {
exclude(group = "com.gravix", module = "gravix-effects")
exclude(group = "com.google.mediapipe", module = "tasks-vision")
exclude(group = "com.google.ai.edge.litert", module = "litert")
}
and in the app's R8 rules (android/app/proguard-rules.pro):
-dontwarn com.gravix.effects.**
-dontwarn com.google.mediapipe.**
Do not pass a GravixBeautyEffect then; if one is passed anyway,
canOwnCamera() finds the SDK missing and the room opens its normal camera.
GravixVideoEffect is the contract an effect package implements. The service
calls attach(target) when the camera track starts (and after a flip),
detach() when it stops, and replays the app's setVideoEffectEnabled /
setVideoEffectParams after every attach. attach must return true only when
frames really go through the effect — room.videoEffectActive reports it.
Natively, an effect registers a frame processor on flutter_webrtc's local track,
looked up by target.trackId:
FlutterWebRTCPlugin.sharedSingleton.getLocalTrack(trackId) →
LocalVideoTrack.addProcessor(ExternalVideoFrameProcessing).FlutterWebRTCPlugin.sharedSingleton.localTracks[trackId] →
-[LocalVideoTrack addProcessing:] with an ExternalVideoProcessingDelegate.Several effects: GravixRoomService(videoEffects: [a, b]) attaches them in
order. A frame processor runs on the SDK's own camera; it does not stack on
GravixBeautyEffect's camera (that picture is not a flutter_webrtc capturer
track), so with both, the processor reports itself not attached. To own the
camera like the built-in effect, implement GravixCameraEffect.
The full contract is in the GravixVideoEffect API docs. The old
beauty: / GravixBeautyFilter / DefaultGravixBeautyFilter still work and are
deprecated (removal no earlier than 2027-09).
lib/
gravix_rtc.dart # the single public import
src/
rtc_core/ # vendored RTC client (in-tree, not a pub dependency)
room/ # GravixRoomService, audio-first
connect/ # region measurement/probe, reports, timeline, analytics
backend/ # token provider
beauty/ # GravixVideoEffect hook (+ deprecated beauty filter)
audio/ music/ large_room/
android/, ios/ # native plugins (music mixer, fast-connect channel)
proto/ # .proto sources for the regenerated protobuf
doc/ # integration guides
The protobuf package is renamed to gravixcloud before regeneration; see
proto/.
The package ships a native plugin on both platforms (gravix_client,
com.gravitycompile.gravix_rtc/music). Nothing to register by hand; the sections below are
what the app has to declare.
The SDK owns the platform audio session on both platforms (flutter_webrtc's own session management is switched off when the plugin registers).
// Prefer the loudspeaker (a connected headset still wins unless force: true).
await AudioManager.instance.setSpeakerOutputPreferred(true);
await AudioManager.instance.setSpeakerOutputPreferred(false); // earpiece / receiver
// Or name the device explicitly with the v2 routing stack; returns false when
// the phone cannot do it (e.g. no earpiece, or a wired headset on iOS).
GravixAudioRouting.v2 = true; // before connect()
final ok = await GravixAudioRouting.setAudioOutput(GravixAudioOutput.earpiece);
MODE_IN_COMMUNICATION + audio focus while in a room;
routing through AudioManager.setCommunicationDevice on API 31+ and
setSpeakerphoneOn/Bluetooth SCO below. Bluetooth headsets take priority
over the speaker unless forced. The plugin manifest adds
MODIFY_AUDIO_SETTINGS; the app must declare and request
BLUETOOTH_CONNECT (API 31+) or Bluetooth headsets are not detected.AVAudioSession playAndRecord with voiceChat (receiver) or
videoChat (speaker), allowBluetooth/allowBluetoothA2DP/allowAirPlay,
applied when the WebRTC audio engine starts and released when it stops. An
explicit speaker uses overrideOutputAudioPort(.speaker) (re-applied after
route changes); an explicit earpiece switches to voiceChat and the
receiver. iOS cannot force the receiver over a wired headset, so that
request returns false.await GravixAudioDevice.setEnableSpeakerphone(true); // loudspeaker, even with a headset
await GravixAudioDevice.setEnableSpeakerphone(false); // headset if connected, else the earpiece
final info = await GravixAudioDevice.getAudioRoutes(); // info.available, info.current
await GravixAudioDevice.setAudioRoute(GravixAudioRoute.bluetooth);
await GravixAudioDevice.clearAudioRoute(); // back to automatic
GravixAudioDevice.onAudioRouteChanged.listen((r) => showRoute(r.current));
Routes: speaker, earpiece, wiredHeadset, bluetooth, usb. An explicit
route stays until it is changed or cleared, or until its device goes away (a
Bluetooth selection ends when the headset disconnects). On Android it wins
over setSpeakerOutputPreferred while it is set. The older calls above keep
working.
Android media mode. An app that initializes WebRTC with
AndroidAudioConfiguration.media (MODE_NORMAL / USAGE_MEDIA, e.g. for
recordable room audio) gets its output routed by Android itself: speaker,
wired, or Bluetooth A2DP. There the earpiece and a loudspeaker forced over a
headset are not reachable, and setAudioRoute answers false
(reason: mediaMode). getAudioRoutes still reports where media plays.
GravixAudioDevice.micStateChanges.listen((e) {
switch (e.state) {
case GravixMicState.interrupted: // a call, another app, a stopped recorder
case GravixMicState.blockedInBackground: // Android: no microphone service type
showBanner('Microphone paused (${e.reason.name})');
case GravixMicState.active:
case GravixMicState.idle:
hideBanner();
}
});
AVAudioSession interruptions.GravixAudioDevice.options = const GravixAudioDeviceOptions(recoverMicAfterInterruption: false);
the state is still reported.GravixAudioDeviceOptions(silentCaptureDetection: true) (off by default)
also reports a capture that runs but delivers digital silence for >= 3 s
(reason: silentCapture; a real microphone always has a noise floor) and
restarts it once per 30 s at most.GravixAudioDeviceOptions(micStateMonitoring: false) turns the whole
monitor off (0.4.14 behaviour: no state events, no recovery, no chatroom).AudioSessionManagementMode.externalCallSystem).In media mode a Bluetooth headset is an output only (A2DP): its microphone needs the call profile (HFP/SCO), which Android opens only in communication mode. So the phone's own microphone records, which for a phone in a pocket reads as "the microphone does not work".
GravixAudioDeviceOptions(bluetoothChatroom: true) (off by default) switches
per microphone state, as other voice-room engines do:
MODE_IN_COMMUNICATION, the headset selected as the communication device
(SCO), and WebRTC's recorder pointed at the headset microphone;Connecting or disconnecting the headset mid-room re-applies the decision.
Limits, and why: WebRTC fixes the playout audio attributes (USAGE_MEDIA) when its audio device module is created, once per process, and the SDK cannot rebuild it under a live room. While the microphone is on, the room therefore plays through the headset's call channel (SCO: mono, 8/16 kHz, "phone call" sound) in communication mode, and goes back to A2DP sound when the microphone turns off. Apps that run the communication configuration need none of this: their headset microphone already works.
Some OEM systems freeze background apps despite a foreground service. On an Infinix (Android 15) the "Hiber" freezer froze a silent background listener a few seconds after Home, released its wake lock and closed its sockets; it dropped from the room ~45 s later. Hiber leaves an app alone while it plays or records audio.
GravixAudioDevice.options = const GravixAudioDeviceOptions(
backgroundKeepAlive: GravixBackgroundKeepAlive.transsionOnly, // or .on; default .off
detectProcessFreeze: true, // default false
);
GravixAudioDevice.processFreezes.listen((e) => log('frozen ${e.frozenFor}'));
if (await GravixAudioDevice.isAggressiveFreezer()) showBackgroundSettingsHint();
isAggressiveFreezer() only detects; ask the user to allow "background
running" / auto-start for your app in the phone's own settings (Phone
Master). The SDK does not request a battery-optimization exemption.In the media configuration (AndroidAudioConfiguration.media,
bypassVoiceProcessing on the WebRTC initialize: MIC capture source, software
echo cancellation) the echo canceller removes only what WebRTC itself plays:
its reference is the room's playout. Another app's audio played on the
loudspeaker (YouTube, a game, music) reaches the microphone through the air
and is not in that reference, so the room hears it. The communication
configuration (VOICE_COMMUNICATION source, the phone's own voice processing)
is a different trade-off that the app picks at initialize.
GravixAudioDeviceOptions(otherAppAudio: ...) (Android, off by default)
watches other apps' playback while a room is connected (Android's playback
activity, API 26+, no permission and no audio content; reported after ~1 s of
continuous playback, cleared after ~2 s without):
GravixAudioDevice.options = const GravixAudioDeviceOptions(
otherAppAudio: GravixOtherAppAudioMode.detect,
);
GravixAudioDevice.otherAppAudioPlayingChanged.listen((playing) {
if (playing) showBanner('Audio from another app can be heard in the room');
});
| Mode | While another app plays and the microphone is on |
|---|---|
off (default) | nothing is watched (the earlier behaviour) |
detect | report only (otherAppAudioPlaying, otherAppAudioPlayingChanged) |
platformEffects | the phone's echo canceller + noise suppressor attached to the running capture |
voiceCommunicationCapture | the capture re-opened with the VOICE_COMMUNICATION source (+ the effects); back to the original source afterwards. Each switch restarts the recorder |
muteMicInBackground | the microphone muted in the engine while our app is in the background (the user's own mute is kept apart) |
What each one achieves depends on the phone (whether its echo canceller hears
other apps' output). A headset-routed player (API 31+) does not count, nor
does the SDK's own silent keep-alive (backgroundKeepAlive).
Measured 2026-10-10 (media profile = Pakhi's settings; other app's music on the loudspeaker; level at the far side relative to the user's own voice):
| Infinix X6728 (Android 15) | Xiaomi 2201117TG (Android 13) | |
|---|---|---|
off (media profile) | +5 to +6 dB (YouTube at max: +2.4 dB) | 0 dB |
platformEffects | removed (< -53 dB; YouTube -48 to -64 dB) | not removed (-1 to -3 dB) |
voiceCommunicationCapture | removed (< -36 dB), 0.25-0.3 s capture gap per switch | removed in 2 of 4 runs only; 0.3-2.5 s gap |
muteMicInBackground | removed, the user is muted too |
Recommended for media-profile apps: platformEffects (no capture restart,
removes the leak where the phone's echo canceller hears other apps) plus a
hint on otherAppAudioPlayingChanged(true) for phones where it cannot help.
Its cost to the user's voice while the other app plays was 1-2 dB with the
tonal test music; under real, dense music (YouTube) it was not measured and
may be larger (an echo canceller treats the music as echo). The
first 1-3 s of the other app's audio pass before detection. Browsers keep
their audio stream open while a media page is paused and for about 8 s after
the media ends, so the watcher reports them longer than they play;
muteMicInBackground therefore also mutes the user for that time.
Also measured (open): in the media profile the echo of the room's OWN playout came back nearly uncancelled on both phones (Infinix +4 to +5 dB vs the voice, no processing +9 dB; Xiaomi -2 to -9 dB, no processing -1 dB), while the communication profile cancelled it (-58 dB). Not investigated yet.
On by default (2026-10-21): RoomOptions.noiseSuppression and
GravixRoomService(noiseSuppression:) default to
GravixNoiseSuppressionOptions() (mode auto: the device decides standard or
studio, with thermal / battery / kill-switch fallbacks). To keep WebRTC's own
noise suppression (the sound before 2026-10-21), pass
noiseSuppression: GravixNoiseSuppressionOptions.off. At runtime:
GravixNoiseSuppression.instance.setMode(...).
Not offered, and why (checked 2026-10-10 against flutter_webrtc 1.6.2 / libwebrtc 150.7871):
AudioPlaybackCapture, API
29+). Needs a MediaProjection consent dialog ("start recording or
casting?") every session and a mediaProjection foreground service; it
cannot capture apps that opt out (allowAudioPlaybackCapture=false or a
capture policy) and only covers media/game/unknown usages; and the
captured mix would have to be fed to the echo canceller as a second
reference, which libwebrtc's Android audio device module does not accept
without native changes. Not acceptable as a default UX.An app that initializes WebRTC itself with AndroidAudioConfiguration.media
and bypassVoiceProcessing: true (MIC capture, so other apps can still
record) gets libwebrtc's software echo canceller (AEC3), not the phone's. Its
playout goes through Android's media "deep buffer" output (~200-300 ms
measured). On a phone whose loudspeaker reaches the microphone loudly, AEC3
cancels that path unreliably.
GravixMediaEcho.apply changes WebRTC's audio module before the first room
(after WebRTC.initialize(), before any connect). The default tuning
changes nothing and makes no platform call:
await rtc.WebRTC.initialize(options: {
'androidAudioConfiguration': rtc.AndroidAudioConfiguration.media.toMap(),
'androidUseHardwareAudioProcessing': false,
'bypassVoiceProcessing': true,
});
await GravixMediaEcho.apply(const GravixMediaEchoTuning(lowLatencyPlayout: true));
lowLatencyPlayout: WebRTC's AudioTrack in low-latency mode (a fast
output track, ~20-30 ms instead of ~200-300 ms). Still USAGE_MEDIA: media
volume, recordable room audio and media routing are unchanged.captureSource: mic / voiceCommunication for WebRTC's recorder.aec3FieldTrials: libwebrtc field trials for AEC3. They replace the whole
process-wide field-trial string; set keepWarpTrial if the app turned
WARP on.Measured on 2026-10-10 (Infinix X6728 Android 15, Xiaomi 2201117TG Android 13, media volume 10/15, run 1, run 2). The numbers are the room's own playout coming back, in dB relative to the user's voice; "<" means not detected (an upper bound set by the room's noise):
| setting | Infinix speech / music | Xiaomi speech / music | user's voice while the far end plays |
|---|---|---|---|
| no processing (reference) | 0, 0 / +4, +4 | -13, <-4 / -10, -11 | - |
| media + MIC + AEC3 (bypass) | <-10, <-16 / <-5, -42 | <-32, <-30 / -44, <-27 | removed (Infinix: fit >= 57 dB, floor-limited; 2nd run not measurable), cut 8-13 dB (Xiaomi) |
same + lowLatencyPlayout | <-35, <-31 / -46, <-29 | <-44, -48 / <-38, -51 | cut 15-24 dB (Infinix), 10-14 dB (Xiaomi) |
| media without bypass (VOICE_COMMUNICATION capture, platform AEC) | <-37, <-55 / <-30, -62 | <-9, <-16 / <-9, <-10 (noisy room) | removed (Infinix: fit 45-52 dB, floor-limited), none measurable (Xiaomi) |
| communication profile (SDK default) | -62, <-19 / -62, -63 | -45, <-28 / <-20, -47 | removed (Infinix: fit 56-60 dB, floor-limited), none measurable (Xiaomi) |
Mono MIC capture (captureSource: mic on a non-bypass module) was not
better. The voice column uses a dense far-end probe (the worst case), so
expect smaller cuts with ordinary speech. "Removed" means the voice fell
into the noise floor while the far end played: the half-duplex behaviour of
that echo canceller on that phone. Part of the low-latency gain may come from
a quieter echo path on the fast output: the raw echo level of that path was
not measured.
Gift and entrance effects (SVGA, MP4) usually play outside WebRTC, so WebRTC's echo canceller
never sees them and listeners hear them through the microphone. GravixAudioScenario.chatRoom
works like Agora's ChatRoom scenario: call mode, the platform echo canceller (which cancels
everything the phone plays), a 16 kHz voice capture and no AGC. Users can keep talking while an
effect plays; the effect is not sent.
await GravixRtcClient.initialize(audioScenario: GravixAudioScenario.chatRoom);
// or, when the app calls WebRTC.initialize itself (before anything else):
await WebRTC.initialize(options: {...yourOptions, ...gravixUseAudioScenario(GravixAudioScenario.chatRoom)});
Trade-offs (as on Agora ChatRoom): call volume, voice-band capture, other apps cannot record while the microphone runs, screen recordings do not capture the room. Latched at the first initialize; Android only.
Keep the call audible with the screen locked or the app in the background.
Runner Info.plist:
<key>UIBackgroundModes</key>
<array>
<string>audio</string>
<string>voip</string> <!-- only if you use CallKit / PushKit -->
</array>
<key>NSMicrophoneUsageDescription</key>
<string>Talk in calls</string>
<key>NSCameraUsageDescription</key>
<string>Video in calls</string>
On Android 11 and later, an app that is not on screen loses its microphone. About
5 s after the user presses Home, the capture goes silent
(App op 27 missing, silencing record) unless a foreground service of type
microphone is running. Room music goes silent with it, because the music is
mixed into the microphone. Playback for listeners needs a mediaPlayback
service.
The SDK ships that service. It is off by default. Turn it on once, before the first connect:
GravixForegroundService.defaults = const GravixForegroundServiceOptions(
enabled: true,
notificationTitle: 'Live room',
notificationText: 'Tap to return',
showLeaveAction: true, // adds a "Leave" button to the notification
);
GravixForegroundService.leaveRequests.listen((_) => leaveTheRoom());
From then on:
Room.connect (and GravixRoomService.connect) starts the service as
mediaPlayback + microphone, for every role (0.4.15; the microphone
type only when RECORD_AUDIO is granted). Android 14 refuses to add the
microphone type while the app is in the background, so a listener who left
the screen and then took a seat used to lose the microphone ~5 s later.
claimMicrophoneAtStart: false restores the old start (mediaPlayback
only, microphone added on the first microphone publish).microphone type that is missing (the permission came later) is claimed
again on every return to the foreground.microphone type records nothing by itself and shows no microphone
indicator; only an actual capture does.camera, only with includeCamera: true.You can also control it per room or by hand:
RoomOptions(foregroundService: ...) or
GravixRoomService.connect(foregroundService: ...);GravixForegroundService.start(micPublished: ...), update(...)
and stop().isRunning and activeTypes report the state. On iOS and the web every call
is a no-op (isSupported is false). On iOS, the audio background mode above
already keeps both directions alive.
Android 14 rules the SDK follows:
microphone needs RECORD_AUDIO already granted. Without it the service
runs as mediaPlayback only, so playback works and the microphone does not
in the background.
microphone
type when the app comes back.POST_NOTIFICATIONS (Android 13+) only affects visibility. Without it the
service still runs, but the notification is hidden (and so is its Leave
button). The SDK does not ask for it; request it in your app if you want the
notification shown.The notification uses channel gravix_call with low importance. Its small icon
is the app icon. To use another drawable, add this inside <application>:
<meta-data
android:name="com.gravitycompile.gravix_rtc.call_notification_icon"
android:resource="@drawable/ic_call_notification" />
The SDK's manifest declares the service, a non-exported receiver for the Leave
action, and these permissions: FOREGROUND_SERVICE_MICROPHONE,
FOREGROUND_SERVICE_MEDIA_PLAYBACK, FOREGROUND_SERVICE_CAMERA, WAKE_LOCK
and POST_NOTIFICATIONS. They do nothing while the service is disabled.
Google Play asks every app that declares foreground-service types to explain
them in the Play Console. If your app does not use the service, remove the
entries in your app's AndroidManifest.xml:
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" tools:node="remove" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" tools:node="remove" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CAMERA" tools:node="remove" />
<application>
<service android:name="com.gravitycompile.gravix_cloud.rtc.GravixCallService" tools:node="remove" />
<receiver android:name="com.gravitycompile.gravix_cloud.rtc.GravixCallLeaveReceiver" tools:node="remove" />
</application>
</manifest>
If you already run your own foreground service for calls, keep the SDK's disabled. Two call notifications would confuse users.
With CallKit the system activates the audio session. Hand activation to it and gate the audio engine on CallKit's callbacks:
// Before connecting:
await AudioManager.instance
.setAudioSessionManagementMode(AudioSessionManagementMode.externalCallSystem);
await AudioManager.instance.setEngineAvailability(AudioEngineAvailability.none);
// CXProviderDelegate.provider(_:didActivate:) -> from Dart:
await AudioManager.instance.setEngineAvailability(AudioEngineAvailability.defaultAvailability);
// provider(_:didDeactivate:):
await AudioManager.instance.setEngineAvailability(AudioEngineAvailability.none);
// CXSetMutedCallAction -> mute the published microphone:
await room.localParticipant?.setMicrophoneEnabled(!action.isMuted);
// Optional: silent mute without the system mute chime.
await AudioManager.instance.setMicrophoneMuteMode(MicrophoneMuteMode.inputMixer);
For a CallKit wake from a killed state (before Flutter runs), gate the engine
from the AppDelegate: GravixCloudPlugin.setEngineAvailability(isInputAvailable: false, isOutputAvailable: false); the plugin applies it when it registers.
await room.localParticipant?.setScreenShareEnabled(true); on both platforms.
mediaProjection, declared in the plugin manifest
with FOREGROUND_SERVICE_MEDIA_PROJECTION) and only then captures, which
Android 14+ requires. Customise the ongoing notification with
AndroidScreenCapture.notificationTitle / notificationText. If your app
already runs its own foreground service for this, set
AndroidScreenCapture.enabled = false. On Android 13+ request
POST_NOTIFICATIONS if you want the notification visible in the shade.ios/BroadcastExtension/. Runner
Info.plist needs RTCAppGroupIdentifier and RTCScreenSharingExtension;
without the latter setScreenShareEnabled(true) throws
PlatformException(broadcastExtensionNotConfigured).0.4.15 (field event 2026-10-09). Every switch's off value is the 0.4.14
behaviour; GravixRecoveryOptions.legacy turns all of them off.
final room = Room(roomOptions: const RoomOptions(recovery: GravixRecoveryOptions()));
room.events.on<RoomConnectedUrlChangedEvent>((e) => showServer(e.region ?? e.url));
// or read room.connectedUrl / room.connectedRegion
GravixRecoveryOptions field | default | what it does |
|---|---|---|
measuredRegionFailover | true | region failover dials the regions in the start-up measurement's order (near-only cap max(2 x best, best + 100 ms)); without a fresh measurement, the server's order |
retrySameUrlFirst / sameUrlRetryDialTimeout | true / 2.5 s | a failed full reconnect retries its url once with a short WebSocket open timeout before another region |
publisherRestartRetryAfter | 2.5 s (null = off) | resume: subscriber back, publisher not -> one fresh publisher ICE restart |
subscriberSufficientWhenIdle | true | resume: nothing unmuted published -> the subscriber connection is enough |
earlyStallDetection / stallNoPacketsFor / stallMinInterval | true / 2 s / 10 s | subscribed camera and audio silent -> resume at once |
noVideoWatchdog / noVideoFor / noVideoWindow / noVideoMinInterval | true / 4 s / 15 s / 60 s | after a resume, a camera decoding nothing -> full rejoin |
keepParticipantsOnRejoin | true | a full rejoin keeps the participant objects; only real leaves / joins are reported |
republishStoppedTracksMuted | true | a muted track whose capture was stopped is republished muted without a capture |
resumePrimaryDeadline | 3 s (null = off) | resume: the primary not back 3 s after the ReconnectResponse -> full rejoin now |
leaveAbortsResumeWait | true | resume: a Leave or a closed socket ends the wait for the primary at once |
networkWaitProbeInterval | 2 s (null = off) | a reconnect waiting for connectivity re-checks it and probes the server every 2 s |
failoverNeedsEvidence | true |
New: Room.connectedUrl, Room.connectedRegion, RoomConnectedUrlChangedEvent,
LocalTrackRepublishFailedEvent, ClientDisconnectReason.mediaStalled.
connect(red: GravixRedMode.on | off | auto, redLossThresholdPct: 3.0) sets RED
(RFC 2198 redundant audio) for the published microphone. With RED each packet also
carries the previous frame, so a lost packet is recovered instead of concealed:
speech stays intelligible on a lossy uplink. It roughly doubles the audio upload
(field 2026-09-30: ~125 kbps with RED vs ~50 kbps without at the 64 kbps cap), which
is wasted on a clean uplink and competes with video on a constrained one.
on (default, unchanged): RED from the first packet.off: plain Opus.auto: plain Opus until the mic's uplink loss stays at or above
redLossThresholdPct for 20 s (not while the RTT is above 1.5 s: that loss is the
link's own queue, RED would add to it); then the mic is republished with RED once
for the rest of the call (listeners see the track leave and come back).E2EE always turns RED off. Same options and policy as the JS SDK (red,
redLossThresholdPct).
Opus DTX is ON by default for the published microphone (2026-10-21): during
silence the encoder sends a comfort-noise frame every ~400 ms instead of 50 frames a
second. Measured on an Android phone (64 kbps cap): a silent room drops from
~110 kbps (RED) / ~55 kbps (plain Opus) to 2-10 kbps, a muted mic from ~7 to
~0.2 kbps, speech is unchanged; audible room noise counts as activity for Opus, so
the saving there is small (the SDK's noise suppression keeps pauses quiet). RED roughly
doubles the audio rate (see above). The music mixer turns DTX off while music plays
(Opus can treat quiet music as silence). Opt out with connect(dtx: false) (also
connectWithTokenProvider / reconnectWithToken); the choice is kept across RED
republishes.
Room music: a local file is decoded on the phone and mixed into the published microphone (Android: in WebRTC's capture callback; iOS: into the audio engine's input mixer). One audio stream, no second track, no server bot, no upload.
final music = GravixRoomMusic(room); // or roomService.roomMusic
await music.start(GravixMusicSource.file(path)); // .contentUri(uri) / .asset('assets/a.mp3')
await music.pause(); await music.resume(); await music.seek(const Duration(seconds: 30));
await music.setMusicVolume(0.6); // 0..1
await music.setMicVolume(1.0); // voice level in the mix (Android)
await music.setDucking(true); // music dips while the host talks (Android)
await music.setLoop(true);
music.state; // ValueListenable<GravixMusicState>: status, position, duration
music.completed.listen((_) => playNext());
await music.stop();
GravixMusicException with a stable code (noMicrophone, openFailed,
captureNotReady, ...); a bad file fails at start(), never silently.muted, remote participants see the mic as on while music plays. When
the music stops the mute becomes a normal one. start() while muted plays the
music with the voice still muted. GravixMusicOptions(continueWhileMicMuted: false) keeps the old behaviour (a mute silences voice and music). iOS: a mute
also silences the music.musicMaxBitrate (96 kbps) through RTP sender parameters (no republish) and
restored after. RED is left as published (switching it needs a republish, an
audible gap) and doubles the rate on the wire. A mic published with DTX on is
republished once with DTX off for the music and back after (DTX treats quiet
music as silence); nothing happens for the default DTX-off mic.state.interrupted) and resumes it
after; a user pause is never auto-resumed.stop() or dispose() releases the decoder
and the monitor. The music runs on the capture thread, so it keeps playing in
the background as long as the app keeps the room and its mic alive.GravixMusicOptions(monitor: true) (default) plays the same
samples locally on the room's stream type; the phone's echo canceller removes
it from the mic.GravixMusicController (roomService.music) is the low-level channel bridge
under it; apps should use GravixRoomMusic.MIT (LICENSE), Copyright (c) Gravity Compile. The vendored RTC client, its
protocol definitions and the ported native plugin code are third-party components
under the Apache License 2.0 (LICENSES/Apache-2.0.txt); see NOTICE.
| leave the session's url only when the region failed (steady device network, the url does not answer, another region does) |
rejoinAbsentGrace | 5 s | a kept participant missing from the rejoin's JoinResponse may reappear (relay) before it is reported gone |
boundedSignalConnect | true | (2026-10-11) each step of a signal connect -- the WebSocket dial, the validate request, the failover's region list, the reachability probes -- gives up at its timeout and closes its socket, so a blackholed path does not hold a reconnect until the OS gives up (~30-130 s) |
signalDialCap | 30 s (null = off) | (2026-10-11) a fresh join's signal dial may take up to this: a connection not up after Timeouts.connection is raced by another one (again every Timeouts.connection), the first one up carries the join; a resume keeps Timeouts.connection |
silentHostFailFast | true | (2026-10-11) after a signal dial that got no connection at all, the next fresh dial to that host is not raced to the cap (it must connect within Timeouts.connection; a connection that comes up keeps the cap) and is not followed by /rtc/validate: a dead region is left sooner |
signalUpgradeWindow / signalDialCeiling | 20 s / 45 s (null window = off) | (2026-10-11) once a raced dial's connection is up, its upgrade may take until 20 s after that, never past 45 s from the dial's start, never less than signalDialCap |
fullReconnectMediaConnect / slowPathPeerConnectWait | true / 30 s | (2026-10-11) a full reconnect waits Timeouts.mediaConnect for its peer connection; on a measurably slow signal path (setup >= slowSignalSetup 5 s or ping RTT >= slowSignalRtt 1 s) the wait goes on up to 30 s |
resumeSatisfiesPeerFailure | true | (2026-10-11) a peer connection reported Failed (the old ICE session, as the network returns) while a resume runs that then brings both connections back: no full rejoin after the resume (the far side saw a fake leave + join) |
resumePeerRestartWait | 3 s (Duration.zero = off) | (2026-10-11) with resumeSatisfiesPeerFailure: a connection still restarting when the resume succeeds gets this long to come back before the full rejoin (it failing rejoins at once) |
lastGoodRegionList | true | (2026-10-11) the failover's region list is fetched from the url the app connected to -- usually the region that just died; when that fetch fails, the list fetched at connect is used (any age) instead of ending the attempt, and a host that just failed is not asked at all. A 401/403 still ends it; no list at all: as before |
restartAbortsOnSignalLoss | true | (2026-10-11) a full reconnect whose signal socket closes fails at once instead of waiting 10 s for connections that can no longer be negotiated; such a failure during a network change does not count against the reconnect attempts |
uplinkFeedbackStallFor | 5 s (Duration.zero = off) | (2026-10-11) a published, unmuted track that keeps sending with no receiver report from the server for this long (the server's RTCP stuck on a dead path after the connection moved to ICE-TCP) -> a resume |
upstreamReconnectFixes | true | upstream 2.13.0/2.13.1 reconnect fixes (Leave{RESUME} resumes, no dropped requests, severed resume retried, negotiation errors reconnect) |
startBitrateBySetupTime | true | upstream 2.13.1 connection-level video start bitrate, scaled by setup time |