Repository navigation
Expand file tree
/
Copy pathAudioDeviceHub.h
More file actions
452 lines (410 loc) · 21.1 KB
/
Copy pathAudioDeviceHub.h
File metadata and controls
452 lines (410 loc) · 21.1 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
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
// Super Timecode Converter
// Copyright (c) 2026 Fiverecords -- MIT License
// https://github.com/fiverecords/SuperTimecodeConverter
#pragma once
#include <JuceHeader.h>
#include <memory>
#include <vector>
//==============================================================================
// AudioDeviceHub -- one open audio device per physical interface, shared by
// every component that uses it.
//
// Until 2026-09 each audio component (LTC in, LTC out, Audio Thru, Audio BPM,
// the generator player, per engine) owned its own juce::AudioDeviceManager and
// opened the interface for itself. Two consequences: an ASIO driver, which
// has one sample rate and one buffer for the whole interface, was opened
// several times by clients that each believed their own settings; and
// whether that worked at all depended on the driver tolerating several
// clients, which many do not.
//
// The hub opens each interface once. A component acquires the device for
// the direction it needs (input or output), gets registered on a fan-out
// callback, and releases it when it stops; the device closes when the last
// client leaves. Sample rate and buffer size are properties of the device
// (DESIGN D23): the request that opens it sets them; a client that joins an
// open device adapts to what is running, whatever it asked for; the global
// setting reaches open devices through reconfigureAll(), and adding a
// direction (ASIO) reopens the device -- both seen by every client as
// audioDeviceStopped / audioDeviceAboutToStart, like any device restart.
// The actual values the driver settled on are what every client is told.
//
// Identity: (device type, device name). For ASIO the same name is the
// interface's input and its output, so a device opened for one direction is
// upgraded in place when the other is requested. For the other APIs input
// and output devices have different names and stay separate, which is fine:
// those drivers are multi-client and have no per-interface rate to disagree
// about.
//
// Fan-out: the hub registers ONE callback on each manager and calls the
// clients itself. Input-only clients are called with no output channels;
// output clients each write into a scratch buffer that is summed into the
// device buffers, so several outputs (an LTC per engine on different channels
// of one interface, the generator's music on others) coexist by construction
// and a client that writes only its own channel cannot leak into another.
//
// The client list is protected by a CriticalSection, the way
// juce::AudioDeviceManager protects its own callback list -- which means it
// is NOT held briefly on the audio side: the audio callback holds it for the
// whole fan-out, every client's callback in turn. The message thread takes
// it when a client is added, removed or looked up (acquire, release,
// reconfigureAll) and while it announces a start or stop to the clients.
// The trade-off, kept until the list is made lock-free (AUDIT LTC-15,
// deferred): a message-thread call can wait for one fan-out; an audio
// callback that arrives while the message thread holds the lock waits for
// it, against CONTRIBUTING's "no locks in the audio callback" -- a priority
// inversion, short because the message thread does little under the lock,
// but not bounded; and the clients of one device share its period, so one
// slow client (the generator reading from disk, AUDIT LTC-3) delays every
// other client on that device.
//
// Message thread only for acquire/release; the callbacks run on the audio
// thread.
//==============================================================================
class AudioDeviceHub
{
public:
static AudioDeviceHub& get()
{
static AudioDeviceHub hub;
return hub;
}
/// Open (or share) `deviceName` of `typeName` for `client` in one
/// direction. Returns the live device on success (valid until the next
/// reconfiguration: read what you need in audioDeviceAboutToStart, which
/// the client is given before this returns), or nullptr with `error`
/// set. A client already registered is moved.
/// `followsGlobalFormat`: whether this client's device should take part
/// in reconfigureAll() (the global SAMPLE RATE / BUFFER SIZE setting); a
/// device follows it if any of its clients does. Every client takes the
/// default (true) since the generator lost its own format combos
/// (DESIGN D23, 2026-09-13); no client passes false today.
juce::AudioIODevice* acquire(juce::AudioIODeviceCallback* client,
const juce::String& typeName, const juce::String& deviceName,
bool asInput, double sampleRate, int bufferSize,
juce::String& error, bool followsGlobalFormat = true)
{
JUCE_ASSERT_MESSAGE_THREAD
error.clear();
if (client == nullptr || deviceName.isEmpty()) { error = "no device"; return nullptr; }
release(client);
Device* dev = findDevice(typeName, deviceName, asInput);
if (dev == nullptr)
dev = findDevice(typeName, deviceName, !asInput); // same name in the other direction: upgrade
if (dev == nullptr)
{
devices.push_back(std::make_unique<Device>());
dev = devices.back().get();
dev->typeName = typeName;
}
const bool needsInput = asInput || dev->inputName.isNotEmpty();
const bool needsOutput = !asInput || dev->outputName.isNotEmpty();
const juce::String wantIn = needsInput ? deviceName : juce::String();
const juce::String wantOut = needsOutput ? deviceName : juce::String();
// Rate and buffer belong to the device. The client that opens it
// sets them; a client joining an open device adapts to what is
// running (its audioDeviceAboutToStart tells it), even if it asked
// for something else -- otherwise two components with different
// preferences on one interface would restart it at each other on
// every start. A global change goes through reconfigureAll().
// Adding a direction (ASIO input to an output-only device) does
// reconfigure, as it must.
const bool alone = dev->fanout.empty();
const double sr = alone ? sampleRate : dev->requestedSampleRate;
const int bs = alone ? bufferSize : dev->requestedBufferSize;
const bool wantsReconfigure = ! dev->open
|| dev->inputName != wantIn || dev->outputName != wantOut
|| (alone && (dev->requestedSampleRate != sr || dev->requestedBufferSize != bs));
if (wantsReconfigure)
{
// Remember what the other clients had, so a request the driver
// refuses (a rate it cannot do, a direction it cannot add) costs
// only the requester: the device goes back to its previous
// configuration for everyone else.
const bool wasOpen = dev->open;
const juce::String prevIn = dev->inputName, prevOut = dev->outputName;
const double prevSr = dev->requestedSampleRate;
const int prevBs = dev->requestedBufferSize;
if (! open(*dev, wantIn, wantOut, sr, bs, error))
{
if (wasOpen && ! dev->fanout.empty())
{
juce::String restoreError;
open(*dev, prevIn, prevOut, prevSr, prevBs, restoreError);
}
if (dev->fanout.empty())
closeAndForget(dev);
return nullptr;
}
}
// Register on the running device: announced (audioDeviceAboutToStart)
// before it can receive a callback, as JUCE's addAudioCallback does.
// Registering after the open also means a re-opened device announces
// itself to the existing clients only, and this one never sees a
// stop it was not started for.
dev->fanout.add(client, asInput, !asInput, followsGlobalFormat, dev->manager->getCurrentAudioDevice());
return dev->manager->getCurrentAudioDevice();
}
/// Apply a new preferred rate and buffer to every open device, once
/// each (every client sees a stop/start). This is how the global
/// SAMPLE RATE / BUFFER SIZE setting reaches shared devices; a device
/// that cannot do the request keeps whatever the driver settled on,
/// which the status lines show.
void reconfigureAll(double sampleRate, int bufferSize)
{
JUCE_ASSERT_MESSAGE_THREAD
for (auto& d : devices)
{
if (! d->open || ! d->fanout.anyFollowsGlobalFormat()) continue;
if (d->requestedSampleRate == sampleRate && d->requestedBufferSize == bufferSize) continue;
juce::String err;
if (! open(*d, d->inputName, d->outputName, sampleRate, bufferSize, err))
{
juce::String restoreError;
open(*d, d->inputName, d->outputName, d->requestedSampleRate, d->requestedBufferSize, restoreError);
}
}
}
/// Unregister `client`; the device closes when nobody is left.
void release(juce::AudioIODeviceCallback* client)
{
JUCE_ASSERT_MESSAGE_THREAD
for (auto& d : devices)
if (d->fanout.contains(client))
{
d->fanout.remove(client, d->open ? d->manager->getCurrentAudioDevice() : nullptr);
if (d->fanout.empty())
closeAndForget(d.get());
return;
}
}
/// Actual values the driver settled on for the device `client` is on
/// (0 when the client is not registered or the device is not open).
double getActualSampleRate(juce::AudioIODeviceCallback* client) const
{
for (auto& d : devices)
if (d->fanout.contains(client) && d->open)
if (auto* dev = d->manager->getCurrentAudioDevice()) return dev->getCurrentSampleRate();
return 0.0;
}
int getActualBufferSize(juce::AudioIODeviceCallback* client) const
{
for (auto& d : devices)
if (d->fanout.contains(client) && d->open)
if (auto* dev = d->manager->getCurrentAudioDevice()) return dev->getCurrentBufferSizeSamples();
return 0;
}
/// Close everything and drop the managers while JUCE is still alive.
/// Call at the end of the main component's destructor, after every
/// client has stopped; the static instance then destroys nothing at exit.
static void shutdown()
{
auto& hub = get();
for (auto& d : hub.devices)
if (d->open) { d->manager->removeAudioCallback(&d->fanout); d->manager->closeAudioDevice(); d->open = false; }
hub.devices.clear();
}
private:
AudioDeviceHub() = default;
~AudioDeviceHub() = default;
//==========================================================================
// Fan-out: the one callback registered on a manager
//==========================================================================
struct Fanout : public juce::AudioIODeviceCallback
{
struct Entry { juce::AudioIODeviceCallback* cb; bool input; bool output; bool globalFormat; };
void add(juce::AudioIODeviceCallback* cb, bool input, bool output, bool globalFormat,
juce::AudioIODevice* runningDevice)
{
if (runningDevice != nullptr)
cb->audioDeviceAboutToStart(runningDevice);
const juce::ScopedLock sl(lock);
entries.push_back({ cb, input, output, globalFormat });
}
bool anyFollowsGlobalFormat() const
{
const juce::ScopedLock sl(lock);
for (auto& e : entries) if (e.globalFormat) return true;
return false;
}
void remove(juce::AudioIODeviceCallback* cb, juce::AudioIODevice* runningDevice)
{
bool found = false;
{
const juce::ScopedLock sl(lock);
for (size_t i = 0; i < entries.size(); ++i)
if (entries[i].cb == cb) { entries.erase(entries.begin() + (long) i); found = true; break; }
}
if (found && runningDevice != nullptr)
cb->audioDeviceStopped();
}
bool contains(juce::AudioIODeviceCallback* cb) const
{
const juce::ScopedLock sl(lock);
for (auto& e : entries) if (e.cb == cb) return true;
return false;
}
bool empty() const { const juce::ScopedLock sl(lock); return entries.empty(); }
void audioDeviceAboutToStart(juce::AudioIODevice* device) override
{
// Sized once, off the audio thread, and generously: four times
// the announced buffer and at least 16384 samples. A driver
// block longer than the announced buffer does not get here in
// one piece: JUCE 9's AudioDeviceManager cuts it into blocks of
// at most getCurrentBufferSizeSamples() before calling us
// (CallbackMaxSizeEnforcer, juce_AudioDeviceManager.cpp). The
// margin, and the chunks below for a call bigger than this, are
// a second guard that costs nothing.
scratch.setSize(juce::jmax(1, device->getActiveOutputChannels().countNumberOfSetBits()),
juce::jmax(16384, 4 * device->getCurrentBufferSizeSamples()), false, false, true);
const juce::ScopedLock sl(lock);
for (auto& e : entries) e.cb->audioDeviceAboutToStart(device);
}
void audioDeviceStopped() override
{
const juce::ScopedLock sl(lock);
for (auto& e : entries) e.cb->audioDeviceStopped();
}
void audioDeviceError(const juce::String& message) override
{
const juce::ScopedLock sl(lock);
for (auto& e : entries) e.cb->audioDeviceError(message);
}
void audioDeviceIOCallbackWithContext(const float* const* inputChannelData, int numInputChannels,
float* const* outputChannelData, int numOutputChannels,
int numSamples,
const juce::AudioIODeviceCallbackContext& context) override
{
for (int ch = 0; ch < numOutputChannels; ++ch)
if (outputChannelData[ch] != nullptr)
juce::FloatVectorOperations::clear(outputChannelData[ch], numSamples);
const juce::ScopedLock sl(lock);
// Output clients render into the scratch buffer, sized in
// audioDeviceAboutToStart (no allocation here). A call longer
// than the scratch -- which AudioDeviceManager, cutting driver
// blocks to the announced buffer, does not make -- is served in
// scratch-sized chunks, in order, within this callback, so every
// output client is asked for every sample the device plays.
// (Clamping to the scratch length left the rest of the buffer
// silent.) Whether it renders a call longer than the buffer the
// device announced is up to the client: LTC out and Audio Thru
// do, but the generator's player (GeneratorAudioPlayer) sizes its
// own scratch to the announced buffer and plays silence for the
// whole of any longer call. The chunks reach the client back to
// back, with the device's context for the whole buffer, so a
// client that dates its calls by the clock dates every chunk at
// the callback's start. LTC out does (measured with the real
// LtcOutput behind this fan-out): clean for any callback up to
// the scratch length, but past it the later chunks are dated a
// whole scratch early, its value tracking snaps, and frames are
// skipped or repeated every period. Hence the generous scratch.
const int scratchChans = juce::jmin(numOutputChannels, scratch.getNumChannels());
const int chunkMax = scratch.getNumSamples();
for (auto& e : entries)
{
const float* const* ins = e.input ? inputChannelData : nullptr;
const int numIns = e.input ? numInputChannels : 0;
if (! e.output)
{
e.cb->audioDeviceIOCallbackWithContext(ins, numIns, nullptr, 0, numSamples, context);
continue;
}
// An entry is input or output, never both (acquire registers
// one direction), so an output client is given no input.
for (int offset = 0; offset < numSamples && chunkMax > 0; offset += chunkMax)
{
const int len = juce::jmin(chunkMax, numSamples - offset);
scratch.clear(0, len); // only the samples in use: the buffer is deliberately oversized
// After clear(), every time: a full-length clear() marks the
// buffer clear and only this unmarks it (else the next
// clear() is skipped and old samples are summed again).
float* const* outs = scratch.getArrayOfWritePointers();
e.cb->audioDeviceIOCallbackWithContext(nullptr, 0, outs, scratchChans, len, context);
for (int ch = 0; ch < scratchChans; ++ch)
if (outputChannelData[ch] != nullptr)
juce::FloatVectorOperations::add(outputChannelData[ch] + offset, outs[ch], len);
}
}
}
juce::CriticalSection lock;
std::vector<Entry> entries;
juce::AudioBuffer<float> scratch;
};
struct Device
{
juce::String typeName, inputName, outputName;
double requestedSampleRate = 0.0;
int requestedBufferSize = 0;
bool open = false;
std::unique_ptr<juce::AudioDeviceManager> manager;
Fanout fanout;
};
std::vector<std::unique_ptr<Device>> devices;
Device* findDevice(const juce::String& typeName, const juce::String& deviceName, bool asInput) const
{
for (auto& d : devices)
if (d->typeName == typeName && (asInput ? d->inputName : d->outputName) == deviceName)
return d.get();
return nullptr;
}
bool open(Device& dev, const juce::String& inputName, const juce::String& outputName,
double sampleRate, int bufferSize, juce::String& error)
{
if (dev.open)
{
dev.manager->removeAudioCallback(&dev.fanout);
dev.manager->closeAudioDevice();
dev.open = false;
}
if (dev.manager == nullptr)
dev.manager = std::make_unique<juce::AudioDeviceManager>();
// Register every device type, switch to the requested one and scan so
// the name is recognised. With zero channels "needed" JUCE fills in
// no default device names, so nothing is opened here -- the
// components used to ask for 128 channels at this point, which made
// initialise() and the type switch each open the type's default
// device for a moment before the real one. The channels are enabled
// explicitly below instead.
dev.manager->initialise(0, 0, nullptr, false);
if (dev.typeName.isNotEmpty())
dev.manager->setCurrentAudioDeviceType(dev.typeName, false);
if (auto* type = dev.manager->getCurrentDeviceTypeObject())
type->scanForDevices();
auto setup = dev.manager->getAudioDeviceSetup();
setup.inputDeviceName = inputName;
setup.outputDeviceName = outputName;
setup.useDefaultInputChannels = false;
setup.useDefaultOutputChannels = false;
setup.inputChannels.clear();
setup.outputChannels.clear();
if (inputName.isNotEmpty()) setup.inputChannels.setRange(0, 128, true); // masked to what the device has
if (outputName.isNotEmpty()) setup.outputChannels.setRange(0, 128, true);
if (sampleRate > 0) setup.sampleRate = sampleRate;
if (bufferSize > 0) setup.bufferSize = bufferSize;
const auto err = dev.manager->setAudioDeviceSetup(setup, true);
if (err.isNotEmpty() || dev.manager->getCurrentAudioDevice() == nullptr)
{
error = err.isNotEmpty() ? err : juce::String("device did not open");
dev.manager->closeAudioDevice();
return false;
}
dev.inputName = inputName;
dev.outputName = outputName;
dev.requestedSampleRate = sampleRate;
dev.requestedBufferSize = bufferSize;
dev.open = true;
dev.manager->addAudioCallback(&dev.fanout); // announces the device to every registered client
return true;
}
void closeAndForget(Device* dev)
{
if (dev->open)
{
dev->manager->removeAudioCallback(&dev->fanout);
dev->manager->closeAudioDevice();
dev->open = false;
}
for (size_t i = 0; i < devices.size(); ++i)
if (devices[i].get() == dev) { devices.erase(devices.begin() + (long) i); return; }
}
JUCE_DECLARE_NON_COPYABLE(AudioDeviceHub)
};