-
Notifications
You must be signed in to change notification settings - Fork 65
Expand file tree
/
Copy pathMicrophoneSource.cs
More file actions
376 lines (320 loc) · 15.8 KB
/
Copy pathMicrophoneSource.cs
File metadata and controls
376 lines (320 loc) · 15.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
using System;
using System.Collections;
using UnityEngine;
using LiveKit.Internal;
namespace LiveKit
{
/// <summary>
/// An audio source which captures from the device's microphone.
/// </summary>
/// <remarks>
/// Ensure microphone permissions are granted before calling <see cref="Start"/>.
/// </remarks>
sealed public class MicrophoneSource : RtcAudioSource
{
private readonly GameObject _sourceObject;
// The device requested by the caller. Empty/null means "follow the OS default".
private readonly string _deviceName;
// The device the microphone is actually recording from right now. This can differ from
// _deviceName when the preferred device is unavailable and we fall back to the OS default,
// so all Microphone.* calls (IsRecording/GetPosition/End) must use this name.
private string _activeDeviceName;
public override event Action<float[], int, int> AudioRead;
private bool _disposed = false;
private bool _started = false;
private bool _restarting = false;
// Diagnostics: counts AudioProbe buffers delivered, so a read-only health monitor can tell
// whether capture has stalled (e.g. after a Bluetooth route change) without restarting.
private volatile int _audioReadFrames = 0;
private bool _monitoring = false;
/// <summary>
/// Creates a new microphone source for the given device.
/// </summary>
/// <param name="deviceName">The name of the device to capture from. Use <see cref="Microphone.devices"/> to
/// get the list of available devices.</param>
/// <param name="sourceObject">The GameObject to attach the AudioSource to. The object must be kept in the scene
/// for the duration of the source's lifetime.</param>
public MicrophoneSource(string deviceName, GameObject sourceObject) : base(RtcAudioSourceType.AudioSourceMicrophone)
{
_deviceName = deviceName;
_sourceObject = sourceObject;
}
/// <summary>
/// Begins capturing audio from the microphone.
/// </summary>
/// <exception cref="InvalidOperationException">
/// Thrown when the microphone is not available or unauthorized.
/// </exception>
/// <remarks>
/// Ensure microphone permissions are granted before calling this method
/// by calling <see cref="Application.RequestUserAuthorization"/>.
/// </remarks>
public override void Start()
{
base.Start();
if (_started) return;
if (!Application.HasUserAuthorization(mode: UserAuthorization.Microphone))
throw new InvalidOperationException("Microphone access not authorized");
MonoBehaviourContext.OnApplicationPauseEvent += OnApplicationPause;
// Restart capture when the system audio device changes (e.g. a Bluetooth headset is
// unplugged). Unity rebuilds its audio graph on a device change, which both detaches
// the AudioProbe tap and leaves Microphone.Start bound to a now-gone device.
AudioSettings.OnAudioConfigurationChanged += OnAudioConfigurationChanged;
MonoBehaviourContext.RunCoroutine(StartMicrophone());
_started = true;
// DIAGNOSTIC (read-only): periodically log capture health so logcat shows whether
// buffers keep flowing and whether the config-changed event fires on a device change.
if (!_monitoring)
{
_monitoring = true;
MonoBehaviourContext.RunCoroutine(MonitorCaptureHealth());
}
}
private IEnumerator StartMicrophone()
{
// Validate that the GameObject is still valid before starting
if (_sourceObject == null)
{
Utils.Error("MicrophoneSource: GameObject is null, cannot start microphone");
yield break;
}
// Verify microphone is still authorized (could change during background)
if (!Application.HasUserAuthorization(UserAuthorization.Microphone))
{
Utils.Error("MicrophoneSource: Microphone authorization lost");
yield break;
}
// Resolve which device to record from. Falls back to the OS default when the
// preferred device is gone, so an unplugged headset transparently hands off to the
// built-in microphone.
_activeDeviceName = ResolveCaptureDevice();
AudioClip clip = null;
try
{
clip = Microphone.Start(
_activeDeviceName,
loop: true,
lengthSec: 1,
frequency: (int)_expectedSampleRate
);
}
catch (Exception e)
{
Utils.Error($"MicrophoneSource: Exception starting microphone: {e.Message}");
yield break;
}
if (clip == null)
{
Utils.Error("MicrophoneSource: Microphone.Start returned null, audio session may not be ready");
yield break;
}
// Ensure no duplicate components exist before adding new ones.
// This is important during app resume on iOS where components might not be
// fully destroyed yet due to Unity's deferred Destroy().
var existingSource = _sourceObject.GetComponent<AudioSource>();
if (existingSource != null)
UnityEngine.Object.DestroyImmediate(existingSource);
var existingProbe = _sourceObject.GetComponent<AudioProbe>();
if (existingProbe != null)
{
existingProbe.AudioRead -= OnAudioRead;
UnityEngine.Object.DestroyImmediate(existingProbe);
}
var source = _sourceObject.AddComponent<AudioSource>();
source.clip = clip;
source.loop = true;
var probe = _sourceObject.AddComponent<AudioProbe>();
// Clear the audio data after it is read as to not play it through the speaker locally.
probe.ClearAfterInvocation();
probe.AudioRead += OnAudioRead;
// Wait for microphone to actually start producing data with a timeout
const float timeout = 2f;
float elapsed = 0f;
while (Microphone.GetPosition(_activeDeviceName) <= 0 && elapsed < timeout)
{
yield return new WaitForSeconds(0.05f);
elapsed += 0.05f;
}
if (Microphone.GetPosition(_activeDeviceName) <= 0)
{
Utils.Error($"MicrophoneSource: Microphone did not start producing data after {timeout}s");
yield break;
}
source.Play();
Utils.Info($"MicrophoneSource device='{_activeDeviceName ?? "<default>"}' started successfully");
}
/// <summary>
/// Stops capturing audio from the microphone.
/// </summary>
public override void Stop()
{
base.Stop();
MonoBehaviourContext.RunCoroutine(StopMicrophone());
MonoBehaviourContext.OnApplicationPauseEvent -= OnApplicationPause;
AudioSettings.OnAudioConfigurationChanged -= OnAudioConfigurationChanged;
_started = false;
}
private IEnumerator StopMicrophone()
{
if (Microphone.IsRecording(_activeDeviceName))
Microphone.End(_activeDeviceName);
// Check if GameObject is still valid before trying to access components
if (_sourceObject != null)
{
var probe = _sourceObject.GetComponent<AudioProbe>();
if (probe != null)
{
probe.AudioRead -= OnAudioRead;
UnityEngine.Object.Destroy(probe);
}
var source = _sourceObject.GetComponent<AudioSource>();
if (source != null)
UnityEngine.Object.Destroy(source);
}
Utils.Info($"MicrophoneSource device='{_activeDeviceName ?? "<default>"}' stopped");
yield return null;
}
private void OnAudioRead(float[] data, int channels, int sampleRate)
{
_audioReadFrames++;
AudioRead?.Invoke(data, channels, sampleRate);
}
private void OnApplicationPause(bool pause)
{
if (!_started)
return;
if (pause)
{
// On iOS, when app goes to background, we should stop using audio resources
// to avoid AVAudioSession interruption errors (FigCaptureSourceRemote -17281)
MonoBehaviourContext.RunCoroutine(StopMicrophone());
}
else
{
// When resuming, restart the microphone
MonoBehaviourContext.RunCoroutine(RestartMicrophone());
}
}
// Picks the device name to pass to Microphone.Start. An empty preferred name, or a
// preferred device that is no longer connected, resolves to null so Unity records from
// the current OS default device.
private string ResolveCaptureDevice()
{
if (string.IsNullOrEmpty(_deviceName))
return null;
if (Array.IndexOf(Microphone.devices, _deviceName) >= 0)
return _deviceName;
Utils.Debug($"MicrophoneSource: preferred device '{_deviceName}' is no longer available, falling back to the OS default");
return null;
}
// Fires on the main thread when Unity's audio configuration changes, including when the
// system audio device changes (e.g. connecting/disconnecting a Bluetooth headset). Mirrors
// AudioStream.OnAudioConfigurationChanged on the playback side.
private void OnAudioConfigurationChanged(bool deviceWasChanged)
{
// DIAGNOSTIC: confirms whether this event fires at all on a device change (the open
// question on Android, where a Bluetooth route change may not change the DSP config).
Utils.Info($"MicrophoneSource: OnAudioConfigurationChanged deviceWasChanged={deviceWasChanged} outputSampleRate={AudioSettings.outputSampleRate} started={_started}");
if (!_started)
return;
// The native source's rate is fixed at construction and RtcAudioSource drops frames
// whose rate doesn't match it. If the device change moved Unity's DSP output rate,
// restarting capture alone won't recover audio — warn so the silence is diagnosable.
// Full recovery (recreating the native source at the new rate) is handled separately.
var outputSampleRate = (uint)AudioSettings.outputSampleRate;
if (outputSampleRate != _expectedSampleRate)
{
Utils.Warning($"MicrophoneSource: audio device change moved the DSP output rate to {outputSampleRate}Hz, but the native source is fixed at {_expectedSampleRate}Hz. Captured frames will be dropped until the track is recreated at the new rate.");
}
// Unity rebuilds its audio graph on any configuration change — including an output
// route change (e.g. a Bluetooth headset disconnecting) where the input device itself
// doesn't change. On mobile the input is always the built-in mic regardless of the
// headset, so deviceWasChanged is false there even though the rebuild detaches the
// AudioProbe tap and stops capture. Always restart so the tap is re-registered;
// AudioStream does the same on the playback side and never gates on deviceWasChanged.
Utils.Debug("MicrophoneSource: audio configuration changed, restarting capture");
MonoBehaviourContext.RunCoroutine(RestartMicrophone());
}
private IEnumerator RestartMicrophone()
{
// The device-change event can fire several times around a single hardware swap;
// ignore re-entrant restarts so overlapping Stop/Start coroutines don't race.
if (_restarting)
{
Utils.Info("MicrophoneSource: restart requested but one is already in progress, ignoring");
yield break;
}
_restarting = true;
Utils.Info("MicrophoneSource: restart begin");
yield return StopMicrophone();
// Wait for iOS audio session to be ready before attempting to restart.
// On iOS, after app resumes from background, the audio session needs time to
// recover from interruption. Poll for readiness instead of using arbitrary delay.
yield return WaitForMicrophoneReady();
yield return StartMicrophone();
_restarting = false;
Utils.Info("MicrophoneSource: restart end");
}
// DIAGNOSTIC (read-only — never restarts): logs capture health every couple of seconds so
// logcat shows whether AudioProbe buffers keep flowing after a device change and whether
// Microphone still reports recording/advancing. Runs for the lifetime of the source.
private IEnumerator MonitorCaptureHealth()
{
int lastFrames = _audioReadFrames;
int lastPosition = -1;
while (_started && !_disposed)
{
yield return new WaitForSeconds(2f);
int frames = _audioReadFrames;
int delta = frames - lastFrames;
lastFrames = frames;
bool recording = false;
int position = -1;
try
{
recording = Microphone.IsRecording(_activeDeviceName);
position = Microphone.GetPosition(_activeDeviceName);
}
catch (Exception e)
{
Utils.Warning($"MicrophoneSource: health probe threw {e.Message}");
}
Utils.Info($"MicrophoneSource: health framesLast2s={delta} totalFrames={frames} isRecording={recording} position={position} prevPosition={lastPosition} device='{_activeDeviceName ?? "<default>"}' muted={Muted} restarting={_restarting}");
lastPosition = position;
}
_monitoring = false;
Utils.Info("MicrophoneSource: health monitor stopped");
}
private IEnumerator WaitForMicrophoneReady()
{
// Wait for microphone devices to become available again after iOS audio session interruption.
// This is more reliable than a fixed delay because we wait for actual system readiness.
const float timeout = 2f;
float elapsed = 0f;
// On iOS, Microphone.devices may be empty immediately after resume while
// AVAudioSession is recovering from interruption. Wait until devices are available.
while (Microphone.devices.Length == 0 && elapsed < timeout)
{
yield return new WaitForSeconds(0.05f);
elapsed += 0.05f;
}
if (Microphone.devices.Length == 0)
{
Utils.Error($"MicrophoneSource: Microphone devices not available after {timeout}s timeout");
yield break;
}
// Extra frame to ensure audio session is fully ready
yield return null;
}
protected override void Dispose(bool disposing)
{
if (!_disposed && disposing) Stop();
_disposed = true;
base.Dispose(disposing);
}
~MicrophoneSource()
{
Dispose(false);
}
}
}