fix(tci): передача с слайса — маркер TX_CHRONO под номером клиента, фронт rfTransmitting

Живой прогон с MSHV на втором слайсе (openHPSDR): приём в порядке, эфир по
trx:1,true,tci поднимается, а звук от клиента не доходит. Два независимых
дефекта, оба видны только на приёмнике > 0 — на rx0 передача работала, потому
стенд их и не ловил.

1. Маркеры TX_CHRONO уходили с receiver = 0 жёстко. MSHV шлёт TX-аудио ТОЛЬКО
   в ответ на маркер и фильтрует все входящие бинарные блоки по номеру
   приёмника первой же строкой обработчика (network.cpp: `if
   (pStream->receiver != tci_trx) return;`, ветка TxChrono там же и собирает
   блок). У клиента на слайсе tci_trx = 1, так что маркеры отбрасывались
   целиком. Теперь вместе с клиентом-модулятором запоминается номер приёмника
   из его же TRX (FTxRx), и маркеры идут под ним.

2. Changed(rfTransmitting) из SetTxSlice стирал TCIMicRequested. Контроллер
   шлёт это поле и просто как «перерисуй TX-бейджи», а адаптер понимал любой
   такой сигнал при FTransmitting = false как «передача кончилась». Приходил
   он посередине нашей же команды: SyncSetTRX ставит просьбу → RequestSliceTx
   → SetTxSlice → Changed → просьба стёрта → SetMOX выбирает микрофон уже без
   неё. В эфир шёл микрофон оператора (тишина), а TX-аудио клиента
   отбрасывалось — TCIMicActive не поднят. Теперь ловится фронт «было → стало»
   (FLastTxOn), а не всякое уведомление.

Стенд test/tci: 217 проверок (было 214), все зелёные. Новое — часть E, слайс
как приёмник 1: по trx:1,true,tci модуляция из TCI взята, маркеры TX_CHRONO
идут и названы номером 1. Негативный контроль разделён: каждая правка краснит
свою проверку.

