wip(audio): выбор устройства ВВОДА на каждом слайсе + вкладки OUT/IN в AUD

Паритет с уже существующим выбором вывода: на каждом слайсе своё устройство
входа (микрофон/виртуальный кабель).

- RadioController: TCtrlSlice += InDeviceIndex/InDevName/AudioIn (ссылка, не
  владение); mic-пул FMicPool с refcount (Acquire/ReleaseMicInput) — один
  открытый TAudioInput на уникальное PA-устройство, смена TX-слайса не
  переоткрывает поток; ActiveMicInput/ActiveMicInDevName для PullSoundCardMic,
  Flush и DefaultMicSource; SetMainMicDevice перепривязывает ссылки слайсов.
- VfoOverlay: во fly-out AUD ряд вкладок OUT | IN, событие OnAudioInDevice.
- PanafallPanel: проброс списка входных устройств во флаги слайсов и главный.
- Settings: персист in_dev_name.

НЕ ГОТОВО: зависание GUI при выборе на слайсе того же входа, что в настройках
(пул открывает поток вместо шортката на общий FAudioIn). В коде оставлена
временная диагностика [MIC] в stderr. Также подозрение на замедление потока
данных от радио — проверяем сравнением с main.

Со мной в коммите временные тестовые программы PortAudio (other/pa*.lpr).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-21 21:52:24 +03:00
co-authored by Claude Opus 4.8
parent 317e9b732c
commit 4aa180e7b2
9 changed files with 493 additions and 49 deletions
+241 -19
View File
@@ -162,9 +162,14 @@ type
RxMuteOnTx: Boolean; // True (умолч.) = динамик слайса молчит на передаче
FMSQOn: Boolean; // FM-squelch включён (слайс в режиме FM)
FMSQLevel: Integer; // порог squelch 0..100
DeviceIndex: Integer; // PortAudio-индекс (-1 = default)
DeviceIndex: Integer; // PortAudio-индекс вывода (-1 = default)
DevName: string;
Audio: TAudioOutput; // владеет; свой поток вывода
// Вход слайса (mic/виртуальный кабель) — источник модуляции, когда именно
// этот слайс выбран TX-источником. -1/'' = брать общий вход (FAudioIn).
InDeviceIndex: Integer; // PortAudio-индекс ввода (-1 = общий)
InDevName: string;
AudioIn: TAudioInput; // НЕ владеет: ссылка на поток из mic-пула
DMR: TDMRSliceDecoder; // владеет; nil для не-DMR слайса
end;
@@ -190,6 +195,11 @@ type
procedure OnSliceDMRAudioReady(SliceId: Integer;
const PCM: array of Single; Count: Integer);
function SliceMutedByTx(Idx: Integer): Boolean; // слайс молчит на передаче?
// Пул входных потоков: захват/освобождение ссылки на устройство ввода.
function AcquireMicInput(DevIndex: Integer): TAudioInput;
procedure ReleaseMicInput(var AI: TAudioInput);
procedure SetSliceInDeviceIdx(Idx, DevIndex: Integer; const DevName: string);
procedure FreeMicPool; // финальная зачистка (FreeEngines)
procedure RefreshSliceShifts; // пересчёт ShiftHz слайсов пана 0 при смене центра
// --- Панадаптеры на аппаратных DDC (этап 3.1/3.2) ---
procedure ResetPanDDCMaps; // все пан-слоты свободны, DDC→Pan пуст
@@ -230,6 +240,18 @@ type
// TargetHz абсолютна; ShiftHz в движок = TargetHz - FCenterFreq.
FSlices: array[0..MAX_SLICES-1] of TCtrlSlice;
FNextSliceId: Integer; // монотонный генератор id слайсов
// ---- Пул входных потоков (mic) ----
// Каждому УНИКАЛЬНОМУ устройству ввода — один открытый TAudioInput на всё
// приложение; слайсы держат на него ссылку по refcount. Потоки живут, пока
// на них кто-то ссылается: переключение TX-источника = смена указателя,
// без Close/Open (иначе на старте передачи терялись бы первые сэмплы).
// Читатель у кольца всегда один — TX-источник в данный момент только один.
FMicPool: array of record
DevIdx: Integer;
Refs: Integer;
AI: TAudioInput;
end;
FSettings: TSettingsManager;
FChannelStore: TChannelStore;
FDeviceStore: TDeviceStore; // общий список устройств (saved+discovered)
@@ -642,6 +664,14 @@ type
// hardwaregain у AD936x) — входит в оба офсета выше.
function FrontEndCalDB: Double;
procedure SetSliceDevice(Id, DevIndex: Integer; const DevName: string);
// Устройство ВВОДА слайса (источник модуляции, когда слайс = TX-источник).
// DevIndex=-1 / DevName='' — вернуться на общий вход (FAudioIn).
procedure SetSliceInDevice(Id, DevIndex: Integer; const DevName: string);
// Общее устройство ввода (главный VFO = слайс A + фолбэк для слайсов).
procedure SetMainMicDevice(DevIndex: Integer; const DevName: string);
// Поток/имя устройства ввода активного TX-источника (слайс или общий).
function ActiveMicInput: TAudioInput;
function ActiveMicInDevName: string;
function SliceCount: Integer;
function GetSlice(Id: Integer; out S: TCtrlSlice): Boolean;
// Диапазон захваченной полосы пана (0 = главный), куда можно ставить слайс.
@@ -1059,8 +1089,10 @@ begin
FSlices[i].Audio.Close;
FreeAndNil(FSlices[i].Audio);
end;
FSlices[i].AudioIn := nil; // владелец — mic-пул (FreeMicPool ниже)
FSlices[i].Active := False;
end;
FreeMicPool;
if Assigned(FAudioOut) then begin FAudioOut.Close; FreeAndNil(FAudioOut); end;
if Assigned(FAudioIn) then begin FAudioIn.Close; FreeAndNil(FAudioIn); end;
if Assigned(FDSPEngine) then
@@ -1922,6 +1954,10 @@ begin
FSlices[slot].DeviceIndex := DevIndex;
FSlices[slot].DevName := DevName;
FSlices[slot].Audio := AO;
// Вход слайса по умолчанию общий (FAudioIn); свой ставится SetSliceInDevice.
FSlices[slot].InDeviceIndex := -1;
FSlices[slot].InDevName := '';
FSlices[slot].AudioIn := nil;
FSlices[slot].DMR := nil;
if Mode = MODE_DMR then
FSlices[slot].DMR := TDMRSliceDecoder.Create(Self, Id);
@@ -1944,6 +1980,9 @@ begin
FSlices[idx].Audio.Close;
FreeAndNil(FSlices[idx].Audio);
end;
ReleaseMicInput(FSlices[idx].AudioIn); // поток закроется, когда уйдёт последняя ссылка
FSlices[idx].InDeviceIndex := -1;
FSlices[idx].InDevName := '';
FSlices[idx].Active := False;
Changed(rfDevice);
end;
@@ -2150,6 +2189,182 @@ begin
end;
end;
// --- Пул входных потоков -----------------------------------------------------
procedure MicDbg(const S: string);
// ВРЕМЕННО: диагностика mic-пула в stderr (поиск причины зависания/отказа
// Pa_OpenStream при выборе входа на слайсе). Снять после разбора.
begin
WriteLn(StdErr, '[MIC] ', S);
Flush(StdErr);
end;
function TRadioController.AcquireMicInput(DevIndex: Integer): TAudioInput;
// Ссылка на открытый поток устройства DevIndex (создаёт при первом обращении).
// DevIndex<0 = «своего входа нет» → nil (читатель упадёт на общий FAudioIn).
var
i, n: Integer;
AI: TAudioInput;
begin
Result := nil;
if Assigned(FAudioIn) then
MicDbg(Format('Acquire(dev=%d): main FAudioIn dev=%d open=%s name="%s"',
[DevIndex, FAudioIn.DeviceIndex, BoolToStr(FAudioIn.IsOpen, True),
FAudioInDevName]))
else
MicDbg(Format('Acquire(dev=%d): FAudioIn=nil', [DevIndex]));
if DevIndex < 0 then Exit;
// Общий вход уже открыт на этом же устройстве — переиспользуем его: второй
// Open того же PA-устройства на эксклюзивном ALSA-hw просто не прошёл бы.
if Assigned(FAudioIn) and (FAudioIn.DeviceIndex = DevIndex) then
begin
MicDbg(' -> общий FAudioIn (без открытия)');
Exit(FAudioIn);
end;
for i := 0 to High(FMicPool) do
if FMicPool[i].DevIdx = DevIndex then
begin
MicDbg(Format(' -> пул[%d] refs=%d open=%s',
[i, FMicPool[i].Refs, BoolToStr(FMicPool[i].AI.IsOpen, True)]));
// Запись с Refs=0 — поток закрыт, но объект жив (см. ReleaseMicInput).
if (FMicPool[i].Refs <= 0) and FLocalAudio and
(not FMicPool[i].AI.IsOpen) then
try FMicPool[i].AI.Open; except end;
Inc(FMicPool[i].Refs);
Exit(FMicPool[i].AI);
end;
AI := TAudioInput.Create(48000);
AI.DeviceIndex := DevIndex;
if FLocalAudio then
begin
MicDbg(Format(' новый поток: Open(dev=%d) ...', [DevIndex]));
try
MicDbg(Format(' Open(dev=%d) = %s err="%s"',
[DevIndex, BoolToStr(AI.Open, True), AI.LastError]));
except
on E: Exception do MicDbg(' Open исключение: ' + E.Message);
end;
end;
n := Length(FMicPool);
SetLength(FMicPool, n + 1);
FMicPool[n].DevIdx := DevIndex;
FMicPool[n].Refs := 1;
FMicPool[n].AI := AI;
Result := AI;
end;
procedure TRadioController.ReleaseMicInput(var AI: TAudioInput);
// Последняя ссылка ушла — поток ЗАКРЫВАЕМ, но объект оставляем в пуле до
// выхода: TX-поток (PullSoundCardMic) мог взять указатель прямо сейчас, а
// освобождение памяти под ним = падение. Повторный выбор устройства просто
// переоткроет ту же запись.
var i: Integer;
begin
if AI = nil then Exit;
if AI = FAudioIn then begin AI := nil; Exit; end; // общий вход не из пула
for i := 0 to High(FMicPool) do
if FMicPool[i].AI = AI then
begin
Dec(FMicPool[i].Refs);
if FMicPool[i].Refs <= 0 then
begin
FMicPool[i].Refs := 0;
FMicPool[i].AI.Close;
end;
Break;
end;
AI := nil;
end;
procedure TRadioController.FreeMicPool;
var i: Integer;
begin
for i := 0 to High(FMicPool) do
if FMicPool[i].AI <> nil then
begin
FMicPool[i].AI.Close;
FMicPool[i].AI.Free;
end;
SetLength(FMicPool, 0);
end;
procedure TRadioController.SetSliceInDevice(Id, DevIndex: Integer;
const DevName: string);
begin
SetSliceInDeviceIdx(FindSliceIndex(Id), DevIndex, DevName);
end;
procedure TRadioController.SetSliceInDeviceIdx(Idx, DevIndex: Integer;
const DevName: string);
begin
if (Idx < 0) or (Idx >= MAX_SLICES) or (not FSlices[Idx].Active) then Exit;
MicDbg(Format('SetSliceInDevice: slot=%d id=%d dev=%d name="%s"',
[Idx, FSlices[Idx].Id, DevIndex, DevName]));
if DevName = '' then DevIndex := -1;
ReleaseMicInput(FSlices[Idx].AudioIn);
FSlices[Idx].InDeviceIndex := DevIndex;
FSlices[Idx].InDevName := DevName;
FSlices[Idx].AudioIn := AcquireMicInput(DevIndex);
// Слайс сейчас источник TX — источник модуляции мог сменить тип
// (звуковуха ↔ микрофон радио).
if (FTxSliceId = FSlices[Idx].Id) and FWDSPReady and Assigned(FDSPEngine) then
FDSPEngine.SetTXMicSource(DefaultMicSource);
end;
procedure TRadioController.SetMainMicDevice(DevIndex: Integer;
const DevName: string);
var i: Integer;
begin
MicDbg(Format('SetMainMicDevice: dev=%d name="%s"', [DevIndex, DevName]));
if Assigned(FAudioIn) then
begin
if FAudioIn.IsOpen then FAudioIn.Close;
FAudioIn.DeviceIndex := DevIndex;
if (DevIndex >= 0) and FLocalAudio then
try
MicDbg(Format(' main Open(dev=%d) = %s err="%s"',
[DevIndex, BoolToStr(FAudioIn.Open, True), FAudioIn.LastError]));
except
on E: Exception do MicDbg(' main Open исключение: ' + E.Message);
end;
end;
FAudioInDevName := DevName;
// Слайсы могли ссылаться на общий вход (или держать свой поток на устройстве,
// которое теперь стало общим) — перепривязать все ссылки к новой картине.
for i := 0 to MAX_SLICES - 1 do
if FSlices[i].Active then
begin
ReleaseMicInput(FSlices[i].AudioIn);
FSlices[i].AudioIn := AcquireMicInput(FSlices[i].InDeviceIndex);
end;
if FWDSPReady and Assigned(FDSPEngine) then
FDSPEngine.SetTXMicSource(DefaultMicSource);
end;
function TRadioController.ActiveMicInput: TAudioInput;
// Вход TX-источника: свой поток слайса, иначе общий (слайс без выбора).
var idx: Integer;
begin
Result := FAudioIn;
if FTxSliceId = 0 then Exit;
idx := FindSliceIndex(FTxSliceId);
// Поток слайса мог не открыться (устройство занято/исчезло) — тогда лучше
// передавать с общего входа, чем молчать в эфир.
if (idx >= 0) and (FSlices[idx].AudioIn <> nil) and
FSlices[idx].AudioIn.IsOpen then
Result := FSlices[idx].AudioIn;
end;
function TRadioController.ActiveMicInDevName: string;
var idx: Integer;
begin
Result := FAudioInDevName;
if FTxSliceId = 0 then Exit;
idx := FindSliceIndex(FTxSliceId);
if (idx >= 0) and (FSlices[idx].InDevName <> '') then
Result := FSlices[idx].InDevName;
end;
function TRadioController.SliceCount: Integer;
var i: Integer;
begin
@@ -2227,15 +2442,17 @@ function TRadioController.PullSoundCardMic(MaxN: Integer): Integer;
// пачкой в локальный буфер — экономим будилки семафора.
var
i, n: Integer;
AI: TAudioInput;
Buf: array[0..2047] of Double;
begin
Result := 0;
if (FAudioIn = nil) or (not FAudioIn.IsOpen) then Exit;
n := FAudioIn.Available;
AI := ActiveMicInput; // вход TX-слайса (свой) либо общий
if (AI = nil) or (not AI.IsOpen) then Exit;
n := AI.Available;
if n > MaxN then n := MaxN;
if n > Length(Buf) then n := Length(Buf);
for i := 0 to n - 1 do
Buf[i] := FAudioIn.ReadSample;
Buf[i] := AI.ReadSample;
if n > 0 then
FDSPEngine.PushTXMicSamplesD(Buf, n);
Result := n;
@@ -2365,7 +2582,15 @@ begin
FTxSliceId := SliceId;
// Передающий тракт под режим нового источника (idle-канал, применится к PTT).
if FWDSPReady and Assigned(FDSPEngine) then
begin
FDSPEngine.SetTXMode(ActiveTXMode);
// Источник модуляции — вход НОВОГО слайса (у него может быть свой).
FDSPEngine.SetTXMicSource(DefaultMicSource);
end;
// Кольцо нового входа копило звук всё время, пока он не был источником:
// без сброса первые ~200мс передачи ушли бы в эфир «из прошлого».
if Assigned(ActiveMicInput) and ActiveMicInput.IsOpen then
ActiveMicInput.Flush;
FDriveLevel := CalcDriveByte; // калибровка драйва банда TX-слайса
ApplyAntPorts; // AD936x: TX-разъём — по диапазону TX-слайса
PushNetworkState; // TX-частота/Alex через ActiveTXFreqHz/TxBandIdx
@@ -3416,7 +3641,8 @@ begin
begin
FWebMicActive := False;
FDSPEngine.SetTXMicSource(DefaultMicSource);
if Assigned(FAudioIn) and FAudioIn.IsOpen then FAudioIn.Flush;
if Assigned(ActiveMicInput) and ActiveMicInput.IsOpen then
ActiveMicInput.Flush;
end;
// Если TUN активен — пересчитываем знак частоты под новый режим
if FTuning then FDSPEngine.SetTXTone(True, FTXSettings.TUNFreq, TUN_TONE_MAG);
@@ -4230,9 +4456,11 @@ begin
end;
function TRadioController.DefaultMicSource: TTXMicSource;
// Источник модуляции решает ТЕКУЩИЙ TX-слайс: у него может быть свой вход
// (виртуальный кабель), даже когда общего входа нет, и наоборот.
begin
if FAudioInDevName <> '' then Result := txmsSoundCard
else Result := txmsRadio;
if ActiveMicInDevName <> '' then Result := txmsSoundCard
else Result := txmsRadio;
end;
function TRadioController.BuildMicLineSelectByte: Byte;
@@ -4416,9 +4644,9 @@ begin
// звук бёрстом уходит в тракт: стоячая задержка ~190мс в DUC-очереди
// + в эфир передаётся аудио, записанное ДО нажатия PTT (на TUN оно
// складывается с тоном поверх полной шкалы → клип-всплеск).
if FTransmitting and (not WasTransmitting) and Assigned(FAudioIn)
and FAudioIn.IsOpen then
FAudioIn.Flush;
if FTransmitting and (not WasTransmitting) and Assigned(ActiveMicInput)
and ActiveMicInput.IsOpen then
ActiveMicInput.Flush;
// В non-DUP RX-IQ пакеты дропаются на входе DSP-потока во время TX, чтобы
// TX leakage не накапливался в RXA pipeline / FFT-истории анализатора.
FDSPEngine.KeepRXDuringTX := FDisplayDuplex;
@@ -5254,16 +5482,10 @@ begin
// Аудио устройство ввода (TX mic). MicSource определяется в SetMOX по PTT —
// здесь только открываем устройство.
if FLoadedGlobal.AudioInDevice <> '' then
begin
FAudioIn.DeviceIndex := FAudioIn.FindDeviceByName(FLoadedGlobal.AudioInDevice);
FAudioInDevName := FLoadedGlobal.AudioInDevice;
FAudioIn.Open;
end
SetMainMicDevice(FAudioIn.FindDeviceByName(FLoadedGlobal.AudioInDevice),
FLoadedGlobal.AudioInDevice)
else
begin
FAudioIn.DeviceIndex := -1;
FAudioInDevName := '';
end;
SetMainMicDevice(-1, '');
end;
end;