fix(tci): рекордер линейного выхода — память под потолком, файл только в своём каталоге

Два дефекта уровня P1 в LINE_OUT_RECORDER_*. Авторизации в протоколе нет
(§3.1), bind наружу разрешён — значит «сколько стоит одна строка из сети»
это вопрос живучести процесса, а не аккуратности.

1. Неограниченное выделение памяти. CmdRecorder проверял только ValidRx
   (номер в потолке), а конструктор выделял буфер целиком: 300 с × 48 кГц
   × 2 канала × int16 = 57.6 МБ на команду, приёмников 1+MAX_SLICES=7, то
   есть 403 МБ семью строками. У мёртвого приёмника Feed не зовут — значит
   срок записи никто не проверял; уход клиента рекордеры не трогал вовсе;
   DropDeadRxStreams бежит только на rfDevice/rfConnected и смене карты
   слайсов, в покое не срабатывает. Память жила до остановки сервера.

   Лечение тремя замками:
   - память набирается кусками по секунде, START не стоит ни байта;
     куски не перевыделяются (никакого realloc в DSP-потоке) и склеиваются
     один раз в Take — уже после того, как рекордер вынут из таблицы;
   - общий бюджет TCI_RECORD_MAX_BYTES (128 МБ) на все рекордеры сразу,
     спрашивается на каждый кусок; отказ не рушит запись, набранное
     остаётся сохраняемым;
   - освобождение по трём событиям: START требует живого приёмника
     (RxActive, ответ receiver is not running), тик сервера подметает
     истёкшие окна по часам (SweepRecorders — окно закрывается от START
     и без единого блока звука), уход клиента забирает его записи
     (DropClientRecorders; рекордер живёт на приёмнике, но платит за него
     тот, кто нажал START).

2. Перезапись произвольного файла. Путь из сети уходил в fmCreate почти
   как пришёл — вместе с '..' и абсолютными путями. Теперь TCIRecordPath
   берёт из строки ТОЛЬКО имя файла, каталог — настроенный tci.record_dir
   (пусто = <каталог конфигурации>/records). Каталог из просьбы
   отбрасывается молча: полный путь на сервере клиенту всё равно
   бесполезен, файл ложится не на его машину. Имя валидируется (пусто,
   '.', '..', управляющие, ':', длиннее 120, расширение не .wav →
   bad file name); после ExtractFileName выйти за каталог нечем. Файл
   создаётся эксклюзивно (TCICreateNewFile: O_EXCL|O_NOFOLLOW на Unix,
   CREATE_NEW на Windows) — ни перезаписи, ни симлинка, без окна между
   FileExists и созданием; клиенту заранее file exists.

Попутно: MainForm.ApplyTCISettings собирал TTCISettings по полям с
чистого листа — новое поле RecordDir обнулялось бы при каждом применении
вкладки CAT. В uses TCIStreams Windows стоит первым намеренно: иначе его
TCriticalSection перекрыл бы SyncObjs (та же грабля, что в DX-кластере).