Проверено на железе: MSHV на втором слайсе передаёт.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-19 21:47:30 +03:00
co-authored by Claude Opus 5
parent 3dc7035f9e
commit ecd42c32f8
3 changed files with 99 additions and 13 deletions
+38 -10
View File
@@ -152,7 +152,9 @@ type
// ── TX-аудио от клиента (§3.4) ── // ── TX-аудио от клиента (§3.4) ──
FTxLock: TCriticalSection; FTxLock: TCriticalSection;
FTxClient: TTCIClient; // кто модулирует (nil — никто) FTxClient: TTCIClient; // кто модулирует (nil — никто)
FTxRx: Integer; // ЕГО номер приёмника (из TRX) — см. PushTxChrono
FTrxOwner: TTCIClient; // кто поставил трансивер в эфир (§4.2) FTrxOwner: TTCIClient; // кто поставил трансивер в эфир (§4.2)
FLastTxOn: Boolean; // было ли радио в эфире на прошлом rfTransmitting
FTxInterp: TTCIInterpolator; FTxInterp: TTCIInterpolator;
FTxInRate: Integer; // частота дискретизации подачи клиента FTxInRate: Integer; // частота дискретизации подачи клиента
FTxRunning: Boolean; // маркеры TX_CHRONO идут FTxRunning: Boolean; // маркеры TX_CHRONO идут
@@ -394,6 +396,9 @@ begin
FStreamLock := TCriticalSection.Create; FStreamLock := TCriticalSection.Create;
FTxLock := TCriticalSection.Create; FTxLock := TCriticalSection.Create;
FWriter := nil; // заводится на первом SAVE (см. EnqueueWav) FWriter := nil; // заводится на первом SAVE (см. EnqueueWav)
// Состояние передачи на старте: фронт «было-стало» ловим с него (см. OnState).
FLastTxOn := (AController <> nil) and
(AController.FTransmitting or AController.FTuning);
FTapsOn := False; FTapsOn := False;
SetLength(FTxRaw, TCI_STREAM_DATA_MAX div 2); // худший случай: int16 SetLength(FTxRaw, TCI_STREAM_DATA_MAX div 2); // худший случай: int16
SetLength(FTxMono, TCI_STREAM_DATA_MAX div 2); SetLength(FTxMono, TCI_STREAM_DATA_MAX div 2);
@@ -1959,8 +1964,11 @@ begin
FromTCI := B and (not Tune) and (Name = 'tci') and HasAudioStream(Client); FromTCI := B and (not Tune) and (Name = 'tci') and HasAudioStream(Client);
FTxLock.Enter; FTxLock.Enter;
try try
if FromTCI then FTxClient := Client // ★Вместе с клиентом запоминаем НОМЕР ПРИЁМНИКА, которым он назвался:
else if (not Tune) and (FTxClient = Client) then FTxClient := nil; // маркеры TX_CHRONO обязаны идти под ним же (см. PushTxChrono).
if FromTCI then begin FTxClient := Client; FTxRx := Rx; end
else if (not Tune) and (FTxClient = Client) then
begin FTxClient := nil; FTxRx := 0; end;
finally FTxLock.Leave; end; finally FTxLock.Leave; end;
Started := False; Started := False;
FLock.Enter; FLock.Enter;
@@ -3227,6 +3235,7 @@ begin
if (FTxClient <> nil) and ((C = nil) or (FTxClient = C)) then if (FTxClient <> nil) and ((C = nil) or (FTxClient = C)) then
begin begin
FTxClient := nil; FTxClient := nil;
FTxRx := 0;
FTxRunning := False; FTxRunning := False;
Drop := True; Drop := True;
end; end;
@@ -3295,14 +3304,15 @@ procedure TTCIAdapter.PushTxChrono;
var var
C: TTCIClient; C: TTCIClient;
Now_: QWord; Now_: QWord;
Rate, Chans, Block: Integer; Rate, Chans, Block, Rx: Integer;
ST: TTCISampleType; ST: TTCISampleType;
H: TTCIStreamHeader; H: TTCIStreamHeader;
Active: Boolean; Active: Boolean;
begin begin
FTxLock.Enter; FTxLock.Enter;
try try
C := FTxClient; C := FTxClient;
Rx := FTxRx;
finally finally
FTxLock.Leave; FTxLock.Leave;
end; end;
@@ -3341,7 +3351,13 @@ begin
end; end;
while FTxOwed >= Block do while FTxOwed >= Block do
begin begin
TCIFillHeader(H, tstTXChrono, 0, Rate, ST, Block, Chans); // ★Номер приёмника — ТОТ, которым назвался клиент в TRX, а не 0.
// Клиент фильтрует ВХОДЯЩИЕ БИНАРНЫЕ блоки по receiver (MSHV,
// network.cpp:231: `if (pStream->receiver != tci_trx) return;`), а
// TX-аудио шлёт ровно в ответ на этот маркер (там же, ветка TxChrono).
// С нулём клиент на втором слайсе (tci_trx = 1) поднимал эфир и молчал:
// маркеры до него не доходили вовсе.
TCIFillHeader(H, tstTXChrono, Rx, Rate, ST, Block, Chans);
C.SendBin(H, nil, 0); C.SendBin(H, nil, 0);
FTxOwed := FTxOwed - Block; FTxOwed := FTxOwed - Block;
end; end;
@@ -3446,7 +3462,7 @@ procedure TTCIAdapter.OnState(Sender: TObject; Field: TRadioField);
var var
Rx, Ch: Integer; Rx, Ch: Integer;
TxHz: Double; TxHz: Double;
MapChanged: Boolean; MapChanged, TxNow: Boolean;
Sig: string; Sig: string;
begin begin
// Снимок слайсов обновляем ДО всего остального и НЕЗАВИСИМО от того, есть ли // Снимок слайсов обновляем ДО всего остального и НЕЗАВИСИМО от того, есть ли
@@ -3466,11 +3482,23 @@ begin
// уход когда-нибудь потом снял бы уже чужую передачу) и снимаем просьбу // уход когда-нибудь потом снял бы уже чужую передачу) и снимаем просьбу
// «модулируй из потока TCI»: она относилась ровно к той передаче, которую // «модулируй из потока TCI»: она относилась ровно к той передаче, которую
// клиент и начал, а следующий PTT оператора обязан идти с его микрофона. // клиент и начал, а следующий PTT оператора обязан идти с его микрофона.
if (Field = rfTransmitting) and (FController <> nil) // ★Ловим именно ФРОНТ «было-стало», а не всякий rfTransmitting. Это поле
and not FController.FTransmitting then // контроллер шлёт и просто «перерисуй TX-бейджи»: SetTxSlice заканчивается
// Changed(rfTransmitting), хотя эфира ещё нет. По прежнему условию такой
// сигнал приходил ПОСЕРЕДИНЕ нашей же команды trx:<N>,true,tci (SyncSetTRX
// ставит просьбу → RequestSliceTx → SetTxSlice → Changed) и стирал её ДО
// SetMOX, который её и читает. Итог на живом железе: клиент на слайсе
// поднимал эфир, а модуляция шла с микрофона оператора, то есть в эфир —
// тишина.
if (Field = rfTransmitting) and (FController <> nil) then
begin begin
ForgetTxOwner; TxNow := FController.FTransmitting or FController.FTuning;
FController.TCIMicRequested := False; if FLastTxOn and (not TxNow) then
begin
ForgetTxOwner;
FController.TCIMicRequested := False;
end;
FLastTxOn := TxNow;
end; end;
MapChanged := False; MapChanged := False;
+26 -2
View File
@@ -495,6 +495,27 @@ web-клиента: явная просьба сильнее умолчания
микрофона, что и web-аудио. Аудио от клиента, который не просил `tci`, микрофона, что и web-аудио. Аудио от клиента, который не просил `tci`,
отбрасывается молча: отвечать ошибкой на каждый чужой блок значит захлебнуться. отбрасывается молча: отвечать ошибкой на каждый чужой блок значит захлебнуться.
**★Маркер `TX_CHRONO` называет приёмник КЛИЕНТА, а не нулевой.** Клиент шлёт
TX-аудио не по своей воле, а строго в ответ на маркер, и все входящие бинарные
блоки фильтрует по номеру приёмника — у MSHV это первая строка обработчика
(`network.cpp`: `if (pStream->receiver != tci_trx) return;`, ветка `TxChrono`
там же собирает и отправляет блок). С жёстким нулём в заголовке клиент,
сидящий на **втором слайсе** (`tci_trx = 1`), маркеров не видел вовсе: эфир по
`trx:1,true,tci` поднимался, а звука не было ни одного блока. Поэтому вместе с
клиентом-модулятором запоминается номер приёмника из его же `TRX`
(`FTxRx`), и маркеры идут под ним.
**★`rfTransmitting` — не всегда «эфир изменился».** Контроллер шлёт это поле и
просто «перерисуй TX-бейджи»: `SetTxSlice` заканчивается `Changed(rfTransmitting)`,
хотя передачи ещё нет. Адаптер раньше понимал любой такой сигнал при
`FTransmitting = false` как «передача кончилась» и снимал `TCIMicRequested`
а приходил он **посередине нашей же команды**: `SyncSetTRX` ставит просьбу →
`RequestSliceTx``SetTxSlice``Changed` → просьба стёрта → `SetMOX` выбирает
микрофон уже без неё. На живом железе это выглядело так: клиент на слайсе
поднимает эфир, а модуляция идёт с микрофона оператора, то есть в эфир —
тишина (и TX-аудио клиента отбрасывалось, `TCIMicActive` не поднят). Теперь
ловится **фронт** «было → стало» (`FLastTxOn`), а не всякое уведомление.
**Запись линейного выхода.** Рекордер один на приёмник (а не на клиента): **Запись линейного выхода.** Рекордер один на приёмник (а не на клиента):
пишет он то, что слышно в аппарате. Буфер на запрошенное время (потолок 300 с) пишет он то, что слышно в аппарате. Буфер на запрошенное время (потолок 300 с)
в int16 48 кГц стерео — это ровно то, что уйдёт в WAV, и вдвое меньше памяти, в int16 48 кГц стерео — это ровно то, что уйдёт в WAV, и вдвое меньше памяти,
@@ -933,7 +954,7 @@ ExpertSDR3 давно бы не было.
движков и сети валится с AV — клиент получает `tci_error`, соединение живо) и движков и сети валится с AV — клиент получает `tci_error`, соединение живо) и
неразрывность пачки инициализации под крутящейся ручкой. неразрывность пачки инициализации под крутящейся ручкой.
### Стенд этапа 2 (бинарные потоки) — 214 проверок, все зелёные ### Стенд этапа 2 (бинарные потоки) — 217 проверок, все зелёные
Отдельная программа (`test/tci/tcitest.pas`, прогон — `test/tci/run.sh`, Отдельная программа (`test/tci/tcitest.pas`, прогон — `test/tci/run.sh`,
внешних библиотек не требует) проверяет потоки на четырёх уровнях: внешних библиотек не требует) проверяет потоки на четырёх уровнях:
@@ -1001,7 +1022,10 @@ ExpertSDR3 давно бы не было.
- **Слайс как приёмник** (часть E, живой движок): созданный на ГЛАВНОМ пане - **Слайс как приёмник** (часть E, живой движок): созданный на ГЛАВНОМ пане
слайс становится приёмником 1, отвечает на `vfo:1,0;` своей частотой (это и слайс становится приёмником 1, отвечает на `vfo:1,0;` своей частотой (это и
есть вся инициализация MSHV), слушается командой `vfo:1,0,<Гц>`, отдаёт своё есть вся инициализация MSHV), слушается командой `vfo:1,0,<Гц>`, отдаёт своё
аудио блоками с `receiver = 1`, а после удаления слайса приёмник 1 замолкает аудио блоками с `receiver = 1`, ★по `trx:1,true,tci` берёт модуляцию из TCI
(то есть просьбу не стирает `Changed(rfTransmitting)` из `SetTxSlice`) и шлёт
маркеры `TX_CHRONO` **под номером 1** — оба этих места и ломали передачу
MSHV, сидящего на втором слайсе, а после удаления слайса приёмник 1 замолкает
целиком — вместо прежнего `vfo:1,0,0`. целиком — вместо прежнего `vfo:1,0,0`.
- **Сквозной прогон через живой WDSP:** синтетический 24-битный IQ подаётся - **Сквозной прогон через живой WDSP:** синтетический 24-битный IQ подаётся
в движок, а клиент по WebSocket получает блоки RX-аудио 12 кГц и IQ 48 кГц в движок, а клиент по WebSocket получает блоки RX-аудио 12 кГц и IQ 48 кГц
+35 -1
View File
@@ -1566,7 +1566,7 @@ var
Op: Byte; Op: Byte;
Pay: TBytes; Pay: TBytes;
H: TTCIStreamHeader; H: TTCIStreamHeader;
AudioBlocks, IQBlocks, BadHdr: Integer; AudioBlocks, IQBlocks, BadHdr, BadRx: Integer;
SliceId: Integer; SliceId: Integer;
SV: TSliceView; SV: TSliceView;
S: string; S: string;
@@ -1758,6 +1758,40 @@ begin
Inc(n); Inc(n);
end; end;
Check('аудио слайса идёт под номером 1', n > 0, IntToStr(n)); Check('аудио слайса идёт под номером 1', n > 0, IntToStr(n));
// ── ★TX со слайса: маркер обязан нести НОМЕР ЭТОГО приёмника ─────────
// MSHV шлёт TX-аудио только в ответ на маркер TX_CHRONO и отбрасывает
// ЛЮБОЙ входящий блок с чужим receiver (network.cpp:231 — `if
// (pStream->receiver != tci_trx) return;`, ветка TxChrono — network.cpp:288
// и далее). С жёстким нулём в заголовке клиент, сидящий на втором слайсе
// (tci_trx = 1), поднимал эфир и молчал: маркеры до него не доходили, а
// без них он не отправляет ни одного блока. Ровно это и наблюдалось на
// живом железе с MSHV.
Ctrl.FWDSPReady := True;
Ctrl.SetSliceSlotAutoTx(Ctrl.SliceSlotOf(SliceId), True);
C.SendText('trx:1,true,tci;');
C.WaitText('trx:', 1500);
Check('TX слайса: модуляция из TCI взята', Ctrl.TCIMicActive);
C.Pump(400);
n := 0;
BadRx := 0;
while C.NextFrame(Op, Pay) do
if (Op = $02) and (Length(Pay) >= SizeOf(H)) then
begin
Move(Pay[0], H, SizeOf(H));
if H.StreamType = LongWord(Ord(tstTXChrono)) then
begin
Inc(n);
if H.Receiver <> 1 then Inc(BadRx);
end;
end;
Check('TX слайса: маркеры TX_CHRONO идут', n > 0, IntToStr(n));
Check('TX слайса: маркер назван номером приёмника (MSHV фильтрует)',
(n > 0) and (BadRx = 0), IntToStr(BadRx));
C.SendText('trx:1,false;');
C.WaitText('trx:', 1000);
Ctrl.SetSliceSlotAutoTx(Ctrl.SliceSlotOf(SliceId), False);
C.SendText('audio_stop:1;'); C.SendText('audio_stop:1;');
C.Pump(200); C.Pump(200);