From 4aa180e7b299d080f3a38cac74d59ee7b1bc5503 Mon Sep 17 00:00:00 2001 From: Vladimir Date: Tue, 21 Jul 2026 21:52:24 +0300 Subject: [PATCH 1/5] =?UTF-8?q?wip(audio):=20=D0=B2=D1=8B=D0=B1=D0=BE?= =?UTF-8?q?=D1=80=20=D1=83=D1=81=D1=82=D1=80=D0=BE=D0=B9=D1=81=D1=82=D0=B2?= =?UTF-8?q?=D0=B0=20=D0=92=D0=92=D0=9E=D0=94=D0=90=20=D0=BD=D0=B0=20=D0=BA?= =?UTF-8?q?=D0=B0=D0=B6=D0=B4=D0=BE=D0=BC=20=D1=81=D0=BB=D0=B0=D0=B9=D1=81?= =?UTF-8?q?=D0=B5=20+=20=D0=B2=D0=BA=D0=BB=D0=B0=D0=B4=D0=BA=D0=B8=20OUT/I?= =?UTF-8?q?N=20=D0=B2=20AUD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Паритет с уже существующим выбором вывода: на каждом слайсе своё устройство входа (микрофон/виртуальный кабель). - 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 --- MainForm.pas | 41 ++++--- PanafallPanel.pas | 24 ++++ RadioController.pas | 260 ++++++++++++++++++++++++++++++++++++++++---- Settings.pas | 5 +- VfoOverlay.pas | 134 ++++++++++++++++++++--- ewsdr.lpi | 5 +- other/padump.lpr | 25 +++++ other/patest2.lpr | 19 ++++ other/patest3.lpr | 29 +++++ 9 files changed, 493 insertions(+), 49 deletions(-) create mode 100644 other/padump.lpr create mode 100644 other/patest2.lpr create mode 100644 other/patest3.lpr diff --git a/MainForm.pas b/MainForm.pas index 1efe48d..c07556c 100644 --- a/MainForm.pas +++ b/MainForm.pas @@ -599,6 +599,7 @@ type procedure OnVfoOverlayVolume(Vol: Integer); procedure OnVfoOverlayMute(Sender: TObject); procedure OnVfoOverlayAudioDevice(DevIndex: Integer; const DevName: string); + procedure OnVfoOverlayAudioInDevice(DevIndex: Integer; const DevName: string); procedure OnVfoOverlaySplit(Sender: TObject); procedure OnVfoOverlayTxSelect(Sender: TObject); procedure OnVfoOverlayRxMuteTx(Sender: TObject); // RXM — приём слайса на TX @@ -3838,8 +3839,10 @@ function TMainForm.DefaultMicSource: TTXMicSource; // иначе используем HW mic-стрим от трансивера. // HW PTT всегда переключает на txmsRadio непосредственно в ApplyMOX. begin - if FController.FAudioInDevName <> '' then Result := txmsSoundCard - else Result := txmsRadio; + // Учитываем вход TX-слайса: у него может быть свой (виртуальный кабель), + // даже когда общего входа нет — иначе взяли бы микрофон радио. + if FController.ActiveMicInDevName <> '' then Result := txmsSoundCard + else Result := txmsRadio; end; // ApplyTXSettingsToDSP переехал в TRadioController (вся TX-цепь над FTXSettings). @@ -5739,6 +5742,20 @@ begin FPan.PushMainFlagAudioDevices; // обновить подсветку текущего устройства end; +procedure TMainForm.OnVfoOverlayAudioInDevice(DevIndex: Integer; const DevName: string); +// Вкладка IN во флаге: откуда слайс берёт модуляцию на передаче. У флага +// главного VFO это общий вход приложения (то же поле, что в настройках). +begin + if CurSliceId <> 0 then + begin + FController.SetSliceInDevice(CurSliceId, DevIndex, DevName); + PushFlagStateAllPans(CurSliceId); + Exit; + end; + ApplyAudioInputDevice(DevIndex, DevName); + FPan.PushMainFlagAudioDevices; +end; + procedure TMainForm.OnVfoOverlayInvalidate(Sender: TObject); var P: TPanafallPanel; begin @@ -5797,6 +5814,7 @@ begin O.OnVolume := OnVfoOverlayVolume; O.OnMute := OnVfoOverlayMute; O.OnAudioDevice := OnVfoOverlayAudioDevice; + O.OnAudioInDevice := OnVfoOverlayAudioInDevice; O.OnSplit := OnVfoOverlaySplit; O.OnTxSelect := OnVfoOverlayTxSelect; O.OnRxMuteTx := OnVfoOverlayRxMuteTx; @@ -6742,6 +6760,7 @@ begin Cfg.Pans[i].Slices[n].FMSQOn := FController.FSlices[s].FMSQOn; Cfg.Pans[i].Slices[n].FMSQLevel := FController.FSlices[s].FMSQLevel; Cfg.Pans[i].Slices[n].DevName := FController.FSlices[s].DevName; + Cfg.Pans[i].Slices[n].InDevName := FController.FSlices[s].InDevName; Inc(n); end; end; @@ -6762,6 +6781,10 @@ begin TWDSPAGCMode(EnsureRange(SC.AGC, 0, Ord(High(TWDSPAGCMode)))), EnsureRange(SC.Volume, 0.0, 1.0), DevIdx, SC.DevName, PanId); if Id < 0 then Exit; + // Свой вход слайса (имя устройства → PA-индекс текущей сессии). + if SC.InDevName <> '' then + FController.SetSliceInDevice(Id, + FController.FAudioIn.FindDeviceByName(SC.InDevName), SC.InDevName); if SC.Muted then FController.SetSliceMute(Id, True); FController.SetSliceRxMuteOnTx(Id, SC.RxMuteOnTx); if SC.FMSQOn then FController.SetSliceFMSquelch(Id, True, SC.FMSQLevel); @@ -8176,18 +8199,10 @@ begin end; procedure TMainForm.ApplyAudioInputDevice(DevIndex: Integer; const DevName: string); +// Общий вход: контроллер сам переоткрывает поток и перепривязывает к нему +// слайсы, которые ссылались на общее устройство (mic-пул). begin - if FController.FAudioIn.IsOpen then - FController.FAudioIn.Close; - FController.FAudioIn.DeviceIndex := DevIndex; - FController.FAudioInDevName := DevName; - if DevIndex >= 0 then - begin - try - FController.FAudioIn.Open; - except - end; - end; + FController.SetMainMicDevice(DevIndex, DevName); end; procedure TMainForm.ApplyAudioBufferSize(BufferSize: Integer); diff --git a/PanafallPanel.pas b/PanafallPanel.pas index c2c5280..35027a7 100644 --- a/PanafallPanel.pas +++ b/PanafallPanel.pas @@ -1353,6 +1353,20 @@ begin Names.Free; end; end; + // Устройства ВВОДА + свой вход слайса ('' = общий вход приложения). + if Assigned(FController.FAudioIn) then + begin + Names := TStringList.Create; + try + Cnt := 0; + FController.FAudioIn.EnumInputDevices(Names, Indices, Cnt); + SetLength(Idx, Cnt); + for I := 0 to Cnt - 1 do Idx[I] := Indices[I]; + O.SetAudioInDevices(Names, Idx, S.InDevName); + finally + Names.Free; + end; + end; end; procedure TPanafallPanel.PushAllSliceFlagStates; @@ -1390,6 +1404,16 @@ begin SetLength(Idx, Cnt); for I := 0 to Cnt - 1 do Idx[I] := Indices[I]; FMainFlag.SetAudioDevices(Names, Idx, FController.FAudioOutDevName); + // Вход главного VFO = общее устройство ввода (то же, что в Settings). + if Assigned(FController.FAudioIn) then + begin + Cnt := 0; + Names.Clear; + FController.FAudioIn.EnumInputDevices(Names, Indices, Cnt); + SetLength(Idx, Cnt); + for I := 0 to Cnt - 1 do Idx[I] := Indices[I]; + FMainFlag.SetAudioInDevices(Names, Idx, FController.FAudioInDevName); + end; finally Names.Free; end; diff --git a/RadioController.pas b/RadioController.pas index b91d18e..debb2e3 100644 --- a/RadioController.pas +++ b/RadioController.pas @@ -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; diff --git a/Settings.pas b/Settings.pas index 8b0ae1d..6f98b36 100644 --- a/Settings.pas +++ b/Settings.pas @@ -122,7 +122,8 @@ type RxMuteOnTx: Boolean; // True = слайс молчит на передаче (self-monitor выкл) FMSQOn: Boolean; FMSQLevel: Integer; - DevName: string; // имя PortAudio-устройства ('' = default) + DevName: string; // имя PortAudio-устройства вывода ('' = default) + InDevName: string; // имя устройства ВВОДА слайса ('' = общий вход) end; TPanCfg = record @@ -1972,6 +1973,7 @@ begin SliceObj.Add('fmsq_on', P.Pans[i].Slices[j].FMSQOn); SliceObj.Add('fmsq_level', P.Pans[i].Slices[j].FMSQLevel); SliceObj.Add('dev_name', P.Pans[i].Slices[j].DevName); + SliceObj.Add('in_dev_name', P.Pans[i].Slices[j].InDevName); end; end; end; @@ -2051,6 +2053,7 @@ begin P.Pans[id].Slices[n].FMSQOn := JB(SliceObj, 'fmsq_on', False); P.Pans[id].Slices[n].FMSQLevel := JI(SliceObj, 'fmsq_level', 30); P.Pans[id].Slices[n].DevName := JS(SliceObj, 'dev_name', ''); + P.Pans[id].Slices[n].InDevName := JS(SliceObj, 'in_dev_name', ''); end; end; end; diff --git a/VfoOverlay.pas b/VfoOverlay.pas index d7f1827..2dfa2e7 100644 --- a/VfoOverlay.pas +++ b/VfoOverlay.pas @@ -94,6 +94,11 @@ type FDevIdx: array of Integer; // соответствующие PortAudio-индексы (-1=default) FCurDev: string; // имя текущего устройства ('' = default) + FInDevNames: TStringList; // имена устройств ВВОДА (микрофон/кабель) + FInDevIdx: array of Integer; // их PortAudio-индексы + FCurInDev: string; // текущий вход слайса ('' = общий вход) + FAudInTab: Boolean; // AUD-панель показывает вход (IN), а не выход + FPanel: TFlyPanel; // открытая fly-out панель FAudScroll: Integer; // прокрутка списка аудио-устройств (верхний индекс) FVolTrack: TRect; // трек громкости (локальные координаты) @@ -108,6 +113,7 @@ type FOnVolume: TVolumeEvent; FOnMute: TNotifyEvent; FOnAudioDevice: TAudioDeviceEvent; + FOnAudioInDevice: TAudioDeviceEvent; FOnSplit: TNotifyEvent; FOnTxSelect: TNotifyEvent; FOnRxMuteTx: TNotifyEvent; @@ -146,6 +152,11 @@ type procedure DrawModePanel(C: TCanvas; X, Y, RowW: Integer); procedure DrawDspPanel(C: TCanvas; X, Y, RowW: Integer); procedure DrawAudPanel(C: TCanvas; X, Y, RowW: Integer); + // Строки активной вкладки AUD (OUT — устройства вывода, IN — ввода; + // у IN первая строка псевдо: «общий вход» / «нет»). + function AudRowCount: Integer; + function AudRowName(I: Integer): string; + function AudRowCurrent(I: Integer): Boolean; procedure RegisterHit(const HR: TRect; HV: Integer); procedure ApplyThemeColors; // раскладывает FTheme → структурные CLR_* function FormatFreq(Hz: Double): string; @@ -192,6 +203,9 @@ type procedure SetRxMuteTxState(AOn: Boolean); // бейдж RXM (self-monitor на TX) procedure SetAudioDevices(Names: TStrings; const Indices: array of Integer; const CurName: string); + // Список устройств ВВОДА + текущий выбор ('' = общий вход приложения). + procedure SetAudioInDevices(Names: TStrings; const Indices: array of Integer; + const CurName: string); procedure UpdateSMeter(DB: Double); procedure UpdateVfo(AVfoHz: Double); procedure UpdateDMRStatus(const AText: string; Synced: Boolean); @@ -223,6 +237,7 @@ type property OnVolume: TVolumeEvent read FOnVolume write FOnVolume; property OnMute: TNotifyEvent read FOnMute write FOnMute; property OnAudioDevice: TAudioDeviceEvent read FOnAudioDevice write FOnAudioDevice; + property OnAudioInDevice: TAudioDeviceEvent read FOnAudioInDevice write FOnAudioInDevice; property OnSplit: TNotifyEvent read FOnSplit write FOnSplit; property OnTxSelect: TNotifyEvent read FOnTxSelect write FOnTxSelect; property OnRxMuteTx: TNotifyEvent read FOnRxMuteTx write FOnRxMuteTx; @@ -262,6 +277,8 @@ const HIT_TX = -26; HIT_AUD_UP = -27; HIT_AUD_DN = -28; + HIT_AUD_TAB_OUT = -34; // вкладка «OUT» в AUD-панели + HIT_AUD_TAB_IN = -35; // вкладка «IN» HIT_SLICE_SEL = -29; // клик по бейджу буквы (выбрать слайс активным) HIT_CLOSE = -30; // × закрытия слайса (неглавные) HIT_SQL = -31; // тумблер FM-squelch (в DSP-панели) @@ -481,6 +498,9 @@ begin FVolDrag := False; FDevNames := TStringList.Create; FCurDev := ''; + FInDevNames := TStringList.Create; + FCurInDev := ''; + FAudInTab := False; // AUD открывается на вкладке OUT FCacheBitmap := TBitmap.Create; FCacheBitmap.PixelFormat := pf32bit; FCacheDirty := True; @@ -520,6 +540,7 @@ end; destructor TVfoOverlay.Destroy; begin + FInDevNames.Free; FDevNames.Free; FCacheBitmap.Free; inherited Destroy; @@ -618,7 +639,9 @@ begin Result := ROW_H + IGAP + ROW_H; // toggles + AGC if IsFM then Inc(Result, IGAP + ROW_H); // + ряд SQL (только FM) end; - fpAud: Result := Min(Max(1, FDevNames.Count), AUD_MAX_VIS) * AUD_ROW_H; + // ряд вкладок OUT/IN + список устройств активной вкладки + fpAud: Result := ROW_H + IGAP + + Min(Max(1, AudRowCount), AUD_MAX_VIS) * AUD_ROW_H; else Result := 0; end; end; @@ -907,20 +930,63 @@ begin end; end; +function TVfoOverlay.AudRowCount: Integer; +begin + // У вкладки IN первая строка — «общий вход» (слайс) / «нет» (главный флаг). + if FAudInTab then Result := FInDevNames.Count + 1 + else Result := FDevNames.Count; +end; + +function TVfoOverlay.AudRowName(I: Integer): string; +begin + if not FAudInTab then Exit(FDevNames[I]); + if I = 0 then + begin + if FIsMain then Result := '(нет · мик радио)' + else Result := '(общий вход)'; + Exit; + end; + Result := FInDevNames[I - 1]; +end; + +function TVfoOverlay.AudRowCurrent(I: Integer): Boolean; +begin + if FAudInTab then + begin + if I = 0 then Exit(FCurInDev = ''); + Result := FInDevNames[I - 1] = FCurInDev; + end + else + // Пустое имя вывода = устройство по умолчанию (первая строка списка). + Result := (FDevNames[I] = FCurDev) or ((FCurDev = '') and (I = 0)); +end; + procedure TVfoOverlay.DrawAudPanel(C: TCanvas; X, Y, RowW: Integer); const ARROW_W = 15; var - I, Slot, VisN, RowWidth, MaxScroll, PanelH, ArrowX, MidY, CY: Integer; - HR: TRect; + I, Slot, VisN, RowWidth, MaxScroll, PanelH, ArrowX, MidY, CY, Cnt: Integer; + HR, TabR: TRect; Nm: string; Cur, Scrollable, CanUp, CanDn: Boolean; Tri: array[0..2] of TPoint; begin + // Ряд вкладок: OUT (куда отдаём звук) / IN (откуда берём модуляцию). + TabR := Rect(X, Y, X + (RowW - 4) div 2, Y + ROW_H); + DrawButton(C, TabR, 'OUT', not FAudInTab, + (FHotIdx >= 0) and (FHitActions[FHotIdx] = HIT_AUD_TAB_OUT), 7); + RegisterHit(TabR, HIT_AUD_TAB_OUT); + TabR := Rect(TabR.Right + 4, Y, X + RowW, Y + ROW_H); + DrawButton(C, TabR, 'IN', FAudInTab, + (FHotIdx >= 0) and (FHitActions[FHotIdx] = HIT_AUD_TAB_IN), 7); + RegisterHit(TabR, HIT_AUD_TAB_IN); + Inc(Y, ROW_H + IGAP); + C.Font.Name := 'Courier New'; C.Font.Size := 7; C.Font.Style := []; - if FDevNames.Count = 0 then + Cnt := AudRowCount; + if Cnt = 0 then begin C.Font.Color := CLR_DIM; C.Brush.Style := bsClear; @@ -928,9 +994,9 @@ begin Exit; end; - VisN := Min(FDevNames.Count, AUD_MAX_VIS); - Scrollable := FDevNames.Count > AUD_MAX_VIS; - MaxScroll := FDevNames.Count - VisN; + VisN := Min(Cnt, AUD_MAX_VIS); + Scrollable := Cnt > AUD_MAX_VIS; + MaxScroll := Cnt - VisN; if FAudScroll > MaxScroll then FAudScroll := MaxScroll; if FAudScroll < 0 then FAudScroll := 0; if Scrollable then RowWidth := RowW - ARROW_W - 2 else RowWidth := RowW; @@ -939,8 +1005,8 @@ begin begin I := FAudScroll + Slot; HR := Rect(X, Y + Slot*AUD_ROW_H, X + RowWidth, Y + Slot*AUD_ROW_H + 16); - Nm := FDevNames[I]; - Cur := (Nm = FCurDev) or ((FCurDev = '') and (I = 0)); + Nm := AudRowName(I); + Cur := AudRowCurrent(I); if Cur then C.Brush.Color := CLR_ACCENT_D else if (FHotIdx >= 0) and (FHitActions[FHotIdx] = HIT_DEV_BASE + I) then C.Brush.Color := CLR_BTN_HOT @@ -954,7 +1020,7 @@ begin if C.TextWidth(Nm) > RowWidth - 8 then while (Length(Nm) > 1) and (C.TextWidth(Nm + '…') > RowWidth - 8) do SetLength(Nm, Length(Nm) - 1); - if Nm <> FDevNames[I] then Nm := Nm + '…'; + if Nm <> AudRowName(I) then Nm := Nm + '…'; C.TextOut(HR.Left + 4, HR.Top + (16 - C.TextHeight('A')) div 2, Nm); RegisterHit(HR, HIT_DEV_BASE + I); end; @@ -1235,16 +1301,42 @@ begin HIT_CLOSE: begin if Assigned(FOnClose) then FOnClose(Self); Exit; end; HIT_AUD_UP: begin if FAudScroll > 0 then Dec(FAudScroll); RequestInvalidate; Exit; end; - HIT_AUD_DN: begin if FAudScroll < FDevNames.Count - AUD_MAX_VIS then + HIT_AUD_DN: begin if FAudScroll < AudRowCount - AUD_MAX_VIS then Inc(FAudScroll); RequestInvalidate; Exit; end; + HIT_AUD_TAB_OUT, + HIT_AUD_TAB_IN: + begin + if FAudInTab <> (HV = HIT_AUD_TAB_IN) then + begin + FAudInTab := HV = HIT_AUD_TAB_IN; + FAudScroll := 0; // список другой длины + Height := ComputeHeight; + RequestInvalidate; + end; + Exit; + end; end; - // Устройство аудио + // Устройство аудио (строка активной вкладки OUT/IN) if HV >= HIT_DEV_BASE then begin - NewMode := HV - HIT_DEV_BASE; // индекс в списке - if (NewMode >= 0) and (NewMode < FDevNames.Count) then + NewMode := HV - HIT_DEV_BASE; // индекс строки + if (NewMode < 0) or (NewMode >= AudRowCount) then Exit; + if FAudInTab then + begin + // Строка 0 = «общий вход» (слайс) / «нет устройства» (главный флаг). + if NewMode = 0 then FCurInDev := '' + else FCurInDev := FInDevNames[NewMode - 1]; + FPanel := fpNone; Height := ComputeHeight; + RequestInvalidate; + if Assigned(FOnAudioInDevice) then + begin + if NewMode = 0 then FOnAudioInDevice(-1, '') + else FOnAudioInDevice(FInDevIdx[NewMode - 1], FInDevNames[NewMode - 1]); + end; + end + else begin FCurDev := FDevNames[NewMode]; FPanel := fpNone; Height := ComputeHeight; @@ -1543,6 +1635,20 @@ begin RequestInvalidate; end; +procedure TVfoOverlay.SetAudioInDevices(Names: TStrings; + const Indices: array of Integer; const CurName: string); +var + I: Integer; +begin + FInDevNames.Assign(Names); + SetLength(FInDevIdx, FInDevNames.Count); + for I := 0 to FInDevNames.Count - 1 do + if I <= High(Indices) then FInDevIdx[I] := Indices[I] else FInDevIdx[I] := -1; + FCurInDev := CurName; + if FPanel = fpAud then Height := ComputeHeight; + RequestInvalidate; +end; + procedure TVfoOverlay.UpdateSMeter(DB: Double); begin if Abs(DB - FSMeterDB) > FSMeterThreshold then diff --git a/ewsdr.lpi b/ewsdr.lpi index 2c969dc..16adc0f 100644 --- a/ewsdr.lpi +++ b/ewsdr.lpi @@ -17,9 +17,9 @@ - + - + @@ -128,6 +128,7 @@ + diff --git a/other/padump.lpr b/other/padump.lpr new file mode 100644 index 0000000..81e593e --- /dev/null +++ b/other/padump.lpr @@ -0,0 +1,25 @@ +program padump; +{$MODE Delphi} +uses SysUtils, Classes, AudioInput, AudioOutput; +var + AI: TAudioInput; + AO: TAudioOutput; + Names: TStringList; + Idx: array[0..63] of Integer; + Cnt, i: Integer; +begin + AI := TAudioInput.Create(48000); + AO := TAudioOutput.Create(48000); + Names := TStringList.Create; + Cnt := 0; + AI.EnumInputDevices(Names, Idx, Cnt); + WriteLn('--- INPUT devices (', Cnt, ') ---'); + for i := 0 to Cnt - 1 do WriteLn(Format('%3d %s', [Idx[i], Names[i]])); + WriteLn('FindDeviceByName(Direwolf_TX_Output) = ', + AI.FindDeviceByName('Direwolf_TX_Output')); + Names.Clear; Cnt := 0; + AO.EnumOutputDevices(Names, Idx, Cnt); + WriteLn('--- OUTPUT devices (', Cnt, ') ---'); + for i := 0 to Cnt - 1 do WriteLn(Format('%3d %s', [Idx[i], Names[i]])); + Names.Free; AI.Free; AO.Free; +end. diff --git a/other/patest2.lpr b/other/patest2.lpr new file mode 100644 index 0000000..0f9fe6e --- /dev/null +++ b/other/patest2.lpr @@ -0,0 +1,19 @@ +program patest2; +{$MODE Delphi} +uses SysUtils, AudioInput; +var A, B: TAudioInput; +begin + A := TAudioInput.Create(48000); + A.DeviceIndex := 15; // DireWolf_TX_Output + WriteLn('open #1 ...'); Flush(Output); + WriteLn(' -> ', A.Open, ' err=', A.LastError); Flush(Output); + B := TAudioInput.Create(48000); + B.DeviceIndex := 15; // то же устройство вторым потоком + WriteLn('open #2 ...'); Flush(Output); + WriteLn(' -> ', B.Open, ' err=', B.LastError); Flush(Output); + Sleep(300); + WriteLn('avail #1=', A.Available, ' #2=', B.Available); Flush(Output); + B.Close; A.Close; + B.Free; A.Free; + WriteLn('done'); +end. diff --git a/other/patest3.lpr b/other/patest3.lpr new file mode 100644 index 0000000..a33ed78 --- /dev/null +++ b/other/patest3.lpr @@ -0,0 +1,29 @@ +program patest3; +{$MODE Delphi} +uses SysUtils, Classes, AudioInput; +var + E: TAudioInput; + A: TAudioInput; + Names: TStringList; + Idx: array[0..63] of Integer; + Cnt, i: Integer; + OK: Boolean; +begin + E := TAudioInput.Create(48000); + Names := TStringList.Create; + Cnt := 0; + E.EnumInputDevices(Names, Idx, Cnt); + for i := 0 to Cnt - 1 do + begin + WriteLn(Format('>>> try %3d %s', [Idx[i], Names[i]])); Flush(Output); + A := TAudioInput.Create(48000); + A.DeviceIndex := Idx[i]; + OK := A.Open; + WriteLn(Format(' %3d open=%s %s', [Idx[i], BoolToStr(OK, True), A.LastError])); + Flush(Output); + A.Close; + A.Free; + end; + Names.Free; E.Free; + WriteLn('ALL DONE'); +end. From c69d53785218532118324ffb6e23b4d231a01b7d Mon Sep 17 00:00:00 2001 From: Vladimir Date: Tue, 21 Jul 2026 23:01:59 +0300 Subject: [PATCH 2/5] =?UTF-8?q?feature(tx):=20=D1=81=D0=BB=D0=B0=D0=B9?= =?UTF-8?q?=D1=81=20=D0=B2=20FM=20RAW=20=D0=BC=D0=BE=D0=B6=D0=BD=D0=BE=20?= =?UTF-8?q?=D0=BD=D0=B0=D0=B7=D0=BD=D0=B0=D1=87=D0=B0=D1=82=D1=8C=20=D0=B8?= =?UTF-8?q?=D1=81=D1=82=D0=BE=D1=87=D0=BD=D0=B8=D0=BA=D0=BE=D0=BC=20=D0=BF?= =?UTF-8?q?=D0=B5=D1=80=D0=B5=D0=B4=D0=B0=D1=87=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TX-бейдж на флаге слайса был заблокирован и для DMR, и для FM RAW, хотя тракт FM RAW передавать умеет: SetTxSlice отбрасывает только DMR, а SetTXMode уводит FM RAW в WFM с плоской девиацией. RX-only остаётся один DMR (нет AMBE-кодера). - VfoOverlay.TxSelectionAllowed: запрет сузился до DMR. - RadioController.SetSliceMode: если режим меняют у слайса, который сейчас источник TX, передающий тракт перестраивается под новый режим. Без этого выбор FM RAW на уже выбранном TX-слайсе оставлял в эфире прежнюю модуляцию до следующего клика по бейджу. На переходах в/из FM RAW заодно пересматривается источник модуляции (внешний PCM) со сбросом кольца входа — как в главном SetMode. Заодно снята временная диагностика [MIC] (MicDbg и её вызовы) из mic-пула: зависание при выборе на слайсе входа из настроек больше не воспроизводится. Проверено на железе: TX со слайса в FM RAW работает. Подробное тестирование — следующим шагом. Co-Authored-By: Claude Opus 4.8 --- RadioController.pas | 54 ++++++++++++++------------------------------- VfoOverlay.pas | 6 +++-- 2 files changed, 21 insertions(+), 39 deletions(-) diff --git a/RadioController.pas b/RadioController.pas index 9458732..996f726 100644 --- a/RadioController.pas +++ b/RadioController.pas @@ -2019,6 +2019,21 @@ begin if (Mode = MODE_DMR) and (FTxSliceId = Id) then SetTxSlice(0); if (OldMode = MODE_DMR) and (Mode <> MODE_DMR) then FreeAndNil(FSlices[idx].DMR); + // Этот слайс — источник передачи: передающий тракт идёт за его режимом + // (иначе после смены режима в эфир ушёл бы прежний вид модуляции). Вход FM + // RAW — внешний PCM, поэтому заодно пересматриваем источник модуляции. + if (FTxSliceId = Id) and (Mode <> OldMode) and + FWDSPReady and Assigned(FDSPEngine) then + begin + FDSPEngine.SetTXMode(ActiveTXMode); + if (Mode = MODE_FMRAW) or (OldMode = MODE_FMRAW) then + begin + if Mode = MODE_FMRAW then FWebMicActive := False; + FDSPEngine.SetTXMicSource(DefaultMicSource); + if Assigned(ActiveMicInput) and ActiveMicInput.IsOpen then + ActiveMicInput.Flush; + end; + end; end; procedure TRadioController.SetSliceFilter(Id, Low, High: Integer); @@ -2197,14 +2212,6 @@ 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). @@ -2213,25 +2220,14 @@ var 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 @@ -2242,15 +2238,7 @@ begin 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; + try AI.Open; except end; n := Length(FMicPool); SetLength(FMicPool, n + 1); FMicPool[n].DevIdx := DevIndex; @@ -2304,8 +2292,6 @@ 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; @@ -2321,18 +2307,12 @@ 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; + try FAudioIn.Open; except end; end; FAudioInDevName := DevName; // Слайсы могли ссылаться на общий вход (или держать свой поток на устройстве, diff --git a/VfoOverlay.pas b/VfoOverlay.pas index 2dfa2e7..0238b78 100644 --- a/VfoOverlay.pas +++ b/VfoOverlay.pas @@ -463,8 +463,10 @@ end; function TVfoOverlay.TxSelectionAllowed: Boolean; begin - // Main A remains the normal TX source. Additional DMR slices are RX-only. - Result := FIsMain or not (FMode in [OVL_MODE_DMR, OVL_MODE_FMRAW]); + // Main A remains the normal TX source. DMR slices stay RX-only (no AMBE + // encoder). FM RAW transmits external PCM from its own input, so it is a + // valid TX source. + Result := FIsMain or (FMode <> OVL_MODE_DMR); end; { ---- Lifecycle ---- } From fb1c26346099dc1cf6a617c727ac17818b696a23 Mon Sep 17 00:00:00 2001 From: Vladimir Date: Wed, 22 Jul 2026 09:25:41 +0300 Subject: [PATCH 3/5] =?UTF-8?q?ui(vfo):=20AUD-=D0=BF=D0=B0=D0=BD=D0=B5?= =?UTF-8?q?=D0=BB=D1=8C=20=D0=BE=D1=82=D0=BA=D1=80=D1=8B=D0=B2=D0=B0=D0=B5?= =?UTF-8?q?=D1=82=D1=81=D1=8F=20=D0=BD=D0=B0=20=D0=B2=D1=8B=D0=B1=D1=80?= =?UTF-8?q?=D0=B0=D0=BD=D0=BD=D0=BE=D0=BC=20=D1=83=D1=81=D1=82=D1=80=D0=BE?= =?UTF-8?q?=D0=B9=D1=81=D1=82=D0=B2=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit При раскрытии fly-out AUD (и при переключении вкладок OUT/IN) список теперь прокручивается к текущему устройству — выбранная строка ставится примерно в середину видимого окна, а не приходится искать её стрелками. Ручной скролл при этом не сбрасывается: push списков от контроллера (SetAudioDevices/SetAudioInDevices) позицию не трогает. Co-Authored-By: Claude Opus 4.8 --- VfoOverlay.pas | 24 +++++++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/VfoOverlay.pas b/VfoOverlay.pas index 0238b78..cb7c248 100644 --- a/VfoOverlay.pas +++ b/VfoOverlay.pas @@ -157,6 +157,7 @@ type function AudRowCount: Integer; function AudRowName(I: Integer): string; function AudRowCurrent(I: Integer): Boolean; + procedure ScrollAudToCurrent; // прокрутка к выбранному устройству procedure RegisterHit(const HR: TRect; HV: Integer); procedure ApplyThemeColors; // раскладывает FTheme → структурные CLR_* function FormatFreq(Hz: Double): string; @@ -657,7 +658,7 @@ end; procedure TVfoOverlay.SetPanel(P: TFlyPanel); begin if FPanel = P then FPanel := fpNone else FPanel := P; - if FPanel = fpAud then FAudScroll := 0; // список с начала + if FPanel = fpAud then ScrollAudToCurrent; // сразу показать выбранное Height := ComputeHeight; RequestInvalidate; end; @@ -963,6 +964,23 @@ begin Result := (FDevNames[I] = FCurDev) or ((FCurDev = '') and (I = 0)); end; +procedure TVfoOverlay.ScrollAudToCurrent; +var + I, Cnt, MaxScroll: Integer; +begin + FAudScroll := 0; + Cnt := AudRowCount; + if Cnt <= AUD_MAX_VIS then Exit; + for I := 0 to Cnt - 1 do + if AudRowCurrent(I) then + begin + // Ставим выбранную строку примерно в середину видимого окна. + MaxScroll := Cnt - AUD_MAX_VIS; + FAudScroll := Min(Max(0, I - AUD_MAX_VIS div 2), MaxScroll); + Exit; + end; +end; + procedure TVfoOverlay.DrawAudPanel(C: TCanvas; X, Y, RowW: Integer); const ARROW_W = 15; @@ -1311,8 +1329,8 @@ begin begin if FAudInTab <> (HV = HIT_AUD_TAB_IN) then begin - FAudInTab := HV = HIT_AUD_TAB_IN; - FAudScroll := 0; // список другой длины + FAudInTab := HV = HIT_AUD_TAB_IN; + ScrollAudToCurrent; // список другой длины Height := ComputeHeight; RequestInvalidate; end; From c986232891b8785163f342788a0cb7e4e0ea6e6a Mon Sep 17 00:00:00 2001 From: Vladimir Date: Wed, 22 Jul 2026 09:29:32 +0300 Subject: [PATCH 4/5] =?UTF-8?q?ui(vfo):=20=D0=BA=D0=BE=D0=BB=D0=B5=D1=81?= =?UTF-8?q?=D0=BE=20=D0=BC=D1=8B=D1=88=D0=B8=20=D0=BF=D1=80=D0=BE=D0=BA?= =?UTF-8?q?=D1=80=D1=83=D1=87=D0=B8=D0=B2=D0=B0=D0=B5=D1=82=20=D1=81=D0=BF?= =?UTF-8?q?=D0=B8=D1=81=D0=BE=D0=BA=20=D1=83=D1=81=D1=82=D1=80=D0=BE=D0=B9?= =?UTF-8?q?=D1=81=D1=82=D0=B2=20=D0=B2=20AUD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit VfoOverlay запоминает прямоугольник списка (FAudListRect) и обрабатывает колесо над ним (HandleMouseWheel): шаг = одна строка. Если курсор в этой зоне, событие считается обработанным и не уходит в спектр — частота не съезжает даже когда список целиком помещается и крутить нечего. PanafallPanel.DispatchFlagsMouseWheel перебирает главный флаг и флаги слайсов (как остальные Dispatch*), MainForm.FormMouseWheel зовёт его по всем панам первым делом после дисплеев VFO; координаты берутся через PbSpectrum.ScreenToClient(Mouse.CursorPos), поэтому работает и в pop-out. Co-Authored-By: Claude Opus 4.8 --- MainForm.pas | 15 +++++++++++++++ PanafallPanel.pas | 21 +++++++++++++++++++++ VfoOverlay.pas | 21 +++++++++++++++++++++ 3 files changed, 57 insertions(+) diff --git a/MainForm.pas b/MainForm.pas index c07556c..3334ebb 100644 --- a/MainForm.pas +++ b/MainForm.pas @@ -7885,12 +7885,27 @@ var SLo, SHi, SNew: Double; Sl: TCtrlSlice; SlicePan: TPanafallPanel; + P: TPoint; begin // Над дисплеем VFO (A или B) колесо обрабатывает сам TFreqDisplay поразрядно // (включая неактивный VFO). Не дублируем здесь, иначе менялись бы обе частоты: // виджет крутил бы B, а этот хендлер — активный A. if OverFreqDisp(FreqDispA) or OverFreqDisp(FreqDispB) then Exit; + // Колесо над раскрытым списком устройств во флаге — прокрутка этого списка. + for i := 0 to MAX_PANS - 1 do + if (FPans[i] <> nil) and Assigned(FPans[i].PbSpectrum) and + FPans[i].PbSpectrum.Visible then + begin + P := FPans[i].PbSpectrum.ScreenToClient(Mouse.CursorPos); + if FPans[i].DispatchFlagsMouseWheel(P.X, P.Y, WheelDelta) then + begin + MarkPanDirty(FPans[i]); + Handled := True; + Exit; + end; + end; + // Колесо над маркером слайса (любого пана) — двигаем частоту ЭТОГО слайса. SliceId := 0; SlicePan := nil; diff --git a/PanafallPanel.pas b/PanafallPanel.pas index 35027a7..9f221d2 100644 --- a/PanafallPanel.pas +++ b/PanafallPanel.pas @@ -292,6 +292,7 @@ type procedure DispatchFlagsMouseMove(X, Y: Integer); function DispatchFlagsMouseUp(X, Y: Integer): Boolean; procedure DispatchFlagsMouseLeave; + function DispatchFlagsMouseWheel(X, Y, WheelDelta: Integer): Boolean; property Controller: TRadioController read FController write FController; property MainFlag: TVfoOverlay read FMainFlag; @@ -1557,4 +1558,24 @@ begin if FSliceFlags[i] <> nil then FSliceFlags[i].HandleMouseLeave; end; +// Колесо над раскрытым списком устройств во флаге: прокрутка списка вместо +// перестройки частоты. Координаты — в системе спектра (как у остальных Handle*). +function TPanafallPanel.DispatchFlagsMouseWheel(X, Y, WheelDelta: Integer): Boolean; +var i: Integer; +begin + Result := False; + if Assigned(FMainFlag) and FMainFlag.Visible then + begin + FActiveFlag := FMainFlag; + if FMainFlag.HandleMouseWheel(X, Y, WheelDelta) then Exit(True); + end; + for i := 0 to High(FSliceFlags) do + if (FSliceFlags[i] <> nil) and FSliceFlags[i].Visible then + begin + FActiveFlag := FSliceFlags[i]; + if FSliceFlags[i].HandleMouseWheel(X, Y, WheelDelta) then Exit(True); + end; + FActiveFlag := nil; +end; + end. diff --git a/VfoOverlay.pas b/VfoOverlay.pas index cb7c248..3bc84fc 100644 --- a/VfoOverlay.pas +++ b/VfoOverlay.pas @@ -101,6 +101,7 @@ type FPanel: TFlyPanel; // открытая fly-out панель FAudScroll: Integer; // прокрутка списка аудио-устройств (верхний индекс) + FAudListRect: TRect; // область списка AUD (лок. коорд.) — зона колёсика FVolTrack: TRect; // трек громкости (локальные координаты) FVolDrag: Boolean; FSqlTrack: TRect; // трек squelch в DSP-панели (лок. координаты) @@ -196,6 +197,8 @@ type function HandleMouseMove(X, Y: Integer): Boolean; function HandleMouseUp(X, Y: Integer): Boolean; function HandleMouseLeave: Boolean; + // Колесо над списком устройств AUD (координаты — как у HandleMouseDown). + function HandleMouseWheel(X, Y, WheelDelta: Integer): Boolean; procedure SetState(AMode, ABW: Integer; AVfoHz, ASMeterDB: Double); procedure SetDSPState(ANRMode, ANBMode: Integer; ASNBOn, AANFOn: Boolean; AAGCMode: Integer); @@ -1015,6 +1018,7 @@ begin end; VisN := Min(Cnt, AUD_MAX_VIS); + FAudListRect := Rect(X, Y, X + RowW, Y + VisN*AUD_ROW_H); // зона колёсика Scrollable := Cnt > AUD_MAX_VIS; MaxScroll := Cnt - VisN; if FAudScroll > MaxScroll then FAudScroll := MaxScroll; @@ -1093,6 +1097,7 @@ var BarL, BarW, MeterX: Integer; begin FHitCount := 0; + FAudListRect := Rect(0, 0, 0, 0); // выставит DrawAudPanel, если панель открыта // Фон карточки C.Brush.Color := CLR_CARD_BG; @@ -1506,6 +1511,22 @@ begin Result := True; // клик был по накладке — не пускаем его в спектр end; +function TVfoOverlay.HandleMouseWheel(X, Y, WheelDelta: Integer): Boolean; +var + MaxScroll, Old: Integer; +begin + Result := False; + if (not Visible) or (FPanel <> fpAud) then Exit; + if not PtInRect(FAudListRect, Point(X - Left, Y - Top)) then Exit; + Result := True; // зона наша — колесо в спектр не пускаем + MaxScroll := AudRowCount - AUD_MAX_VIS; + if MaxScroll <= 0 then Exit; // список целиком виден — крутить нечего + Old := FAudScroll; + if WheelDelta > 0 then Dec(FAudScroll) else Inc(FAudScroll); + FAudScroll := EnsureRange(FAudScroll, 0, MaxScroll); + if FAudScroll <> Old then RequestInvalidate; +end; + function TVfoOverlay.HandleMouseLeave: Boolean; begin Result := False; From 8b5faaf986d8a7c6e946331cf6bbc7a66aae9a4b Mon Sep 17 00:00:00 2001 From: Vladimir Date: Wed, 22 Jul 2026 10:21:39 +0300 Subject: [PATCH 5/5] =?UTF-8?q?chore(audio):=20=D1=83=D0=B1=D1=80=D0=B0?= =?UTF-8?q?=D0=BD=D1=8B=20=D0=B2=D1=80=D0=B5=D0=BC=D0=B5=D0=BD=D0=BD=D1=8B?= =?UTF-8?q?=D0=B5=20=D0=B7=D0=BE=D0=BD=D0=B4=D1=8B=20PortAudio?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit other/padump.lpr, patest2.lpr, patest3.lpr писались для диагностики зависания при выборе входа на слайсе (двойное открытие устройства, дамп PA-списка). Баг закрыт как невоспроизводимый, зонды в сборку не входят и ниоткуда не вызываются — при надобности достаются из истории (4aa180e). Co-Authored-By: Claude Opus 4.8 --- other/padump.lpr | 25 ------------------------- other/patest2.lpr | 19 ------------------- other/patest3.lpr | 29 ----------------------------- 3 files changed, 73 deletions(-) delete mode 100644 other/padump.lpr delete mode 100644 other/patest2.lpr delete mode 100644 other/patest3.lpr diff --git a/other/padump.lpr b/other/padump.lpr deleted file mode 100644 index 81e593e..0000000 --- a/other/padump.lpr +++ /dev/null @@ -1,25 +0,0 @@ -program padump; -{$MODE Delphi} -uses SysUtils, Classes, AudioInput, AudioOutput; -var - AI: TAudioInput; - AO: TAudioOutput; - Names: TStringList; - Idx: array[0..63] of Integer; - Cnt, i: Integer; -begin - AI := TAudioInput.Create(48000); - AO := TAudioOutput.Create(48000); - Names := TStringList.Create; - Cnt := 0; - AI.EnumInputDevices(Names, Idx, Cnt); - WriteLn('--- INPUT devices (', Cnt, ') ---'); - for i := 0 to Cnt - 1 do WriteLn(Format('%3d %s', [Idx[i], Names[i]])); - WriteLn('FindDeviceByName(Direwolf_TX_Output) = ', - AI.FindDeviceByName('Direwolf_TX_Output')); - Names.Clear; Cnt := 0; - AO.EnumOutputDevices(Names, Idx, Cnt); - WriteLn('--- OUTPUT devices (', Cnt, ') ---'); - for i := 0 to Cnt - 1 do WriteLn(Format('%3d %s', [Idx[i], Names[i]])); - Names.Free; AI.Free; AO.Free; -end. diff --git a/other/patest2.lpr b/other/patest2.lpr deleted file mode 100644 index 0f9fe6e..0000000 --- a/other/patest2.lpr +++ /dev/null @@ -1,19 +0,0 @@ -program patest2; -{$MODE Delphi} -uses SysUtils, AudioInput; -var A, B: TAudioInput; -begin - A := TAudioInput.Create(48000); - A.DeviceIndex := 15; // DireWolf_TX_Output - WriteLn('open #1 ...'); Flush(Output); - WriteLn(' -> ', A.Open, ' err=', A.LastError); Flush(Output); - B := TAudioInput.Create(48000); - B.DeviceIndex := 15; // то же устройство вторым потоком - WriteLn('open #2 ...'); Flush(Output); - WriteLn(' -> ', B.Open, ' err=', B.LastError); Flush(Output); - Sleep(300); - WriteLn('avail #1=', A.Available, ' #2=', B.Available); Flush(Output); - B.Close; A.Close; - B.Free; A.Free; - WriteLn('done'); -end. diff --git a/other/patest3.lpr b/other/patest3.lpr deleted file mode 100644 index a33ed78..0000000 --- a/other/patest3.lpr +++ /dev/null @@ -1,29 +0,0 @@ -program patest3; -{$MODE Delphi} -uses SysUtils, Classes, AudioInput; -var - E: TAudioInput; - A: TAudioInput; - Names: TStringList; - Idx: array[0..63] of Integer; - Cnt, i: Integer; - OK: Boolean; -begin - E := TAudioInput.Create(48000); - Names := TStringList.Create; - Cnt := 0; - E.EnumInputDevices(Names, Idx, Cnt); - for i := 0 to Cnt - 1 do - begin - WriteLn(Format('>>> try %3d %s', [Idx[i], Names[i]])); Flush(Output); - A := TAudioInput.Create(48000); - A.DeviceIndex := Idx[i]; - OK := A.Open; - WriteLn(Format(' %3d open=%s %s', [Idx[i], BoolToStr(OK, True), A.LastError])); - Flush(Output); - A.Close; - A.Free; - end; - Names.Free; E.Free; - WriteLn('ALL DONE'); -end.