Стенд 189/189 (было 169): 11 проверок имени файла (/etc/passwd.wav,
../../.., D|\rec\a.wav), бюджет (START не выделяет, растёт кусками,
потолок, отказ не рушит запись, срок истекает без Feed), существующий WAV
не перезаписывается, START на мёртвом приёмнике и с чужим номером, и
сквозная проверка в части E — клиент стартует запись, набирает память
живым звуком через WDSP, рвёт TCP, бюджет возвращается к нулю. Без фиксов
новые проверки краснеют. GUI (--ws=qt6) и демон зелёные.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-19 12:30:36 +03:00
co-authored by Claude Opus 5
parent adbb8d02ac
commit 79132f133c
7 changed files with 609 additions and 54 deletions
+3
View File
@@ -6598,6 +6598,9 @@ procedure TMainForm.ApplyTCISettings(Enabled: Boolean; Port: Integer;
var Prev, Cfg: TTCISettings;
begin
Prev := FTCICfg;
// ★От текущей конфигурации, а не с чистого листа: вкладка правит три поля,
// а в записи есть и другие (RecordDir), и сборка по полям их обнуляла бы.
Cfg := FTCICfg;
Cfg.Enabled := Enabled;
Cfg.Port := Port;
Cfg.BindAddr := BindAddr;
+9
View File
@@ -466,6 +466,12 @@ type
Enabled: Boolean;
Port: Integer; // 1..65535, умолчание 40001 (как у ExpertSDR3)
BindAddr: string; // '127.0.0.1' или '0.0.0.0'
// ★Единственный каталог, куда TCI пишет записи линейного выхода
// (LINE_OUT_RECORDER_SAVE). Пусто — <каталог конфигурации>/records.
// Имя файла клиент выбирает сам, но КАТАЛОГ из его строки не берётся
// никогда: авторизации в протоколе нет, и полный путь из сети означал бы
// запись в любой доступный процессу файл (см. TCIRecordPath).
RecordDir: string;
end;
// Transverter (XVTR) entry — один трансвертер.
@@ -2999,6 +3005,7 @@ begin
T.Enabled := False; // включается осознанно, как CAT-транспорты
T.Port := 40001;
T.BindAddr := '127.0.0.1';
T.RecordDir := ''; // пусто = <каталог конфигурации>/records
end;
procedure TSettingsManager.LoadTCISettings(out T: TTCISettings);
@@ -3010,6 +3017,7 @@ begin
T.Enabled := JB(O, 'enabled', False);
T.Port := EnsureRange(JI(O, 'port', 40001), 1, 65535);
T.BindAddr := JS(O, 'bind_addr', '127.0.0.1');
T.RecordDir := JS(O, 'record_dir', '');
end;
procedure TSettingsManager.SaveTCISettings(const T: TTCISettings);
@@ -3019,6 +3027,7 @@ begin
JW(O, 'enabled', T.Enabled);
JW(O, 'port', T.Port);
JWS(O, 'bind_addr', T.BindAddr);
JWS(O, 'record_dir', T.RecordDir);
Save;
end;
+105 -9
View File
@@ -43,7 +43,7 @@ interface
uses
Classes, SysUtils, DateUtils, Math, SyncObjs,
RadioController, RadioBackend, WDSPEngine, Settings,
RadioController, RadioBackend, WDSPEngine, Settings, PlatformUtils,
DXSpotStore, TCIProtocol, TCIServer, TCIStreams;
const
@@ -236,6 +236,9 @@ type
procedure PushTxChrono; // тик: маркеры времени клиенту
procedure CmdStream(Client: TTCIClient; const M: TTCIMessage);
procedure CmdRecorder(Client: TTCIClient; const M: TTCIMessage);
procedure DropClientRecorders(C: TTCIClient); // клиент ушёл — и запись с ним
procedure SweepRecorders; // тик: истёкшие окна записи
function RecordDir: string; // каталог, куда пишем WAV
procedure ClearTxClient(C: TTCIClient); // клиент ушёл/перестал модулировать
procedure StopTxOf(C: TTCIClient); // ★снять эфир, начатый этим клиентом
procedure ForgetTxOwner; // передача кончилась не по TCI
@@ -1329,6 +1332,7 @@ procedure TTCIAdapter.HandleDisconnect(Client: TTCIClient);
begin
DropHolds(Client);
DropClientStreams(Client);
DropClientRecorders(Client);
ClearTxClient(Client);
// ★И только теперь — сама передача: если в эфир нас поставил именно этот
// клиент, снимаем MOX. Оставить включённый передатчик за ушедшим клиентом
@@ -2640,6 +2644,7 @@ begin
try
FServer.EnumClients(PushSensors);
PushTxChrono;
SweepRecorders;
except
// молча: следующий тик через 20 мс попробует снова
end;
@@ -2992,7 +2997,7 @@ end;
procedure TTCIAdapter.CmdRecorder(Client: TTCIClient; const M: TTCIMessage);
var
Rx, Sec, Rate: Integer;
Path: string;
Path, Req: string;
Data: TTCIPcm;
Old, New_: TTCIRecorder;
begin
@@ -3004,11 +3009,21 @@ begin
if M.Name = 'LINE_OUT_RECORDER_START' then
begin
// ★Только ЖИВОЙ приёмник. Раньше хватало номера в потолке, а у мёртвого
// приёмника Feed не зовут вовсе — значит и срок записи никто не проверял:
// рекордер висел до перезапуска сервера. Отвечаем как потокам.
if not RxActive(Rx) then
begin
Reply(Client, TCIBuild('tci_error',
[LowerCase(M.Name), 'receiver is not running']));
Exit;
end;
if not TCITryArgInt(M, 1, Sec) then Sec := TCI_RECORD_MAX_SEC;
Sec := EnsureRange(Sec, 1, TCI_RECORD_MAX_SEC);
// Кольцо заводим ДО лока: на предельных 300 с это 57 МБ, и выделять их
// под локом, которого ждёт DSP-поток, значит уронить звук на десятки мс.
New_ := TTCIRecorder.Create(Rx, TCI_AUDIO_ENGINE_RATE, Sec);
// Объект заводим ДО лока — не ради памяти (её он больше не выделяет, см.
// TTCIRecorder), а чтобы не звать чужой конструктор под локом DSP-потока.
// Владельца помним: уйдёт клиент — уйдёт и его запись.
New_ := TTCIRecorder.Create(Rx, TCI_AUDIO_ENGINE_RATE, Sec, Client);
FStreamLock.Enter;
try
// Рекордер один на приёмник, а не на клиента: пишет он то, что слышно
@@ -3036,20 +3051,35 @@ begin
end;
// LINE_OUT_RECORDER_SAVE
Path := TCIRecordPath(TCIUnescape(TCIArg(M, 1)));
if Path = '' then
Req := TCIUnescape(TCIArg(M, 1));
if Req = '' then
begin
Reply(Client, TCIBuild('tci_error', [LowerCase(M.Name), 'no file name']));
Exit;
end;
// MP3 у нас кодировать нечем — молча подсунуть WAV с расширением .mp3 хуже,
// чем сказать правду: клиент такой файл всё равно не откроет.
if SameText(ExtractFileExt(Path), '.mp3') then
// чем сказать правду: клиент такой файл всё равно не откроет. Проверяем до
// TCIRecordPath, чтобы у самой частой причины отказа был свой текст.
if SameText(ExtractFileExt(Req), '.mp3') then
begin
Reply(Client, TCIBuild('tci_error',
[LowerCase(M.Name), 'only wav is supported']));
Exit;
end;
// ★Каталог всегда наш, из просьбы берётся одно имя файла — см. TCIRecordPath.
Path := TCIRecordPath(RecordDir, Req);
if Path = '' then
begin
Reply(Client, TCIBuild('tci_error', [LowerCase(M.Name), 'bad file name']));
Exit;
end;
// Существующий файл не трогаем (окончательно это решит O_EXCL в писателе,
// здесь — только чтобы клиент услышал причину).
if FileExists(Path) then
begin
Reply(Client, TCIBuild('tci_error', [LowerCase(M.Name), 'file exists']));
Exit;
end;
// Забираем рекордер из таблицы (сохранение завершает запись, §4.3) и только
// потом снимаем с него данные: копия кольца — это десятки мегабайт, и делать
@@ -3079,6 +3109,72 @@ begin
TTCIWavWriter.Create(Path, Data, Rate);
end;
procedure TTCIAdapter.DropClientRecorders(C: TTCIClient);
// ★Клиент ушёл — забираем его записи. Рекордер живёт на приёмнике, а не на
// клиенте (второй такой же был бы копией памяти), но платит за него тот, кто
// нажал START: без этого пары «подключился, START, отключился» набивали память
// до потолка бюджета, и вернуть её было некому до перезапуска сервера.
var
Rx: Integer;
Dead: array[0..TCI_MAX_RX-1] of TTCIRecorder;
begin
if C = nil then Exit;
FStreamLock.Enter;
try
for Rx := 0 to TCI_MAX_RX - 1 do
begin
Dead[Rx] := nil;
if (FRec[Rx] <> nil) and (FRec[Rx].Owner = TObject(C)) then
begin
Dead[Rx] := FRec[Rx];
FRec[Rx] := nil;
end;
end;
finally
FStreamLock.Leave;
end;
// Освобождаем вне лока: его ждёт DSP-поток, а деструктор трогает свой лок.
for Rx := 0 to TCI_MAX_RX - 1 do Dead[Rx].Free;
end;
procedure TTCIAdapter.SweepRecorders;
// Тик сервера. ★Срок записи раньше проверял только Feed, то есть DSP-поток —
// а он к рекордеру замьюченного, молчащего или пропавшего приёмника не приходит
// вовсе. Окно закрывается по ЧАСАМ от START (§4.3), значит и спрашивать про
// него надо по часам, а не по звуку.
var
Rx: Integer;
Dead: array[0..TCI_MAX_RX-1] of TTCIRecorder;
begin
FStreamLock.Enter;
try
for Rx := 0 to TCI_MAX_RX - 1 do
begin
Dead[Rx] := nil;
if (FRec[Rx] <> nil) and FRec[Rx].Expired then
begin
Dead[Rx] := FRec[Rx];
FRec[Rx] := nil;
end;
end;
finally
FStreamLock.Leave;
end;
for Rx := 0 to TCI_MAX_RX - 1 do Dead[Rx].Free;
end;
function TTCIAdapter.RecordDir: string;
// Каталог записей: настройка, а пусто — <каталог конфигурации>/records.
// Каталог создаём здесь же: писатель работает в своём потоке и сказать об
// отсутствии каталога ему уже некому.
begin
Result := Trim(FCfg.RecordDir);
if Result = '' then Result := GetAppCfgDir + 'records';
Result := IncludeTrailingPathDelimiter(Result);
if not DirectoryExists(Result) then
if not ForceDirectories(Result) then Result := '';
end;
procedure TTCIAdapter.ClearTxClient(C: TTCIClient);
// C = nil — снять кого угодно (остановка сервера).
var Drop: Boolean;
+11
View File
@@ -58,6 +58,17 @@ const
TCI_TX_BUFFERING_MIN = 50;
TCI_TX_BUFFERING_MAX = 500;
TCI_RECORD_MAX_SEC = 300; // потолок записи линейного выхода
// ★Общий потолок памяти ВСЕХ рекордеров сразу. Приёмников у нас
// 1 + MAX_SLICES, и предельные 300 с на каждом — это 57.6 МБ × 7 ≈ 403 МБ,
// которые неавторизованный клиент выпрашивал бы семью строками. Бюджет
// общий на адаптер, спрашивается при выделении КАЖДОГО куска (см.
// TTCIRecorder): 128 МБ — это две полных записи предельной длины, больше
// одновременно не нужно никому.
TCI_RECORD_MAX_BYTES = Int64(128) * 1024 * 1024;
// Кусок кольца записи — секунда звука (48000 × 2 канала × int16 = 192 КБ).
// Выделяем их по мере набора: команда START больше не стоит ни байта, а
// молчащий или мёртвый приёмник не стоит ничего вовсе.
TCI_RECORD_CHUNK_SEC = 1;
TCI_VOL_MIN_DB = -60; TCI_VOL_MAX_DB = 0;
TCI_SQL_MIN_DB = -140; TCI_SQL_MAX_DB = 0;
+240 -29
View File
@@ -32,6 +32,11 @@ unit TCIStreams;
interface
uses
// ★Windows идёт ПЕРВЫМ намеренно: он объявляет свой TCriticalSection (запись,
// а не класс), и стоя последним перекрыл бы SyncObjs — на win64 это уже
// ловилось в DX-кластере. Порядок здесь и есть лечение.
{$IFDEF WINDOWS}Windows,{$ENDIF}
{$IFDEF UNIX}BaseUnix,{$ENDIF}
Classes, SysUtils, Math, SyncObjs, TCIProtocol, TCIServer;
const
@@ -179,26 +184,44 @@ type
Срок считаем по ЧАСАМ, а не по накопленным сэмплам: линейный выход молчит
(мьют, пауза приёмника), а время записи всё равно идёт. }
{ ★Память набирается КУСКАМИ по мере записи, а не вся сразу на START.
Раньше конструктор выделял MaxSec × 48 кГц × 2 канала × int16 до 57.6 МБ
на одну строку из сети; семь приёмников давали 403 МБ, и удержать их мог
любой клиент (авторизации в TCI нет). Теперь START не стоит ни байта:
молчащий, мёртвый или замьюченный приёмник не занимает ничего, а растущая
запись спрашивает разрешения на каждый кусок у общего бюджета
TCI_RECORD_MAX_BYTES. Куски не перевыделяются (никакого realloc в
DSP-потоке) и склеиваются один раз, в Take а он идёт уже после того, как
рекордер вынут из таблицы, то есть без DSP-потока на плечах. }
TTCIRecorder = class
private
FLock: TCriticalSection;
FBuf: array of SmallInt; // чередование L/R
FCap: Integer; // ёмкость в сэмплах на канал
FChunks: array of TTCIPcm; // куски по FChunk сэмплов на канал, L/R вперемешку
FChunk: Integer; // сэмплов на канал в куске
FCap: Integer; // потолок в сэмплах на канал
FCount: Integer; // накоплено сэмплов на канал
FBytes: Int64; // занято под FChunks (столько же взято у бюджета)
FRate: Integer;
FRx: Integer;
FOwner: TObject; // клиент, попросивший START (для его ухода)
FExpired: Boolean; // окно записи закрылось, данные удалены
FStarved: Boolean; // бюджет не дал расти — пишем сколько влезло
FEndsAt: QWord; // GetTickCount64 конца окна
procedure DropData; // под FLock
function EnsureRoom: Boolean; // под FLock: место под ещё один сэмпл
public
constructor Create(ARx, ARateHz, AMaxSec: Integer);
constructor Create(ARx, ARateHz, AMaxSec: Integer; AOwner: TObject);
destructor Destroy; override;
procedure Feed(const L, R: array of Single; N: Integer);
{ Забрать накопленное и завершить запись. nil либо не записано ничего,
либо окно уже истекло (по документу это одно и то же: записи нет). }
function Take: TTCIPcm;
{ Окно закрылось по часам. Спрашивает тик сервера: у мёртвого приёмника
Feed не зовут вовсе, и без этого вопроса память жила бы до Stop. }
function Expired: Boolean;
property Rx: Integer read FRx;
property Rate: Integer read FRate;
property Owner: TObject read FOwner;
end;
{ Писатель WAV в своём потоке: файл до 60 МБ, а зовут сохранение из тика
@@ -229,9 +252,33 @@ function TCIDecimFactor(SrcRate, WantRate: Integer): Integer;
TCIDecimFactor, а настоящая частота уйдёт в заголовке блока. }
function TCIPickIQRate(SrcRate, WantRate: Integer): Integer;
{ Путь из LINE_OUT_RECORDER_SAVE в путь файловой системы: в протоколе ':'
запрещён и заменён на '|' (§4.3), слэши допускаются любые. }
function TCIRecordPath(const S: string): string;
{ ═══ Общий бюджет памяти рекордеров ═══════════════════════════════════════
Взять/вернуть можно из любого потока (Take зовёт DSP-поток, Free тик,
поток клиента и поток контроллера). TCIRecBudgetTake возвращает False, если
запрошенное не влезает в TCI_RECORD_MAX_BYTES. }
function TCIRecBudgetTake(Bytes: Int64): Boolean;
procedure TCIRecBudgetFree(Bytes: Int64);
function TCIRecBudgetUsed: Int64; // для стенда и диагностики
{ ═══ Имя файла записи ═════════════════════════════════════════════════════
★Из строки LINE_OUT_RECORDER_SAVE берётся ТОЛЬКО имя файла, а каталог
всегда настроенный каталог записей. Причина простая: авторизации в TCI нет
(§3.1), а bind наружу разрешён, поэтому любой, кто дотянулся до порта, писал
бы файлы от имени EWSDR куда угодно путь уходил в fmCreate почти как
пришёл, вместе с '..' и абсолютными путями. Документ (§4.3) в примерах даёт
полный путь, но полный путь на СЕРВЕРЕ клиенту всё равно бесполезен: файл
ложится не на его машину, а на нашу. Поэтому каталог из просьбы отбрасываем
молча (клиент получает разумный файл, а не ошибку), а имя проверяем: пусто,
'.', '..', управляющие символы и слишком длинное отказ; расширение обязано
быть .wav (кодировать MP3 нам нечем). Выйти за каталог после ExtractFileName
нечем разделителей в имени уже не осталось.
Result = '' имя не годится; иначе полный путь внутри BaseDir. }
function TCIRecordPath(const BaseDir, Req: string): string;
{ Создать файл, НЕ перезаписывая существующий и не идя по симлинку. Отдельно
от TFileStream: fmCreate молча затирает то, что уже лежит по этому пути, а
имя приходит из сети. THandle(-1) не вышло. }
function TCICreateNewFile(const Path: string): THandle;
implementation
@@ -676,18 +723,24 @@ end;
Рекордер линейного выхода
═══════════════════════════════════════════════════════════════════════════ }
constructor TTCIRecorder.Create(ARx, ARateHz, AMaxSec: Integer);
constructor TTCIRecorder.Create(ARx, ARateHz, AMaxSec: Integer; AOwner: TObject);
begin
inherited Create;
FLock := TCriticalSection.Create;
FRx := ARx;
FRate := ARateHz;
FOwner := AOwner;
if AMaxSec < 1 then AMaxSec := 1;
if AMaxSec > TCI_RECORD_MAX_SEC then AMaxSec := TCI_RECORD_MAX_SEC;
FCap := ARateHz * AMaxSec;
SetLength(FBuf, FCap * 2);
FChunk := ARateHz * TCI_RECORD_CHUNK_SEC;
if FChunk < 1 then FChunk := 1;
if FChunk > FCap then FChunk := FCap;
// ★Ни одного байта под звук здесь не выделяется — см. шапку объявления.
FCount := 0;
FBytes := 0;
FExpired := False;
FStarved := False;
// Окно открывается прямо здесь: клиент отсчитывает его от своей команды
// START, и ждать первого блока аудио, чтобы завести часы, нельзя.
FEndsAt := GetTickCount64 + QWord(AMaxSec) * 1000;
@@ -695,23 +748,66 @@ end;
destructor TTCIRecorder.Destroy;
begin
// Бюджет возвращаем и на аварийном пути: рекордер освобождают и по уходу
// клиента, и по исчезновению приёмника, и на Stop — DropData зовут не все.
TCIRecBudgetFree(FBytes);
FBytes := 0;
FLock.Free;
inherited;
end;
function TTCIRecorder.EnsureRoom: Boolean;
// Под FLock. True — в последнем куске есть место хотя бы под один сэмпл.
var
Need: Int64;
N: Integer;
begin
Result := False;
if FCount >= FCap then Exit; // набрали свои MaxSec
N := Length(FChunks);
if FCount < N * FChunk then Exit(True); // место в текущем куске
if FStarved then Exit; // бюджет уже отказал — не долбим его
Need := Int64(FChunk) * 2 * SizeOf(SmallInt);
if not TCIRecBudgetTake(Need) then
begin
// Отказ не рушит запись: то, что успели набрать, остаётся сохраняемым.
FStarved := True;
Exit;
end;
SetLength(FChunks, N + 1);
SetLength(FChunks[N], FChunk * 2);
Inc(FBytes, Need);
Result := True;
end;
procedure TTCIRecorder.DropData;
// Под FLock. Память отдаём сразу: истёкшая запись на 300 с держала бы 57 МБ
// до тех пор, пока клиент не вспомнит про BREAK.
var i: Integer;
begin
FCount := 0;
FExpired := True;
SetLength(FBuf, 0);
for i := 0 to High(FChunks) do FChunks[i] := nil;
FChunks := nil;
TCIRecBudgetFree(FBytes);
FBytes := 0;
end;
function TTCIRecorder.Expired: Boolean;
begin
FLock.Enter;
try
if (not FExpired) and (GetTickCount64 >= FEndsAt) then DropData;
Result := FExpired;
finally
FLock.Leave;
end;
end;
procedure TTCIRecorder.Feed(const L, R: array of Single; N: Integer);
// DSP-поток. Пишем линейно до конца окна; истекло — данных больше нет (§4.3).
var
i: Integer;
i, j, C: Integer;
A, B: Single;
begin
if (N <= 0) or (FCap <= 0) then Exit;
@@ -730,13 +826,18 @@ begin
if N > FCap - FCount then N := FCap - FCount;
for i := 0 to N - 1 do
begin
// Место спрашиваем на каждый сэмпл: кусок мог кончиться посередине
// блока, а бюджет — отказать (тогда дописываем ровно до его границы).
if not EnsureRoom then Break;
A := L[i]; B := R[i];
if A > 1.0 then A := 1.0; if A < -1.0 then A := -1.0;
if B > 1.0 then B := 1.0; if B < -1.0 then B := -1.0;
FBuf[(FCount + i) * 2] := Round(A * 32767);
FBuf[(FCount + i) * 2 + 1] := Round(B * 32767);
C := FCount div FChunk;
j := (FCount mod FChunk) * 2;
FChunks[C][j] := Round(A * 32767);
FChunks[C][j + 1] := Round(B * 32767);
Inc(FCount);
end;
Inc(FCount, N);
finally
FLock.Leave;
end;
@@ -744,7 +845,7 @@ end;
function TTCIRecorder.Take: TTCIPcm;
var
i: Integer;
C, n, Left: Integer;
begin
Result := nil;
FLock.Enter;
@@ -753,8 +854,19 @@ begin
// приёмник), и тогда Feed часы не смотрел ни разу.
if (not FExpired) and (GetTickCount64 >= FEndsAt) then DropData;
if FExpired or (FCount <= 0) then Exit;
// Склейка кусков в один буфер — единственное копирование за всю запись, и
// оно идёт уже вне DSP-потока: SAVE вынимает рекордер из таблицы раньше,
// чем зовёт Take, так что кормить его больше некому.
SetLength(Result, FCount * 2);
for i := 0 to FCount * 2 - 1 do Result[i] := FBuf[i];
Left := FCount;
for C := 0 to High(FChunks) do
begin
if Left <= 0 then Break;
n := FChunk;
if n > Left then n := Left;
Move(FChunks[C][0], Result[(FCount - Left) * 2], n * 2 * SizeOf(SmallInt));
Dec(Left, n);
end;
DropData; // SAVE завершает запись (§4.3)
finally
FLock.Leave;
@@ -781,7 +893,8 @@ procedure TTCIWavWriter.Execute;
// два десятка отдельных Write ради них незачем (а строковые литералы в
// нетипизированный Write в FPC ещё и передаются не тем, чем кажется).
var
FS: TFileStream;
FS: THandleStream;
H: THandle;
Hdr: array[0..43] of Byte;
DataBytes: LongWord;
@@ -820,12 +933,21 @@ begin
PutTag(36, 'data');
PutU32(40, DataBytes);
FS := TFileStream.Create(FPath, fmCreate);
// ★Не TFileStream/fmCreate: имя пришло из сети, и затирать им чужой файл
// нельзя. TCICreateNewFile создаёт только новый и не идёт по симлинку.
H := TCICreateNewFile(FPath);
// Не вышло (файл уже есть, нет прав, нет каталога) — просто уходим: выйти
// отсюда через Exit нельзя, ниже ещё возврат памяти под данные.
if H <> THandle(-1) then
begin
FS := THandleStream.Create(H);
try
FS.Write(Hdr[0], SizeOf(Hdr));
if DataBytes > 0 then FS.Write(FData[0], DataBytes);
finally
FS.Free;
FileClose(H);
end;
end;
except
// Записать не вышло (нет прав, нет каталога, диск полон) — сказать об этом
@@ -867,19 +989,108 @@ begin
Exit(LEGAL[i]);
end;
function TCIRecordPath(const S: string): string;
var i: Integer;
{ ═══════════════════════════════════════════════════════════════════════════
Бюджет памяти рекордеров
═══════════════════════════════════════════════════════════════════════════ }
var
RecBudgetLock: TCriticalSection = nil;
RecBudgetUsed: Int64 = 0;
function TCIRecBudgetTake(Bytes: Int64): Boolean;
begin
Result := S;
for i := 1 to Length(Result) do
if Result[i] = '|' then Result[i] := ':';
{$IFDEF WINDOWS}
for i := 1 to Length(Result) do
if Result[i] = '/' then Result[i] := '\';
{$ELSE}
for i := 1 to Length(Result) do
if Result[i] = '\' then Result[i] := '/';
{$ENDIF}
Result := False;
if Bytes <= 0 then Exit(True);
if RecBudgetLock = nil then Exit;
RecBudgetLock.Enter;
try
if RecBudgetUsed + Bytes > TCI_RECORD_MAX_BYTES then Exit;
Inc(RecBudgetUsed, Bytes);
Result := True;
finally
RecBudgetLock.Leave;
end;
end;
procedure TCIRecBudgetFree(Bytes: Int64);
begin
if (Bytes <= 0) or (RecBudgetLock = nil) then Exit;
RecBudgetLock.Enter;
try
Dec(RecBudgetUsed, Bytes);
if RecBudgetUsed < 0 then RecBudgetUsed := 0;
finally
RecBudgetLock.Leave;
end;
end;
function TCIRecBudgetUsed: Int64;
begin
Result := 0;
if RecBudgetLock = nil then Exit;
RecBudgetLock.Enter;
try
Result := RecBudgetUsed;
finally
RecBudgetLock.Leave;
end;
end;
{ ═══════════════════════════════════════════════════════════════════════════
Имя файла записи
═══════════════════════════════════════════════════════════════════════════ }
function TCIRecordPath(const BaseDir, Req: string): string;
const
MAX_NAME = 120;
var
S, Name: string;
i: Integer;
begin
Result := '';
if BaseDir = '' then Exit;
S := Req;
// ':' в протоколе запрещён и заменён на '|' (§4.3) — возвращаем на место,
// иначе 'D|\rec\a.wav' стало бы каталогом с именем 'D|'. Слэши допускаются
// любые, приводим к своему: без этого ExtractFileName на Linux не увидит
// разделителя в 'D:\rec\a.wav' и примет всю строку за имя файла.
for i := 1 to Length(S) do
if S[i] = '|' then S[i] := ':';
for i := 1 to Length(S) do
if (S[i] = '/') or (S[i] = '\') then S[i] := PathDelim;
// ★Каталог из просьбы отбрасываем целиком — вместе с '..', абсолютным путём
// и буквой диска. Именно здесь закрывается запись в произвольный файл.
Name := ExtractFileName(S);
if (Name = '') or (Name = '.') or (Name = '..') then Exit;
if Length(Name) > MAX_NAME then Exit;
for i := 1 to Length(Name) do
if (Name[i] < ' ') or (Name[i] = ':') then Exit;
// Пишем мы только WAV, а расширение — единственное, по чему клиент потом
// узнает файл. Чужое расширение здесь честнее молчаливой подмены.
if not SameText(ExtractFileExt(Name), '.wav') then Exit;
Result := IncludeTrailingPathDelimiter(BaseDir) + Name;
end;
function TCICreateNewFile(const Path: string): THandle;
begin
{$IFDEF UNIX}
// O_EXCL — не трогать существующий файл, O_NOFOLLOW — не идти по симлинку,
// подложенному вместо него. Обе проверки делает ядро одним вызовом: FileExists
// перед FileCreate оставлял бы окно между проверкой и созданием.
Result := THandle(FpOpen(PChar(Path),
O_WRONLY or O_CREAT or O_EXCL or O_NOFOLLOW, &644));
{$ELSE}
// CREATE_NEW = «создать, только если файла нет»; симлинки в Windows без прав
// администратора не создаются, отдельной защиты от них не нужно.
Result := CreateFileW(PWideChar(UnicodeString(Path)), GENERIC_WRITE, 0, nil,
CREATE_NEW, FILE_ATTRIBUTE_NORMAL, 0);
{$ENDIF}
end;
initialization
RecBudgetLock := TCriticalSection.Create;
finalization
FreeAndNil(RecBudgetLock);
end.
+64 -2
View File
@@ -136,9 +136,16 @@ TCI-клиенты ──WebSocket──► TTCIServer ──► TTCIAdapter ─
Секция `tci` в корне `settings.json`:
```json
"tci": { "enabled": false, "port": 40001, "bind_addr": "127.0.0.1" }
"tci": { "enabled": false, "port": 40001, "bind_addr": "127.0.0.1",
"record_dir": "" }
```
`record_dir` — единственный каталог, куда TCI пишет записи линейного выхода;
пусто = `<каталог конфигурации>/records`, каталог создаётся при первом `SAVE`.
Поля в UI у него нет намеренно: это не рабочая настройка, а граница, и менять
её осмысленно только руками в файле. Почему каталог вообще существует и почему
путь из команды в него не попадает — §2.5, «Имя файла: каталог всегда наш».
UI — вкладка **CAT → TCI Server**, справа от «TCP CAT Server» (галка, порт,
интерфейс): TCI — такой же канал внешнего управления трансивером, что и CAT,
и оператор ищет его там, а не в «Advanced». В протоколе
@@ -506,6 +513,50 @@ ExpertSDR3 давно бы не было.
это время не читает свой сокет. MP3 не поддержан — кодера в проекте нет,
и на `.mp3` уходит честный `tci_error`.
**★Память рекордера — три замка, и все три нужны.** Авторизации в протоколе
нет (§3.1), поэтому «сколько памяти займёт одна строка из сети» — это вопрос
не об аккуратности, а о живучести процесса. Раньше `START` выделял буфер
целиком: 300 с × 48 кГц × 2 канала × int16 = 57.6 МБ на команду, а приёмников
1 + `MAX_SLICES` = 7, то есть 403 МБ семью строками, и держать их можно было
сколько угодно.
1. **Память набирается кусками по секунде, а не вся сразу.** `START` не стоит
ни байта; молчащий, замьюченный или просто не звучащий приёмник не стоит
ничего вовсе. Куски не перевыделяются (никакого `realloc` в DSP-потоке) и
склеиваются один раз — в `Take`, а он идёт уже после того, как рекордер
вынут из таблицы, то есть без DSP-потока на плечах.
2. **Общий бюджет `TCI_RECORD_MAX_BYTES` (128 МБ) на все рекордеры сразу.**
Спрашивается при выделении каждого куска. Отказ не рушит запись: набранное
остаётся сохраняемым, просто дальше она не растёт — иначе клиент терял бы
уже записанное из-за чужой записи на другом приёмнике.
3. **Освобождение по трём событиям, а не по одному.** Раньше срок проверял
только `Feed`, то есть DSP-поток — а к мёртвому приёмнику он не приходит
никогда, и `START` на несуществующий номер оставлял память навсегда.
Теперь: `START` требует **живого** приёмника (`RxActive`, ответ
`receiver is not running` — как у потоков); тик сервера подметает истёкшие
окна по часам (`SweepRecorders`, окно закрывается от `START` и без единого
блока звука); уход клиента забирает его записи (`DropClientRecorders`
рекордер живёт на приёмнике, но платит за него тот, кто нажал `START`, иначе
пары «подключился, `START`, отключился» набивали бы бюджет до потолка).
**★Имя файла: каталог всегда наш.** `LINE_OUT_RECORDER_SAVE` в §4.3 принимает
«полное имя файла», и раньше оно почти без изменений уходило в `fmCreate`
то есть любой, кто дотянулся до порта (а bind наружу разрешён), писал файлы от
имени EWSDR куда угодно и затирал существующие. Теперь из строки берётся
**только имя файла** (`TCIRecordPath`), а каталог — настроенный каталог
записей (`tci.record_dir`, пусто = `<каталог конфигурации>/records`). Полный
путь на СЕРВЕРЕ клиенту всё равно бесполезен: файл ложится не на его машину, а
на нашу, поэтому каталог из просьбы отбрасывается молча — клиент получает
разумный файл, а не ошибку. Само имя проверяется: пусто, `.`, `..`,
управляющие символы, `:`, длиннее 120 и расширение не `.wav` — отказ
(`bad file name`). Выйти за каталог после `ExtractFileName` нечем:
разделителей в имени уже не осталось, а `..` не проходит проверку. Файл
создаётся **эксклюзивно** (`TCICreateNewFile`: `O_EXCL or O_NOFOLLOW` на Unix,
`CREATE_NEW` на Windows) — существующий не перезаписывается и симлинк не
уводит наружу, причём одним вызовом ядра, без окна между `FileExists` и
созданием. Клиенту про уже занятое имя отвечаем `file exists` до постановки
задачи писателю.
**Потолок кадра.** Приёмный буфер соединения (`WsClient.WS_BUF_SIZE`) поднят
с 4 до 32 КБ: блок TX-аудио — это 64 байта заголовка плюс `data[16384]`, а
кадр крупнее буфера не собирается никогда (BufLen упирается в потолок и разбор
@@ -806,11 +857,22 @@ ExpertSDR3 давно бы не было.
- **Рекордер и WAV:** буфер ограничен запрошенным временем и пишется с начала
окна (переполнение отбрасывает новое, а не затирает старое), до срока запись
есть, после срока её нет и истёкший рекордер не оживает, `Take` завершает
запись, файл получает верные RIFF/fmt/data и длину.
запись, файл получает верные RIFF/fmt/data и длину. ★Память: `START` не
выделяет ни байта, бюджет растёт кусками по мере звука и возвращается по
`Free`, потолок берётся целиком и сверх него следует отказ, при отказе запись
не рушится, окно истекает по часам **без единого `Feed`** и отдаёт память
само. ★Имя файла: простое имя ложится в каталог записей, а каталог из
просьбы отбрасывается — абсолютный путь, `..` и буква диска наружу не
выводят; пусто, `..`, не-`.wav`, управляющий символ и отсутствие каталога
записей дают отказ; существующий файл писатель не перезаписывает.
- **Команды на живом сервере** (настоящий `TRadioController`, WS-клиент на
сыром сокете): отказ на несуществующий приёмник и на нечисловой аргумент,
отказ на старт потока с незапущенного пана, подтверждение и отбраковка
параметров, `SAVE` без записи и `SAVE` в `.mp3` отвечают ошибкой,
`LINE_OUT_RECORDER_START` на мёртвом приёмнике и с чужим номером отвечает
ошибкой и **не стоит памяти**, `SAVE` с чужим путём отбивается по имени;
запись, начатая ушедшим клиентом, освобождается вместе с ним (видно насквозь
по общему бюджету в части E);
`TRX:0,true,tci` без аудиопотока модуляцию не берёт, а с потоком берёт,
реально поднимает передачу и снимает её по `TRX:0,false` и по уходу клиента;
чужой бинарный блок не рвёт соединение; без передачи маркеров `TX_CHRONO`
+168 -5
View File
@@ -72,6 +72,7 @@ var
Buf: array[0..63] of Byte;
N, i: Integer;
T: TTCISampleType;
Base: string;
begin
WriteLn('A. Протокол потоков');
@@ -140,7 +141,37 @@ begin
Check('клип int16 +', SmallInt(Word(Buf[0]) or (Word(Buf[1]) shl 8)) = 32767);
Check('клип int16 ', SmallInt(Word(Buf[2]) or (Word(Buf[3]) shl 8)) = -32767);
Check('путь | → :', TCIRecordPath('home|user/rec.wav') = 'home:user/rec.wav');
// ★Имя файла записи: каталог из просьбы клиента не берётся НИКОГДА —
// авторизации в TCI нет, и полный путь из сети означал бы запись в любой
// доступный процессу файл. Берём одно имя и кладём в свой каталог.
Base := IncludeTrailingPathDelimiter(GetTempDir) + 'tcirec';
Check('путь: простое имя',
TCIRecordPath(Base, 'rec.wav') =
IncludeTrailingPathDelimiter(Base) + 'rec.wav',
TCIRecordPath(Base, 'rec.wav'));
Check('путь: каталог из просьбы отброшен',
TCIRecordPath(Base, 'home/user/rec_dir/a.wav') =
IncludeTrailingPathDelimiter(Base) + 'a.wav',
TCIRecordPath(Base, 'home/user/rec_dir/a.wav'));
Check('путь: абсолютный не выводит наружу',
TCIRecordPath(Base, '/etc/passwd.wav') =
IncludeTrailingPathDelimiter(Base) + 'passwd.wav',
TCIRecordPath(Base, '/etc/passwd.wav'));
Check('путь: .. не выводит наружу',
TCIRecordPath(Base, '../../../home/vladimir/.bashrc.wav') =
IncludeTrailingPathDelimiter(Base) + '.bashrc.wav',
TCIRecordPath(Base, '../../../home/vladimir/.bashrc.wav'));
Check('путь: буква диска (| → :) не выводит наружу',
TCIRecordPath(Base, 'D|\\rec\\a.wav') =
IncludeTrailingPathDelimiter(Base) + 'a.wav',
TCIRecordPath(Base, 'D|\\rec\\a.wav'));
Check('путь: пусто — отказ', TCIRecordPath(Base, '') = '');
Check('путь: только каталог — отказ', TCIRecordPath(Base, 'a/b/') = '');
Check('путь: «..» — отказ', TCIRecordPath(Base, '..') = '');
Check('путь: не .wav — отказ', TCIRecordPath(Base, 'a.mp3') = '');
Check('путь: управляющий символ — отказ',
TCIRecordPath(Base, 'a' + #10 + 'b.wav') = '');
Check('путь: без каталога — отказ', TCIRecordPath('', 'a.wav') = '');
// Частоты Pluto (576…5760 кГц) на 384 делятся не все: клиенту обязана
// достаться ЗАКОННАЯ частота из набора протокола, а не 576/480 кГц.
@@ -531,12 +562,13 @@ var
Sz: LongWord;
W: TTCIWavWriter;
Waited: Integer;
Was: Int64;
begin
WriteLn('C. Рекордер линейного выхода');
// Максимум записи — 1 секунда: подаём полторы, лишнее не берём. ★Именно НЕ
// берём: окно записи по §4.3 начинается со START, а не «последняя секунда».
Rec := TTCIRecorder.Create(0, 48000, 1);
Rec := TTCIRecorder.Create(0, 48000, 1, nil);
try
Total := 0;
for i := 0 to 8191 do begin L[i] := 0.25; R[i] := -0.25; end;
@@ -556,7 +588,7 @@ begin
end;
// Порядок отсчётов: первым обязан идти самый ПЕРВЫЙ записанный.
Rec := TTCIRecorder.Create(0, 1000, 1); // ёмкость 1000 отсчётов
Rec := TTCIRecorder.Create(0, 1000, 1, nil); // ёмкость 1000 отсчётов
try
for i := 0 to 1499 do
begin
@@ -575,7 +607,7 @@ begin
// ★Истечение срока: по документу «по истечении времени запись удаляется».
// Секунду ждать незачем — окно берём минимальное и смотрим на часы.
Rec := TTCIRecorder.Create(0, 48000, 1);
Rec := TTCIRecorder.Create(0, 48000, 1, nil);
try
for i := 0 to 8191 do begin L[i] := 0.25; R[i] := -0.25; end;
Rec.Feed(L, R, 8192);
@@ -583,7 +615,7 @@ begin
finally
Rec.Free;
end;
Rec := TTCIRecorder.Create(0, 48000, 1);
Rec := TTCIRecorder.Create(0, 48000, 1, nil);
try
Rec.Feed(L, R, 8192);
Sleep(1100); // окно закрылось
@@ -594,6 +626,58 @@ begin
Rec.Free;
end;
// ★Бюджет памяти. Раньше конструктор выделял MaxSec × 48 кГц × 2 × int16
// СРАЗУ — 57.6 МБ на строку из сети, 403 МБ на семь приёмников, и держать их
// мог кто угодно (авторизации в TCI нет). Теперь START не стоит ни байта.
Was := TCIRecBudgetUsed;
Rec := TTCIRecorder.Create(0, 48000, TCI_RECORD_MAX_SEC, nil);
try
Check('бюджет: START не выделяет памяти', TCIRecBudgetUsed = Was,
IntToStr(TCIRecBudgetUsed - Was));
for i := 0 to 8191 do begin L[i] := 0.25; R[i] := -0.25; end;
Rec.Feed(L, R, 8192);
Check('бюджет: растёт по мере записи', TCIRecBudgetUsed > Was);
Check('бюджет: кусками, а не всей ёмкостью',
TCIRecBudgetUsed - Was <= 4 * 48000 * 2 * 2,
IntToStr(TCIRecBudgetUsed - Was));
finally
Rec.Free;
end;
Check('бюджет: возвращается по Free', TCIRecBudgetUsed = Was);
// Потолок общий на все рекордеры сразу — иначе семь живых приёмников по
// 300 с всё равно дали бы 403 МБ.
Check('бюджет: потолок берётся целиком',
TCIRecBudgetTake(TCI_RECORD_MAX_BYTES - TCIRecBudgetUsed));
Check('бюджет: сверх потолка отказ', not TCIRecBudgetTake(1));
Rec := TTCIRecorder.Create(0, 48000, 10, nil);
try
Rec.Feed(L, R, 8192);
Check('бюджет: при отказе запись не рушится, а стоит пустой',
Length(Rec.Take) = 0);
finally
Rec.Free;
end;
TCIRecBudgetFree(TCI_RECORD_MAX_BYTES - Was);
Check('бюджет: после возврата снова можно брать', TCIRecBudgetTake(1));
TCIRecBudgetFree(1);
// ★Срок по ЧАСАМ спрашивает тик сервера: у мёртвого, молчащего или
// замьюченного приёмника Feed не зовут вовсе, и без этого вопроса память
// жила бы до остановки сервера.
Rec := TTCIRecorder.Create(0, 48000, 1, nil);
try
Rec.Feed(L, R, 8192);
Check('срок: до истечения окно открыто', not Rec.Expired);
Check('срок: память занята', TCIRecBudgetUsed > Was);
Sleep(1100);
Check('срок: истекло без единого Feed', Rec.Expired);
Check('срок: память отдана без Take и Free', TCIRecBudgetUsed = Was,
IntToStr(TCIRecBudgetUsed - Was));
finally
Rec.Free;
end;
// WAV: заголовок и длина.
Path := GetTempDir + 'tcitest_rec.wav';
DeleteFile(Path);
@@ -629,6 +713,20 @@ begin
finally
FS.Free;
end;
// ★Существующий файл не трогаем: имя приходит из сети, и fmCreate затирал
// бы любой доступный процессу файл. Пишем поверх заведомо другой длиной —
// файл обязан остаться прежним.
SetLength(Data, 10);
for i := 0 to 9 do Data[i] := 1;
TTCIWavWriter.Create(Path, Data, 48000);
Sleep(200);
FS := TFileStream.Create(Path, fmOpenRead);
try
Check('WAV: существующий файл не перезаписан',
FS.Size = 44 + 2000 * 2, IntToStr(FS.Size));
finally
FS.Free;
end;
DeleteFile(Path);
end;
end;
@@ -890,6 +988,7 @@ var
Op: Byte;
Pay: TBytes;
GotBinary, GotClose: Boolean;
Was: Int64;
begin
WriteLn('D. Команды потоков на живом сервере');
@@ -979,6 +1078,28 @@ begin
Ctrl.SetSampleRate(192000);
C.WaitText('if_limits', 1000);
// ★Рекордер только на ЖИВОМ приёмнике. Раньше хватало номера в потолке, и
// семь строк подряд занимали 403 МБ, которые никто не освобождал: у
// мёртвого приёмника Feed не зовут, значит и срок никто не проверял.
Was := TCIRecBudgetUsed;
C.SendText('line_out_recorder_start:3,300;');
S := C.WaitText('tci_error', 1500);
Check('recorder start на мёртвом приёмнике → ошибка',
Pos('receiver is not running', S) > 0, S);
C.SendText('line_out_recorder_start:99,300;');
S := C.WaitText('tci_error', 1500);
Check('recorder start с чужим номером → ошибка',
Pos('bad receiver', S) > 0, S);
Check('recorder: отказ не стоил памяти', TCIRecBudgetUsed = Was,
IntToStr(TCIRecBudgetUsed - Was));
// Имя файла из сети: каталог из просьбы игнорируется целиком, наружу
// записи не выходят (подробный разбор имён — в части A).
C.SendText('line_out_recorder_start:0,10;');
C.SendText('line_out_recorder_save:0,..' + '/' + '..' + '/etc/passwd;');
S := C.WaitText('tci_error', 1500);
Check('save с чужим путём → отказ по имени', Pos('bad file name', S) > 0, S);
// Рекордер: сохранять нечего — честная ошибка вместо пустого файла.
C.SendText('line_out_recorder_save:0,' +
StringReplace(GetTempDir + 'tcitest_none.wav', ':', '|', [rfReplaceAll]) + ';');
@@ -1205,6 +1326,8 @@ var
SliceId: Integer;
SV: TSliceView;
S: string;
Was: Int64;
C2: TRawClient;
begin
WriteLn('E. Сквозной прогон через движок');
@@ -1304,6 +1427,46 @@ begin
Check('сквозной: блоки IQ пришли', IQBlocks > 0, IntToStr(IQBlocks));
Check('сквозной: заголовки верны', BadHdr = 0, IntToStr(BadHdr));
// ── ★Запись линейного выхода и уход клиента ──────────────────────────
// Рекордер живёт на приёмнике, но платит за него тот, кто нажал START.
// Раньше уход клиента его не трогал вовсе: пары «подключился, START,
// отключился» набивали память до потолка, и вернуть её было некому до
// остановки сервера. Тут это видно насквозь — по общему бюджету.
Was := TCIRecBudgetUsed;
C2 := TRawClient.Create;
try
Check('запись: второй клиент подключился', C2.Connect(PORT));
C2.WaitText('ready;', 2000);
C2.SendText('line_out_recorder_start:0,300;');
C2.Pump(100);
for k := 0 to 40 do
begin
for i := 0 to PAIRS - 1 do
begin
V := Round(Cos(2 * Pi * 1000 * Phase / RATE) * 4000000);
Pkt[i * 6] := Byte(V shr 16);
Pkt[i * 6 + 1] := Byte(V shr 8);
Pkt[i * 6 + 2] := Byte(V);
V := Round(Sin(2 * Pi * 1000 * Phase / RATE) * 4000000);
Pkt[i * 6 + 3] := Byte(V shr 16);
Pkt[i * 6 + 4] := Byte(V shr 8);
Pkt[i * 6 + 5] := Byte(V);
Inc(Phase);
end;
Ctrl.FDSPEngine.PushDDCPacket(Pkt, 0, PAIRS);
Sleep(2);
end;
C2.Pump(200);
Check('запись: набирает память по мере звука', TCIRecBudgetUsed > Was,
IntToStr(TCIRecBudgetUsed - Was));
finally
C2.Close_; // уход без close-кадра, как при обрыве
C2.Free;
end;
Sleep(400);
Check('запись: уход клиента освобождает её память',
TCIRecBudgetUsed = Was, IntToStr(TCIRecBudgetUsed - Was));
// ── ★Слайс главного пана = приёмник 1 ────────────────────────────────
// Ровно случай Pluto: панов больше одного там не бывает, и «второй
// приёмник» существует только как слайс. Приёмник = слот слайса, поэтому