fix(tci,cat): резерв бюджета переезжает писателю; строгий разбор полей ZZ-команд

Три замечания по 79132f1.

1. [P2] Лимит памяти рекордеров обходился при SAVE. Take собирал сплошную
   копию записи, а DropData тут же возвращал исходные куски в бюджет —
   копия жила дальше в асинхронном TTCIWavWriter уже неучтённой. Один SAVE
   поднимал настоящее потребление примерно вдвое, а на медленном или
   зависшем сетевом каталоге очередь writer-потоков и их буферов росла
   мимо потолка вовсе. Теперь копия НАСЛЕДУЕТ резерв кусков, из которых
   собрана: Take отдаёт его out-параметром Reserved, писатель держит до
   конца записи и отпускает в ReleaseData — ровно один раз, сколько бы
   путей выхода ни было у Execute. Новых денег у бюджета копия не берёт,
   так что потолок теперь считает и очередь сохранений тоже.

2. [P2] Пять ZZ-команд проверяли длину поля, но не содержимое:
   StrToIntDef(s, 0) превращал любую нечисловую пару символов в индекс 0.
   ZZBSxx; переключал диапазон на нулевой вместо ?;, ZZBMxx; и
   ZZAUxx;/ZZBPxx; двигали VFO, ZZFIxx; выбирал фильтр 0. Разбор приведён
   к идиоме ZZFL/ZZFH: TryStrToInt, иначе ошибка формата.

3. [P3] Сообщение об отказе запуска TCI звало в Settings → Advanced, а
   настройки там уже не живут — вкладка CAT (переезд был в de0830f).

Стенд 194/194: Take отдаёт резерв размером с копию, после Take занят ровно
он, писатель возвращает его по окончании. Без фикса первая проверка
краснеет. GUI (--ws=qt6) и демон зелёные. doc/TCI.md §2.5 и сводка стенда,
doc/CAT_STATUS.md — таблица разбора.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-19 12:50:21 +03:00
co-authored by Claude Opus 5
parent 79132f133c
commit 9b77809a73
7 changed files with 119 additions and 31 deletions
+44 -11
View File
@@ -214,8 +214,12 @@ type
destructor Destroy; override;
procedure Feed(const L, R: array of Single; N: Integer);
{ Забрать накопленное и завершить запись. nil — либо не записано ничего,
либо окно уже истекло (по документу это одно и то же: записи нет). }
function Take: TTCIPcm;
либо окно уже истекло (по документу это одно и то же: записи нет).
★Reserved — сколько байт бюджета УХОДИТ ВМЕСТЕ С ДАННЫМИ: копия живёт
дальше в писателе, и пока он её не отпустит, она обязана оставаться
учтённой. Вызывающий передаёт это число писателю (или возвращает сам
через TCIRecBudgetFree, если писателя не будет). }
function Take(out Reserved: Int64): TTCIPcm;
{ Окно закрылось по часам. Спрашивает тик сервера: у мёртвого приёмника
Feed не зовут вовсе, и без этого вопроса память жила бы до Stop. }
function Expired: Boolean;
@@ -225,17 +229,23 @@ type
end;
{ Писатель WAV в своём потоке: файл до 60 МБ, а зовут сохранение из тика
сервера — блокировать его на секунду диска нельзя. Данные забирает себе. }
сервера — блокировать его на секунду диска нельзя. Данные забирает себе
ВМЕСТЕ С ИХ РЕЗЕРВОМ в общем бюджете (AReserved из TTCIRecorder.Take) и
отпускает его, только когда данные больше не нужны. Пока писатель ждёт
медленный диск, эти байты остаются занятыми — так потолок и держит
очередь сохранений, а не только сами записи. }
TTCIWavWriter = class(TThread)
private
FPath: string;
FData: TTCIPcm;
FRate: Integer;
FReserved: Int64;
procedure ReleaseData;
protected
procedure Execute; override;
public
constructor Create(const APath: string; const AData: TTCIPcm;
ARateHz: Integer);
ARateHz: Integer; AReserved: Int64);
end;
{ Коэффициент прореживания SrcRate → WantRate: наибольший целый делитель,
@@ -843,11 +853,13 @@ begin
end;
end;
function TTCIRecorder.Take: TTCIPcm;
function TTCIRecorder.Take(out Reserved: Int64): TTCIPcm;
var
C, n, Left: Integer;
Copy_: Int64;
begin
Result := nil;
Result := nil;
Reserved := 0;
FLock.Enter;
try
// Проверка срока и здесь: аудио могло не идти вовсе (мьют, стоящий
@@ -857,6 +869,13 @@ begin
// Склейка кусков в один буфер — единственное копирование за всю запись, и
// оно идёт уже вне DSP-потока: SAVE вынимает рекордер из таблицы раньше,
// чем зовёт Take, так что кормить его больше некому.
// ★Копия НЕ берёт у бюджета новых денег и НЕ отпускает старых: она
// наследует резерв кусков, из которых собрана. Иначе SAVE был бы дырой в
// потолке — DropData возвращал бы куски сразу, а копия жила бы дальше в
// писателе неучтённой, и на медленном (или зависшем) сетевом каталоге
// writer-потоки копились бы без всякой границы.
Copy_ := Int64(FCount) * 2 * SizeOf(SmallInt);
if Copy_ > FBytes then Copy_ := FBytes; // не отдать больше, чем занято
SetLength(Result, FCount * 2);
Left := FCount;
for C := 0 to High(FChunks) do
@@ -867,6 +886,10 @@ begin
Move(FChunks[C][0], Result[(FCount - Left) * 2], n * 2 * SizeOf(SmallInt));
Dec(Left, n);
end;
// Из общего резерва оставляем ровно то, что перешло в копию, остальное
// (хвост последнего куска) возвращаем — этим и займётся DropData.
Reserved := Copy_;
Dec(FBytes, Copy_);
DropData; // SAVE завершает запись (§4.3)
finally
FLock.Leave;
@@ -878,16 +901,26 @@ end;
═══════════════════════════════════════════════════════════════════════════ }
constructor TTCIWavWriter.Create(const APath: string;
const AData: TTCIPcm; ARateHz: Integer);
const AData: TTCIPcm; ARateHz: Integer; AReserved: Int64);
begin
inherited Create(True);
FreeOnTerminate := True;
FPath := APath;
FData := AData;
FRate := ARateHz;
FPath := APath;
FData := AData;
FRate := ARateHz;
FReserved := AReserved;
Start;
end;
procedure TTCIWavWriter.ReleaseData;
// Данные и их место в бюджете уходят вместе и ровно один раз — сколько бы
// путей выхода ни было у Execute.
begin
FData := nil;
TCIRecBudgetFree(FReserved);
FReserved := 0;
end;
procedure TTCIWavWriter.Execute;
// Заголовок собираем в буфере: WAV — это фиксированные 44 байта, и городить
// два десятка отдельных Write ради них незачем (а строковые литералы в
@@ -954,7 +987,7 @@ begin
// клиенту уже некому: команда давно подтверждена. Молчим, но и не падаем:
// исключение из потока утащило бы за собой процесс.
end;
FData := nil;
ReleaseData;
end;
{ ═══════════════════════════════════════════════════════════════════════════