mirror of
https://git.vladimir.cc/vladimir/ewsdr.git
synced 2026-08-25 19:45:09 +00:00
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:
@@ -6598,6 +6598,9 @@ procedure TMainForm.ApplyTCISettings(Enabled: Boolean; Port: Integer;
|
|||||||
var Prev, Cfg: TTCISettings;
|
var Prev, Cfg: TTCISettings;
|
||||||
begin
|
begin
|
||||||
Prev := FTCICfg;
|
Prev := FTCICfg;
|
||||||
|
// ★От текущей конфигурации, а не с чистого листа: вкладка правит три поля,
|
||||||
|
// а в записи есть и другие (RecordDir), и сборка по полям их обнуляла бы.
|
||||||
|
Cfg := FTCICfg;
|
||||||
Cfg.Enabled := Enabled;
|
Cfg.Enabled := Enabled;
|
||||||
Cfg.Port := Port;
|
Cfg.Port := Port;
|
||||||
Cfg.BindAddr := BindAddr;
|
Cfg.BindAddr := BindAddr;
|
||||||
|
|||||||
@@ -466,6 +466,12 @@ type
|
|||||||
Enabled: Boolean;
|
Enabled: Boolean;
|
||||||
Port: Integer; // 1..65535, умолчание 40001 (как у ExpertSDR3)
|
Port: Integer; // 1..65535, умолчание 40001 (как у ExpertSDR3)
|
||||||
BindAddr: string; // '127.0.0.1' или '0.0.0.0'
|
BindAddr: string; // '127.0.0.1' или '0.0.0.0'
|
||||||
|
// ★Единственный каталог, куда TCI пишет записи линейного выхода
|
||||||
|
// (LINE_OUT_RECORDER_SAVE). Пусто — <каталог конфигурации>/records.
|
||||||
|
// Имя файла клиент выбирает сам, но КАТАЛОГ из его строки не берётся
|
||||||
|
// никогда: авторизации в протоколе нет, и полный путь из сети означал бы
|
||||||
|
// запись в любой доступный процессу файл (см. TCIRecordPath).
|
||||||
|
RecordDir: string;
|
||||||
end;
|
end;
|
||||||
|
|
||||||
// Transverter (XVTR) entry — один трансвертер.
|
// Transverter (XVTR) entry — один трансвертер.
|
||||||
@@ -2999,6 +3005,7 @@ begin
|
|||||||
T.Enabled := False; // включается осознанно, как CAT-транспорты
|
T.Enabled := False; // включается осознанно, как CAT-транспорты
|
||||||
T.Port := 40001;
|
T.Port := 40001;
|
||||||
T.BindAddr := '127.0.0.1';
|
T.BindAddr := '127.0.0.1';
|
||||||
|
T.RecordDir := ''; // пусто = <каталог конфигурации>/records
|
||||||
end;
|
end;
|
||||||
|
|
||||||
procedure TSettingsManager.LoadTCISettings(out T: TTCISettings);
|
procedure TSettingsManager.LoadTCISettings(out T: TTCISettings);
|
||||||
@@ -3010,6 +3017,7 @@ begin
|
|||||||
T.Enabled := JB(O, 'enabled', False);
|
T.Enabled := JB(O, 'enabled', False);
|
||||||
T.Port := EnsureRange(JI(O, 'port', 40001), 1, 65535);
|
T.Port := EnsureRange(JI(O, 'port', 40001), 1, 65535);
|
||||||
T.BindAddr := JS(O, 'bind_addr', '127.0.0.1');
|
T.BindAddr := JS(O, 'bind_addr', '127.0.0.1');
|
||||||
|
T.RecordDir := JS(O, 'record_dir', '');
|
||||||
end;
|
end;
|
||||||
|
|
||||||
procedure TSettingsManager.SaveTCISettings(const T: TTCISettings);
|
procedure TSettingsManager.SaveTCISettings(const T: TTCISettings);
|
||||||
@@ -3019,6 +3027,7 @@ begin
|
|||||||
JW(O, 'enabled', T.Enabled);
|
JW(O, 'enabled', T.Enabled);
|
||||||
JW(O, 'port', T.Port);
|
JW(O, 'port', T.Port);
|
||||||
JWS(O, 'bind_addr', T.BindAddr);
|
JWS(O, 'bind_addr', T.BindAddr);
|
||||||
|
JWS(O, 'record_dir', T.RecordDir);
|
||||||
Save;
|
Save;
|
||||||
end;
|
end;
|
||||||
|
|
||||||
|
|||||||
+105
-9
@@ -43,7 +43,7 @@ interface
|
|||||||
|
|
||||||
uses
|
uses
|
||||||
Classes, SysUtils, DateUtils, Math, SyncObjs,
|
Classes, SysUtils, DateUtils, Math, SyncObjs,
|
||||||
RadioController, RadioBackend, WDSPEngine, Settings,
|
RadioController, RadioBackend, WDSPEngine, Settings, PlatformUtils,
|
||||||
DXSpotStore, TCIProtocol, TCIServer, TCIStreams;
|
DXSpotStore, TCIProtocol, TCIServer, TCIStreams;
|
||||||
|
|
||||||
const
|
const
|
||||||
@@ -236,6 +236,9 @@ type
|
|||||||
procedure PushTxChrono; // тик: маркеры времени клиенту
|
procedure PushTxChrono; // тик: маркеры времени клиенту
|
||||||
procedure CmdStream(Client: TTCIClient; const M: TTCIMessage);
|
procedure CmdStream(Client: TTCIClient; const M: TTCIMessage);
|
||||||
procedure CmdRecorder(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 ClearTxClient(C: TTCIClient); // клиент ушёл/перестал модулировать
|
||||||
procedure StopTxOf(C: TTCIClient); // ★снять эфир, начатый этим клиентом
|
procedure StopTxOf(C: TTCIClient); // ★снять эфир, начатый этим клиентом
|
||||||
procedure ForgetTxOwner; // передача кончилась не по TCI
|
procedure ForgetTxOwner; // передача кончилась не по TCI
|
||||||
@@ -1329,6 +1332,7 @@ procedure TTCIAdapter.HandleDisconnect(Client: TTCIClient);
|
|||||||
begin
|
begin
|
||||||
DropHolds(Client);
|
DropHolds(Client);
|
||||||
DropClientStreams(Client);
|
DropClientStreams(Client);
|
||||||
|
DropClientRecorders(Client);
|
||||||
ClearTxClient(Client);
|
ClearTxClient(Client);
|
||||||
// ★И только теперь — сама передача: если в эфир нас поставил именно этот
|
// ★И только теперь — сама передача: если в эфир нас поставил именно этот
|
||||||
// клиент, снимаем MOX. Оставить включённый передатчик за ушедшим клиентом
|
// клиент, снимаем MOX. Оставить включённый передатчик за ушедшим клиентом
|
||||||
@@ -2640,6 +2644,7 @@ begin
|
|||||||
try
|
try
|
||||||
FServer.EnumClients(PushSensors);
|
FServer.EnumClients(PushSensors);
|
||||||
PushTxChrono;
|
PushTxChrono;
|
||||||
|
SweepRecorders;
|
||||||
except
|
except
|
||||||
// молча: следующий тик через 20 мс попробует снова
|
// молча: следующий тик через 20 мс попробует снова
|
||||||
end;
|
end;
|
||||||
@@ -2992,7 +2997,7 @@ end;
|
|||||||
procedure TTCIAdapter.CmdRecorder(Client: TTCIClient; const M: TTCIMessage);
|
procedure TTCIAdapter.CmdRecorder(Client: TTCIClient; const M: TTCIMessage);
|
||||||
var
|
var
|
||||||
Rx, Sec, Rate: Integer;
|
Rx, Sec, Rate: Integer;
|
||||||
Path: string;
|
Path, Req: string;
|
||||||
Data: TTCIPcm;
|
Data: TTCIPcm;
|
||||||
Old, New_: TTCIRecorder;
|
Old, New_: TTCIRecorder;
|
||||||
begin
|
begin
|
||||||
@@ -3004,11 +3009,21 @@ begin
|
|||||||
|
|
||||||
if M.Name = 'LINE_OUT_RECORDER_START' then
|
if M.Name = 'LINE_OUT_RECORDER_START' then
|
||||||
begin
|
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;
|
if not TCITryArgInt(M, 1, Sec) then Sec := TCI_RECORD_MAX_SEC;
|
||||||
Sec := EnsureRange(Sec, 1, TCI_RECORD_MAX_SEC);
|
Sec := EnsureRange(Sec, 1, TCI_RECORD_MAX_SEC);
|
||||||
// Кольцо заводим ДО лока: на предельных 300 с это 57 МБ, и выделять их
|
// Объект заводим ДО лока — не ради памяти (её он больше не выделяет, см.
|
||||||
// под локом, которого ждёт DSP-поток, значит уронить звук на десятки мс.
|
// TTCIRecorder), а чтобы не звать чужой конструктор под локом DSP-потока.
|
||||||
New_ := TTCIRecorder.Create(Rx, TCI_AUDIO_ENGINE_RATE, Sec);
|
// Владельца помним: уйдёт клиент — уйдёт и его запись.
|
||||||
|
New_ := TTCIRecorder.Create(Rx, TCI_AUDIO_ENGINE_RATE, Sec, Client);
|
||||||
FStreamLock.Enter;
|
FStreamLock.Enter;
|
||||||
try
|
try
|
||||||
// Рекордер один на приёмник, а не на клиента: пишет он то, что слышно
|
// Рекордер один на приёмник, а не на клиента: пишет он то, что слышно
|
||||||
@@ -3036,20 +3051,35 @@ begin
|
|||||||
end;
|
end;
|
||||||
|
|
||||||
// LINE_OUT_RECORDER_SAVE
|
// LINE_OUT_RECORDER_SAVE
|
||||||
Path := TCIRecordPath(TCIUnescape(TCIArg(M, 1)));
|
Req := TCIUnescape(TCIArg(M, 1));
|
||||||
if Path = '' then
|
if Req = '' then
|
||||||
begin
|
begin
|
||||||
Reply(Client, TCIBuild('tci_error', [LowerCase(M.Name), 'no file name']));
|
Reply(Client, TCIBuild('tci_error', [LowerCase(M.Name), 'no file name']));
|
||||||
Exit;
|
Exit;
|
||||||
end;
|
end;
|
||||||
// MP3 у нас кодировать нечем — молча подсунуть WAV с расширением .mp3 хуже,
|
// MP3 у нас кодировать нечем — молча подсунуть WAV с расширением .mp3 хуже,
|
||||||
// чем сказать правду: клиент такой файл всё равно не откроет.
|
// чем сказать правду: клиент такой файл всё равно не откроет. Проверяем до
|
||||||
if SameText(ExtractFileExt(Path), '.mp3') then
|
// TCIRecordPath, чтобы у самой частой причины отказа был свой текст.
|
||||||
|
if SameText(ExtractFileExt(Req), '.mp3') then
|
||||||
begin
|
begin
|
||||||
Reply(Client, TCIBuild('tci_error',
|
Reply(Client, TCIBuild('tci_error',
|
||||||
[LowerCase(M.Name), 'only wav is supported']));
|
[LowerCase(M.Name), 'only wav is supported']));
|
||||||
Exit;
|
Exit;
|
||||||
end;
|
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) и только
|
// Забираем рекордер из таблицы (сохранение завершает запись, §4.3) и только
|
||||||
// потом снимаем с него данные: копия кольца — это десятки мегабайт, и делать
|
// потом снимаем с него данные: копия кольца — это десятки мегабайт, и делать
|
||||||
@@ -3079,6 +3109,72 @@ begin
|
|||||||
TTCIWavWriter.Create(Path, Data, Rate);
|
TTCIWavWriter.Create(Path, Data, Rate);
|
||||||
end;
|
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);
|
procedure TTCIAdapter.ClearTxClient(C: TTCIClient);
|
||||||
// C = nil — снять кого угодно (остановка сервера).
|
// C = nil — снять кого угодно (остановка сервера).
|
||||||
var Drop: Boolean;
|
var Drop: Boolean;
|
||||||
|
|||||||
@@ -58,6 +58,17 @@ const
|
|||||||
TCI_TX_BUFFERING_MIN = 50;
|
TCI_TX_BUFFERING_MIN = 50;
|
||||||
TCI_TX_BUFFERING_MAX = 500;
|
TCI_TX_BUFFERING_MAX = 500;
|
||||||
TCI_RECORD_MAX_SEC = 300; // потолок записи линейного выхода
|
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_VOL_MIN_DB = -60; TCI_VOL_MAX_DB = 0;
|
||||||
TCI_SQL_MIN_DB = -140; TCI_SQL_MAX_DB = 0;
|
TCI_SQL_MIN_DB = -140; TCI_SQL_MAX_DB = 0;
|
||||||
|
|||||||
+249
-38
@@ -32,6 +32,11 @@ unit TCIStreams;
|
|||||||
interface
|
interface
|
||||||
|
|
||||||
uses
|
uses
|
||||||
|
// ★Windows идёт ПЕРВЫМ намеренно: он объявляет свой TCriticalSection (запись,
|
||||||
|
// а не класс), и стоя последним перекрыл бы SyncObjs — на win64 это уже
|
||||||
|
// ловилось в DX-кластере. Порядок здесь и есть лечение.
|
||||||
|
{$IFDEF WINDOWS}Windows,{$ENDIF}
|
||||||
|
{$IFDEF UNIX}BaseUnix,{$ENDIF}
|
||||||
Classes, SysUtils, Math, SyncObjs, TCIProtocol, TCIServer;
|
Classes, SysUtils, Math, SyncObjs, TCIProtocol, TCIServer;
|
||||||
|
|
||||||
const
|
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
|
TTCIRecorder = class
|
||||||
private
|
private
|
||||||
FLock: TCriticalSection;
|
FLock: TCriticalSection;
|
||||||
FBuf: array of SmallInt; // чередование L/R
|
FChunks: array of TTCIPcm; // куски по FChunk сэмплов на канал, L/R вперемешку
|
||||||
FCap: Integer; // ёмкость в сэмплах на канал
|
FChunk: Integer; // сэмплов на канал в куске
|
||||||
|
FCap: Integer; // потолок в сэмплах на канал
|
||||||
FCount: Integer; // накоплено сэмплов на канал
|
FCount: Integer; // накоплено сэмплов на канал
|
||||||
|
FBytes: Int64; // занято под FChunks (столько же взято у бюджета)
|
||||||
FRate: Integer;
|
FRate: Integer;
|
||||||
FRx: Integer;
|
FRx: Integer;
|
||||||
|
FOwner: TObject; // клиент, попросивший START (для его ухода)
|
||||||
FExpired: Boolean; // окно записи закрылось, данные удалены
|
FExpired: Boolean; // окно записи закрылось, данные удалены
|
||||||
|
FStarved: Boolean; // бюджет не дал расти — пишем сколько влезло
|
||||||
FEndsAt: QWord; // GetTickCount64 конца окна
|
FEndsAt: QWord; // GetTickCount64 конца окна
|
||||||
procedure DropData; // под FLock
|
procedure DropData; // под FLock
|
||||||
|
function EnsureRoom: Boolean; // под FLock: место под ещё один сэмпл
|
||||||
public
|
public
|
||||||
constructor Create(ARx, ARateHz, AMaxSec: Integer);
|
constructor Create(ARx, ARateHz, AMaxSec: Integer; AOwner: TObject);
|
||||||
destructor Destroy; override;
|
destructor Destroy; override;
|
||||||
procedure Feed(const L, R: array of Single; N: Integer);
|
procedure Feed(const L, R: array of Single; N: Integer);
|
||||||
{ Забрать накопленное и завершить запись. nil — либо не записано ничего,
|
{ Забрать накопленное и завершить запись. nil — либо не записано ничего,
|
||||||
либо окно уже истекло (по документу это одно и то же: записи нет). }
|
либо окно уже истекло (по документу это одно и то же: записи нет). }
|
||||||
function Take: TTCIPcm;
|
function Take: TTCIPcm;
|
||||||
|
{ Окно закрылось по часам. Спрашивает тик сервера: у мёртвого приёмника
|
||||||
|
Feed не зовут вовсе, и без этого вопроса память жила бы до Stop. }
|
||||||
|
function Expired: Boolean;
|
||||||
property Rx: Integer read FRx;
|
property Rx: Integer read FRx;
|
||||||
property Rate: Integer read FRate;
|
property Rate: Integer read FRate;
|
||||||
|
property Owner: TObject read FOwner;
|
||||||
end;
|
end;
|
||||||
|
|
||||||
{ Писатель WAV в своём потоке: файл до 60 МБ, а зовут сохранение из тика
|
{ Писатель WAV в своём потоке: файл до 60 МБ, а зовут сохранение из тика
|
||||||
@@ -229,9 +252,33 @@ function TCIDecimFactor(SrcRate, WantRate: Integer): Integer;
|
|||||||
TCIDecimFactor, а настоящая частота уйдёт в заголовке блока. }
|
TCIDecimFactor, а настоящая частота уйдёт в заголовке блока. }
|
||||||
function TCIPickIQRate(SrcRate, WantRate: Integer): Integer;
|
function TCIPickIQRate(SrcRate, WantRate: Integer): Integer;
|
||||||
|
|
||||||
{ Путь из LINE_OUT_RECORDER_SAVE в путь файловой системы: в протоколе ':'
|
{ ═══ Общий бюджет памяти рекордеров ═══════════════════════════════════════
|
||||||
запрещён и заменён на '|' (§4.3), слэши допускаются любые. }
|
Взять/вернуть можно из любого потока (Take зовёт DSP-поток, Free — тик,
|
||||||
function TCIRecordPath(const S: string): string;
|
поток клиента и поток контроллера). 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
|
implementation
|
||||||
|
|
||||||
@@ -676,18 +723,24 @@ end;
|
|||||||
Рекордер линейного выхода
|
Рекордер линейного выхода
|
||||||
═══════════════════════════════════════════════════════════════════════════ }
|
═══════════════════════════════════════════════════════════════════════════ }
|
||||||
|
|
||||||
constructor TTCIRecorder.Create(ARx, ARateHz, AMaxSec: Integer);
|
constructor TTCIRecorder.Create(ARx, ARateHz, AMaxSec: Integer; AOwner: TObject);
|
||||||
begin
|
begin
|
||||||
inherited Create;
|
inherited Create;
|
||||||
FLock := TCriticalSection.Create;
|
FLock := TCriticalSection.Create;
|
||||||
FRx := ARx;
|
FRx := ARx;
|
||||||
FRate := ARateHz;
|
FRate := ARateHz;
|
||||||
|
FOwner := AOwner;
|
||||||
if AMaxSec < 1 then AMaxSec := 1;
|
if AMaxSec < 1 then AMaxSec := 1;
|
||||||
if AMaxSec > TCI_RECORD_MAX_SEC then AMaxSec := TCI_RECORD_MAX_SEC;
|
if AMaxSec > TCI_RECORD_MAX_SEC then AMaxSec := TCI_RECORD_MAX_SEC;
|
||||||
FCap := ARateHz * AMaxSec;
|
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;
|
FCount := 0;
|
||||||
|
FBytes := 0;
|
||||||
FExpired := False;
|
FExpired := False;
|
||||||
|
FStarved := False;
|
||||||
// Окно открывается прямо здесь: клиент отсчитывает его от своей команды
|
// Окно открывается прямо здесь: клиент отсчитывает его от своей команды
|
||||||
// START, и ждать первого блока аудио, чтобы завести часы, нельзя.
|
// START, и ждать первого блока аудио, чтобы завести часы, нельзя.
|
||||||
FEndsAt := GetTickCount64 + QWord(AMaxSec) * 1000;
|
FEndsAt := GetTickCount64 + QWord(AMaxSec) * 1000;
|
||||||
@@ -695,23 +748,66 @@ end;
|
|||||||
|
|
||||||
destructor TTCIRecorder.Destroy;
|
destructor TTCIRecorder.Destroy;
|
||||||
begin
|
begin
|
||||||
|
// Бюджет возвращаем и на аварийном пути: рекордер освобождают и по уходу
|
||||||
|
// клиента, и по исчезновению приёмника, и на Stop — DropData зовут не все.
|
||||||
|
TCIRecBudgetFree(FBytes);
|
||||||
|
FBytes := 0;
|
||||||
FLock.Free;
|
FLock.Free;
|
||||||
inherited;
|
inherited;
|
||||||
end;
|
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;
|
procedure TTCIRecorder.DropData;
|
||||||
// Под FLock. Память отдаём сразу: истёкшая запись на 300 с держала бы 57 МБ
|
// Под FLock. Память отдаём сразу: истёкшая запись на 300 с держала бы 57 МБ
|
||||||
// до тех пор, пока клиент не вспомнит про BREAK.
|
// до тех пор, пока клиент не вспомнит про BREAK.
|
||||||
|
var i: Integer;
|
||||||
begin
|
begin
|
||||||
FCount := 0;
|
FCount := 0;
|
||||||
FExpired := True;
|
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;
|
end;
|
||||||
|
|
||||||
procedure TTCIRecorder.Feed(const L, R: array of Single; N: Integer);
|
procedure TTCIRecorder.Feed(const L, R: array of Single; N: Integer);
|
||||||
// DSP-поток. Пишем линейно до конца окна; истекло — данных больше нет (§4.3).
|
// DSP-поток. Пишем линейно до конца окна; истекло — данных больше нет (§4.3).
|
||||||
var
|
var
|
||||||
i: Integer;
|
i, j, C: Integer;
|
||||||
A, B: Single;
|
A, B: Single;
|
||||||
begin
|
begin
|
||||||
if (N <= 0) or (FCap <= 0) then Exit;
|
if (N <= 0) or (FCap <= 0) then Exit;
|
||||||
@@ -730,13 +826,18 @@ begin
|
|||||||
if N > FCap - FCount then N := FCap - FCount;
|
if N > FCap - FCount then N := FCap - FCount;
|
||||||
for i := 0 to N - 1 do
|
for i := 0 to N - 1 do
|
||||||
begin
|
begin
|
||||||
|
// Место спрашиваем на каждый сэмпл: кусок мог кончиться посередине
|
||||||
|
// блока, а бюджет — отказать (тогда дописываем ровно до его границы).
|
||||||
|
if not EnsureRoom then Break;
|
||||||
A := L[i]; B := R[i];
|
A := L[i]; B := R[i];
|
||||||
if A > 1.0 then A := 1.0; if A < -1.0 then A := -1.0;
|
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;
|
if B > 1.0 then B := 1.0; if B < -1.0 then B := -1.0;
|
||||||
FBuf[(FCount + i) * 2] := Round(A * 32767);
|
C := FCount div FChunk;
|
||||||
FBuf[(FCount + i) * 2 + 1] := Round(B * 32767);
|
j := (FCount mod FChunk) * 2;
|
||||||
|
FChunks[C][j] := Round(A * 32767);
|
||||||
|
FChunks[C][j + 1] := Round(B * 32767);
|
||||||
|
Inc(FCount);
|
||||||
end;
|
end;
|
||||||
Inc(FCount, N);
|
|
||||||
finally
|
finally
|
||||||
FLock.Leave;
|
FLock.Leave;
|
||||||
end;
|
end;
|
||||||
@@ -744,7 +845,7 @@ end;
|
|||||||
|
|
||||||
function TTCIRecorder.Take: TTCIPcm;
|
function TTCIRecorder.Take: TTCIPcm;
|
||||||
var
|
var
|
||||||
i: Integer;
|
C, n, Left: Integer;
|
||||||
begin
|
begin
|
||||||
Result := nil;
|
Result := nil;
|
||||||
FLock.Enter;
|
FLock.Enter;
|
||||||
@@ -753,8 +854,19 @@ begin
|
|||||||
// приёмник), и тогда Feed часы не смотрел ни разу.
|
// приёмник), и тогда Feed часы не смотрел ни разу.
|
||||||
if (not FExpired) and (GetTickCount64 >= FEndsAt) then DropData;
|
if (not FExpired) and (GetTickCount64 >= FEndsAt) then DropData;
|
||||||
if FExpired or (FCount <= 0) then Exit;
|
if FExpired or (FCount <= 0) then Exit;
|
||||||
|
// Склейка кусков в один буфер — единственное копирование за всю запись, и
|
||||||
|
// оно идёт уже вне DSP-потока: SAVE вынимает рекордер из таблицы раньше,
|
||||||
|
// чем зовёт Take, так что кормить его больше некому.
|
||||||
SetLength(Result, FCount * 2);
|
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)
|
DropData; // SAVE завершает запись (§4.3)
|
||||||
finally
|
finally
|
||||||
FLock.Leave;
|
FLock.Leave;
|
||||||
@@ -781,7 +893,8 @@ procedure TTCIWavWriter.Execute;
|
|||||||
// два десятка отдельных Write ради них незачем (а строковые литералы в
|
// два десятка отдельных Write ради них незачем (а строковые литералы в
|
||||||
// нетипизированный Write в FPC ещё и передаются не тем, чем кажется).
|
// нетипизированный Write в FPC ещё и передаются не тем, чем кажется).
|
||||||
var
|
var
|
||||||
FS: TFileStream;
|
FS: THandleStream;
|
||||||
|
H: THandle;
|
||||||
Hdr: array[0..43] of Byte;
|
Hdr: array[0..43] of Byte;
|
||||||
DataBytes: LongWord;
|
DataBytes: LongWord;
|
||||||
|
|
||||||
@@ -820,12 +933,21 @@ begin
|
|||||||
PutTag(36, 'data');
|
PutTag(36, 'data');
|
||||||
PutU32(40, DataBytes);
|
PutU32(40, DataBytes);
|
||||||
|
|
||||||
FS := TFileStream.Create(FPath, fmCreate);
|
// ★Не TFileStream/fmCreate: имя пришло из сети, и затирать им чужой файл
|
||||||
try
|
// нельзя. TCICreateNewFile создаёт только новый и не идёт по симлинку.
|
||||||
FS.Write(Hdr[0], SizeOf(Hdr));
|
H := TCICreateNewFile(FPath);
|
||||||
if DataBytes > 0 then FS.Write(FData[0], DataBytes);
|
// Не вышло (файл уже есть, нет прав, нет каталога) — просто уходим: выйти
|
||||||
finally
|
// отсюда через Exit нельзя, ниже ещё возврат памяти под данные.
|
||||||
FS.Free;
|
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;
|
end;
|
||||||
except
|
except
|
||||||
// Записать не вышло (нет прав, нет каталога, диск полон) — сказать об этом
|
// Записать не вышло (нет прав, нет каталога, диск полон) — сказать об этом
|
||||||
@@ -867,19 +989,108 @@ begin
|
|||||||
Exit(LEGAL[i]);
|
Exit(LEGAL[i]);
|
||||||
end;
|
end;
|
||||||
|
|
||||||
function TCIRecordPath(const S: string): string;
|
{ ═══════════════════════════════════════════════════════════════════════════
|
||||||
var i: Integer;
|
Бюджет памяти рекордеров
|
||||||
|
═══════════════════════════════════════════════════════════════════════════ }
|
||||||
|
|
||||||
|
var
|
||||||
|
RecBudgetLock: TCriticalSection = nil;
|
||||||
|
RecBudgetUsed: Int64 = 0;
|
||||||
|
|
||||||
|
function TCIRecBudgetTake(Bytes: Int64): Boolean;
|
||||||
begin
|
begin
|
||||||
Result := S;
|
Result := False;
|
||||||
for i := 1 to Length(Result) do
|
if Bytes <= 0 then Exit(True);
|
||||||
if Result[i] = '|' then Result[i] := ':';
|
if RecBudgetLock = nil then Exit;
|
||||||
{$IFDEF WINDOWS}
|
RecBudgetLock.Enter;
|
||||||
for i := 1 to Length(Result) do
|
try
|
||||||
if Result[i] = '/' then Result[i] := '\';
|
if RecBudgetUsed + Bytes > TCI_RECORD_MAX_BYTES then Exit;
|
||||||
{$ELSE}
|
Inc(RecBudgetUsed, Bytes);
|
||||||
for i := 1 to Length(Result) do
|
Result := True;
|
||||||
if Result[i] = '\' then Result[i] := '/';
|
finally
|
||||||
{$ENDIF}
|
RecBudgetLock.Leave;
|
||||||
|
end;
|
||||||
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.
|
end.
|
||||||
|
|||||||
+64
-2
@@ -136,9 +136,16 @@ TCI-клиенты ──WebSocket──► TTCIServer ──► TTCIAdapter ─
|
|||||||
Секция `tci` в корне `settings.json`:
|
Секция `tci` в корне `settings.json`:
|
||||||
|
|
||||||
```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» (галка, порт,
|
UI — вкладка **CAT → TCI Server**, справа от «TCP CAT Server» (галка, порт,
|
||||||
интерфейс): TCI — такой же канал внешнего управления трансивером, что и CAT,
|
интерфейс): TCI — такой же канал внешнего управления трансивером, что и CAT,
|
||||||
и оператор ищет его там, а не в «Advanced». В протоколе
|
и оператор ищет его там, а не в «Advanced». В протоколе
|
||||||
@@ -506,6 +513,50 @@ ExpertSDR3 давно бы не было.
|
|||||||
это время не читает свой сокет. MP3 не поддержан — кодера в проекте нет,
|
это время не читает свой сокет. MP3 не поддержан — кодера в проекте нет,
|
||||||
и на `.mp3` уходит честный `tci_error`.
|
и на `.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`) поднят
|
**Потолок кадра.** Приёмный буфер соединения (`WsClient.WS_BUF_SIZE`) поднят
|
||||||
с 4 до 32 КБ: блок TX-аудио — это 64 байта заголовка плюс `data[16384]`, а
|
с 4 до 32 КБ: блок TX-аудио — это 64 байта заголовка плюс `data[16384]`, а
|
||||||
кадр крупнее буфера не собирается никогда (BufLen упирается в потолок и разбор
|
кадр крупнее буфера не собирается никогда (BufLen упирается в потолок и разбор
|
||||||
@@ -806,11 +857,22 @@ ExpertSDR3 давно бы не было.
|
|||||||
- **Рекордер и WAV:** буфер ограничен запрошенным временем и пишется с начала
|
- **Рекордер и WAV:** буфер ограничен запрошенным временем и пишется с начала
|
||||||
окна (переполнение отбрасывает новое, а не затирает старое), до срока запись
|
окна (переполнение отбрасывает новое, а не затирает старое), до срока запись
|
||||||
есть, после срока её нет и истёкший рекордер не оживает, `Take` завершает
|
есть, после срока её нет и истёкший рекордер не оживает, `Take` завершает
|
||||||
запись, файл получает верные RIFF/fmt/data и длину.
|
запись, файл получает верные RIFF/fmt/data и длину. ★Память: `START` не
|
||||||
|
выделяет ни байта, бюджет растёт кусками по мере звука и возвращается по
|
||||||
|
`Free`, потолок берётся целиком и сверх него следует отказ, при отказе запись
|
||||||
|
не рушится, окно истекает по часам **без единого `Feed`** и отдаёт память
|
||||||
|
само. ★Имя файла: простое имя ложится в каталог записей, а каталог из
|
||||||
|
просьбы отбрасывается — абсолютный путь, `..` и буква диска наружу не
|
||||||
|
выводят; пусто, `..`, не-`.wav`, управляющий символ и отсутствие каталога
|
||||||
|
записей дают отказ; существующий файл писатель не перезаписывает.
|
||||||
- **Команды на живом сервере** (настоящий `TRadioController`, WS-клиент на
|
- **Команды на живом сервере** (настоящий `TRadioController`, WS-клиент на
|
||||||
сыром сокете): отказ на несуществующий приёмник и на нечисловой аргумент,
|
сыром сокете): отказ на несуществующий приёмник и на нечисловой аргумент,
|
||||||
отказ на старт потока с незапущенного пана, подтверждение и отбраковка
|
отказ на старт потока с незапущенного пана, подтверждение и отбраковка
|
||||||
параметров, `SAVE` без записи и `SAVE` в `.mp3` отвечают ошибкой,
|
параметров, `SAVE` без записи и `SAVE` в `.mp3` отвечают ошибкой,
|
||||||
|
`LINE_OUT_RECORDER_START` на мёртвом приёмнике и с чужим номером отвечает
|
||||||
|
ошибкой и **не стоит памяти**, `SAVE` с чужим путём отбивается по имени;
|
||||||
|
запись, начатая ушедшим клиентом, освобождается вместе с ним (видно насквозь
|
||||||
|
по общему бюджету в части E);
|
||||||
`TRX:0,true,tci` без аудиопотока модуляцию не берёт, а с потоком берёт,
|
`TRX:0,true,tci` без аудиопотока модуляцию не берёт, а с потоком берёт,
|
||||||
реально поднимает передачу и снимает её по `TRX:0,false` и по уходу клиента;
|
реально поднимает передачу и снимает её по `TRX:0,false` и по уходу клиента;
|
||||||
чужой бинарный блок не рвёт соединение; без передачи маркеров `TX_CHRONO`
|
чужой бинарный блок не рвёт соединение; без передачи маркеров `TX_CHRONO`
|
||||||
|
|||||||
+168
-5
@@ -72,6 +72,7 @@ var
|
|||||||
Buf: array[0..63] of Byte;
|
Buf: array[0..63] of Byte;
|
||||||
N, i: Integer;
|
N, i: Integer;
|
||||||
T: TTCISampleType;
|
T: TTCISampleType;
|
||||||
|
Base: string;
|
||||||
begin
|
begin
|
||||||
WriteLn('A. Протокол потоков');
|
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[0]) or (Word(Buf[1]) shl 8)) = 32767);
|
||||||
Check('клип int16 −', SmallInt(Word(Buf[2]) or (Word(Buf[3]) 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 делятся не все: клиенту обязана
|
// Частоты Pluto (576…5760 кГц) на 384 делятся не все: клиенту обязана
|
||||||
// достаться ЗАКОННАЯ частота из набора протокола, а не 576/480 кГц.
|
// достаться ЗАКОННАЯ частота из набора протокола, а не 576/480 кГц.
|
||||||
@@ -531,12 +562,13 @@ var
|
|||||||
Sz: LongWord;
|
Sz: LongWord;
|
||||||
W: TTCIWavWriter;
|
W: TTCIWavWriter;
|
||||||
Waited: Integer;
|
Waited: Integer;
|
||||||
|
Was: Int64;
|
||||||
begin
|
begin
|
||||||
WriteLn('C. Рекордер линейного выхода');
|
WriteLn('C. Рекордер линейного выхода');
|
||||||
|
|
||||||
// Максимум записи — 1 секунда: подаём полторы, лишнее не берём. ★Именно НЕ
|
// Максимум записи — 1 секунда: подаём полторы, лишнее не берём. ★Именно НЕ
|
||||||
// берём: окно записи по §4.3 начинается со START, а не «последняя секунда».
|
// берём: окно записи по §4.3 начинается со START, а не «последняя секунда».
|
||||||
Rec := TTCIRecorder.Create(0, 48000, 1);
|
Rec := TTCIRecorder.Create(0, 48000, 1, nil);
|
||||||
try
|
try
|
||||||
Total := 0;
|
Total := 0;
|
||||||
for i := 0 to 8191 do begin L[i] := 0.25; R[i] := -0.25; end;
|
for i := 0 to 8191 do begin L[i] := 0.25; R[i] := -0.25; end;
|
||||||
@@ -556,7 +588,7 @@ begin
|
|||||||
end;
|
end;
|
||||||
|
|
||||||
// Порядок отсчётов: первым обязан идти самый ПЕРВЫЙ записанный.
|
// Порядок отсчётов: первым обязан идти самый ПЕРВЫЙ записанный.
|
||||||
Rec := TTCIRecorder.Create(0, 1000, 1); // ёмкость 1000 отсчётов
|
Rec := TTCIRecorder.Create(0, 1000, 1, nil); // ёмкость 1000 отсчётов
|
||||||
try
|
try
|
||||||
for i := 0 to 1499 do
|
for i := 0 to 1499 do
|
||||||
begin
|
begin
|
||||||
@@ -575,7 +607,7 @@ begin
|
|||||||
|
|
||||||
// ★Истечение срока: по документу «по истечении времени запись удаляется».
|
// ★Истечение срока: по документу «по истечении времени запись удаляется».
|
||||||
// Секунду ждать незачем — окно берём минимальное и смотрим на часы.
|
// Секунду ждать незачем — окно берём минимальное и смотрим на часы.
|
||||||
Rec := TTCIRecorder.Create(0, 48000, 1);
|
Rec := TTCIRecorder.Create(0, 48000, 1, nil);
|
||||||
try
|
try
|
||||||
for i := 0 to 8191 do begin L[i] := 0.25; R[i] := -0.25; end;
|
for i := 0 to 8191 do begin L[i] := 0.25; R[i] := -0.25; end;
|
||||||
Rec.Feed(L, R, 8192);
|
Rec.Feed(L, R, 8192);
|
||||||
@@ -583,7 +615,7 @@ begin
|
|||||||
finally
|
finally
|
||||||
Rec.Free;
|
Rec.Free;
|
||||||
end;
|
end;
|
||||||
Rec := TTCIRecorder.Create(0, 48000, 1);
|
Rec := TTCIRecorder.Create(0, 48000, 1, nil);
|
||||||
try
|
try
|
||||||
Rec.Feed(L, R, 8192);
|
Rec.Feed(L, R, 8192);
|
||||||
Sleep(1100); // окно закрылось
|
Sleep(1100); // окно закрылось
|
||||||
@@ -594,6 +626,58 @@ begin
|
|||||||
Rec.Free;
|
Rec.Free;
|
||||||
end;
|
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: заголовок и длина.
|
// WAV: заголовок и длина.
|
||||||
Path := GetTempDir + 'tcitest_rec.wav';
|
Path := GetTempDir + 'tcitest_rec.wav';
|
||||||
DeleteFile(Path);
|
DeleteFile(Path);
|
||||||
@@ -629,6 +713,20 @@ begin
|
|||||||
finally
|
finally
|
||||||
FS.Free;
|
FS.Free;
|
||||||
end;
|
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);
|
DeleteFile(Path);
|
||||||
end;
|
end;
|
||||||
end;
|
end;
|
||||||
@@ -890,6 +988,7 @@ var
|
|||||||
Op: Byte;
|
Op: Byte;
|
||||||
Pay: TBytes;
|
Pay: TBytes;
|
||||||
GotBinary, GotClose: Boolean;
|
GotBinary, GotClose: Boolean;
|
||||||
|
Was: Int64;
|
||||||
begin
|
begin
|
||||||
WriteLn('D. Команды потоков на живом сервере');
|
WriteLn('D. Команды потоков на живом сервере');
|
||||||
|
|
||||||
@@ -979,6 +1078,28 @@ begin
|
|||||||
Ctrl.SetSampleRate(192000);
|
Ctrl.SetSampleRate(192000);
|
||||||
C.WaitText('if_limits', 1000);
|
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,' +
|
C.SendText('line_out_recorder_save:0,' +
|
||||||
StringReplace(GetTempDir + 'tcitest_none.wav', ':', '|', [rfReplaceAll]) + ';');
|
StringReplace(GetTempDir + 'tcitest_none.wav', ':', '|', [rfReplaceAll]) + ';');
|
||||||
@@ -1205,6 +1326,8 @@ var
|
|||||||
SliceId: Integer;
|
SliceId: Integer;
|
||||||
SV: TSliceView;
|
SV: TSliceView;
|
||||||
S: string;
|
S: string;
|
||||||
|
Was: Int64;
|
||||||
|
C2: TRawClient;
|
||||||
begin
|
begin
|
||||||
WriteLn('E. Сквозной прогон через движок');
|
WriteLn('E. Сквозной прогон через движок');
|
||||||
|
|
||||||
@@ -1304,6 +1427,46 @@ begin
|
|||||||
Check('сквозной: блоки IQ пришли', IQBlocks > 0, IntToStr(IQBlocks));
|
Check('сквозной: блоки IQ пришли', IQBlocks > 0, IntToStr(IQBlocks));
|
||||||
Check('сквозной: заголовки верны', BadHdr = 0, IntToStr(BadHdr));
|
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 ────────────────────────────────
|
// ── ★Слайс главного пана = приёмник 1 ────────────────────────────────
|
||||||
// Ровно случай Pluto: панов больше одного там не бывает, и «второй
|
// Ровно случай Pluto: панов больше одного там не бывает, и «второй
|
||||||
// приёмник» существует только как слайс. Приёмник = слот слайса, поэтому
|
// приёмник» существует только как слайс. Приёмник = слот слайса, поэтому
|
||||||
|
|||||||
Reference in New Issue
Block a user