Звуковые дорожки смешиваются, а не побеждают друг друга

Правило «верхний побеждает» я перенёс на звук механически, и это была
ошибка: оно придумано для картинки, где и делает возможной сборку
копированием. Для звука оно бессмысленно — под голос нельзя было положить
фоновую музыку. И стоит смешивание ничего: звук всё равно перекодируется.

В сборке куски со звуком нескольких дорожек сводятся тем же путём, что
переходы: слоями, каждый со своей громкостью.

В предпросмотре сложнее: плеер играет одну звуковую дорожку за раз.
Поэтому каждая дорожка монтажа подаётся своим списком кусков, а смесь
собирается цепочкой фильтров плеера. Громкость клипов подставляется в ту
же цепочку — иначе предпросмотр врал бы про баланс.

Проверено: два тона на разных дорожках, второй с громкостью 50%. В
готовом ролике -21.2 и -27.1 dB, в предпросмотре -21.2 и -27.2. Совпало.

Найдена по дороге ошибка в собственном условии: я считал слои без
победителя и потому обнулял список, когда звучали ровно две дорожки.
This commit is contained in:
chaiman 2026-08-20 12:10:57 +03:00
commit 3583f74864
4 changed files with 355 additions and 33 deletions

View file

@ -292,6 +292,18 @@ Linux потом искала по нему `.dll`.
громкость. Клип с непрозрачностью меньше единицы смешивается с тем, что
под ним.
**Звуковые дорожки смешиваются.** Правило «верхний побеждает» придумано
для картинки — оно и делает возможной сборку копированием. Для звука оно
бессмысленно: без смешивания нельзя положить фоновую музыку под голос. И
стоит оно ничего, потому что звук всё равно перекодируется.
Плеер играет одну звуковую дорожку за раз, поэтому в предпросмотре смесь
собирается его же цепочкой фильтров: каждая дорожка монтажа подаётся
своим списком кусков, дальше `amix`. Громкость клипов подставляется в ту
же цепочку — иначе предпросмотр врал бы про баланс. Проверено: в
предпросмотре и в готовом ролике уровни совпадают до десятой доли
децибела.
Два клипа **на одной дорожке**, наползшие друг на друга, дают переход:
один плавно перетекает в другой. На разных дорожках перекрытие означает
не переход, а наложение — там по-прежнему побеждает верхний, если он

View file

@ -71,6 +71,15 @@ function FlattenStream(AProject: TProject; AKind: TTrackKind;
const AMinLength: Double;
out ASegments: TFlatSegmentArray; out AMissing: TGenRequests): Boolean;
{ Уплощение одной звуковой дорожки — для предпросмотра.
Плеер умеет играть только одну звуковую дорожку за раз, поэтому смесь
ему приходится собирать самому: каждая дорожка монтажа подаётся своим
списком кусков, а дальше они складываются его же средствами. }
function FlattenAudioTrack(AProject: TProject; ATrack: TTrack;
const ATotal: Double;
out ASegments: TFlatSegmentArray; out AMissing: TGenRequests): Boolean;
{ Длина слышимого материала одного вида дорожек. }
function AudibleEnd(AProject: TProject; AKind: TTrackKind): Double;
@ -274,6 +283,70 @@ var
AProject.Master.Height);
end;
{ Все звучащие клипы в этот момент, со всех слышимых звуковых дорожек.
Если звучит ровно один и без перехода, слои не нужны — кусок описан
обычным способом. }
procedure CollectAudioLayers(AWinner, APartner: TClip;
const AAt, ASegStart: Double; var ASeg: TFlatSegment);
var
Ti, C, N: Integer;
Track: TTrack;
Cl: TClip;
procedure Push(const ALayer: TSegmentLayer);
begin
SetLength(ASeg.Layers, N + 1);
ASeg.Layers[N] := ALayer;
Inc(N);
end;
begin
N := 0;
ASeg.Layers := nil;
if APartner <> nil then
begin
{ Переход на дорожке победителя: один уходит, другой приходит. }
if APartner.TlStart < AWinner.TlStart then
begin
Push(LayerOf(APartner, ASegStart, APartner.Volume, 0));
Push(LayerOf(AWinner, ASegStart, 0, AWinner.Volume));
end
else
begin
Push(LayerOf(AWinner, ASegStart, AWinner.Volume, 0));
Push(LayerOf(APartner, ASegStart, 0, APartner.Volume));
end;
end
else
Push(LayerOf(AWinner, ASegStart, AWinner.Volume, AWinner.Volume));
{ Всё, что звучит одновременно на других звуковых дорожках. }
for Ti := 0 to AProject.TrackCount - 1 do
begin
Track := AProject.Tracks[Ti];
if Track.Kind <> tkAudio then
Continue;
if not AProject.TrackAudible(Track) then
Continue;
for C := 0 to Track.Count - 1 do
begin
Cl := Track[C];
if not Cl.Covers(AAt) then
Continue;
if (Cl = AWinner) or (Cl = APartner) then
Continue;
Push(LayerOf(Cl, ASegStart, Cl.Volume, Cl.Volume));
end;
end;
{ Звучит ровно один клип и без перехода — смешивать нечего, кусок
описывается обычным способом и обходится без пересборки. }
if N < 2 then
ASeg.Layers := nil;
end;
{ Клип, побеждающий в указанный момент: с самой верхней дорожки. }
function WinnerAt(const ATime: Double): TClip;
var
@ -531,7 +604,19 @@ begin
end;
end;
if (Partner <> nil) or (Under <> nil) then
{ Звуковые дорожки смешиваются, а не побеждают друг друга.
Правило «верхний побеждает» придумано для картинки: оно и делает
возможной сборку копированием. Для звука оно бессмысленно — без
смешивания нельзя положить фоновую музыку под голос. И стоит оно
ничего: звук у нас всё равно перекодируется. }
if AKind = tkAudio then
begin
CollectAudioLayers(Winner, Partner, Mid, SegStart, Seg);
if Length(Seg.Layers) > 0 then
Seg.Transcode := True;
end
else if (Partner <> nil) or (Under <> nil) then
begin
SetLength(Seg.Layers, 2);
if Partner <> nil then
@ -626,6 +711,85 @@ begin
Result := True;
end;
function FlattenAudioTrack(AProject: TProject; ATrack: TTrack;
const ATotal: Double;
out ASegments: TFlatSegmentArray; out AMissing: TGenRequests): Boolean;
var
C, N: Integer;
At: Double;
Clip: TClip;
Item: TMediaItem;
Seg: TFlatSegment;
Req: TGenRequest;
Path: string;
procedure PushSilence(const ALen: Double);
begin
if ALen <= 0.0005 then
Exit;
Seg := Default(TFlatSegment);
Seg.Volume := 1;
Seg.Kind := skBlack;
Seg.Duration := ALen;
Req.Kind := gkBlack;
Req.SourcePath := '';
Req.Duration := BucketFor(ALen);
Req.TargetPath := GeneratedPath(AProject.CacheDir, Req);
Seg.FilePath := Req.TargetPath;
if not FileExists(Req.TargetPath) then
begin
SetLength(AMissing, Length(AMissing) + 1);
AMissing[High(AMissing)] := Req;
end;
SetLength(ASegments, N + 1);
ASegments[N] := Seg;
Inc(N);
end;
begin
ASegments := nil;
AMissing := nil;
Result := False;
if (AProject = nil) or (ATrack = nil) then
Exit;
N := 0;
At := 0;
ATrack.Sort;
for C := 0 to ATrack.Count - 1 do
begin
Clip := ATrack[C];
{ Клипы на дорожке могут наползать друг на друга. В предпросмотре это
разбирается проще некуда: наползший кусок начинается там, где
кончился предыдущий. Переход в предпросмотре не показывается — он
виден в готовом ролике. }
if Clip.TlEnd <= At + 0.0005 then
Continue;
if Clip.TlStart > At then
PushSilence(Clip.TlStart - At);
Item := AProject.FindMedia(Clip.MediaId);
if Item = nil then
Continue;
Seg := Default(TFlatSegment);
Seg.Kind := skVideo;
Seg.Volume := Clip.Volume;
Seg.FilePath := Item.EffectivePath;
Seg.SrcStart := Clip.SrcIn + Max(0, At - Clip.TlStart);
Seg.Duration := Clip.TlEnd - Max(At, Clip.TlStart);
SetLength(ASegments, N + 1);
ASegments[N] := Seg;
Inc(N);
At := Clip.TlEnd;
end;
if ATotal > At then
PushSilence(ATotal - At);
Result := N > 0;
end;
function FlattenProject(AProject: TProject;
out AVideo, AAudio: TFlatSegmentArray;
out AMissing: TGenRequests): Boolean;

View file

@ -92,6 +92,7 @@ type
находить звучащий кусок и брать у него громкость. }
FPreviewSegments: TFlatSegmentArray;
FLastVolumeSent: Double;
FLastVolumeKey: string;
FGenerate: TGenerateThread;
FGenerateBusy: Boolean;
@ -1337,8 +1338,12 @@ end;
procedure TMainForm.LoadPreviewEdl(const ASegments, AAudio: TFlatSegmentArray);
var
Edl, AudioEdl: string;
Pos, Total: Double;
Pos, Total, TotalLen: Double;
WasPaused: Boolean;
Urls: array of string;
TrackSegs: TFlatSegmentArray;
TrackMissing: TGenRequests;
I: Integer;
begin
{ Кадр проекта задаётся до загрузки: mpv должен показывать то, что
получится в готовом ролике, а не размер очередного исходника. Файл
@ -1347,13 +1352,32 @@ begin
ApplyPreviewFrame;
Edl := BuildEdlUri(ASegments);
{ Звук идёт отдельным списком кусков и подаётся плееру как внешняя
звуковая дорожка. Иначе картинку и звук из разных мест монтажа
одновременно не показать: у одного файла звук всегда свой.
{ Каждая звуковая дорожка идёт своим списком кусков.
Проверено замером: mpv принимает edl:// в качестве внешнего звука и
берёт его именно оттуда. }
AudioEdl := BuildEdlUri(AAudio);
Одним общим списком не обойтись: звуковые дорожки смешиваются, а не
побеждают друг друга, иначе под голос не положить музыку. Плеер играет
одну дорожку за раз, поэтому смесь он собирает своими средствами, а
мы даём ему каждую по отдельности. }
Urls := nil;
TotalLen := Max(AudibleEnd(FProject, tkVideo), AudibleEnd(FProject, tkAudio));
for I := 0 to FProject.TrackCount - 1 do
begin
if FProject.Tracks[I].Kind <> tkAudio then
Continue;
if not FProject.TrackAudible(FProject.Tracks[I]) then
Continue;
if FProject.Tracks[I].Count = 0 then
Continue;
if not FlattenAudioTrack(FProject, FProject.Tracks[I], TotalLen,
TrackSegs, TrackMissing) then
Continue;
SetLength(Urls, Length(Urls) + 1);
Urls[High(Urls)] := BuildEdlUri(TrackSegs);
end;
AudioEdl := '';
for I := 0 to High(Urls) do
AudioEdl := AudioEdl + Urls[I] + '|';
{ Список кусков запоминаем всегда, даже если строка не изменилась:
громкость в неё не входит, а брать её потом неоткуда. }
@ -1374,7 +1398,7 @@ begin
перемоткой следом. Иначе mpv успевает начать с нуля, и на каждой
правке монтажа слышен и виден кусок не оттуда. }
{ Внешний звук назначается до загрузки: он часть того, что открывается. }
FPlayer.SetAudioSource(AudioEdl);
FPlayer.SetAudioSources(Urls);
FPlayer.LoadUrl(Edl, Format('start=%s,pause=%s',
[SecondsToStr(Pos), BoolToStr(WasPaused, 'yes', 'no')]));
FLastLoadedEdl := Edl;
@ -1852,32 +1876,50 @@ begin
AddClipFromPool(FProject.PoolCount - 1, TrackIdx, DropTime);
end;
{ Громкость клипов в предпросмотре.
Дорожки смешиваются, поэтому одной общей громкостью не обойтись: у
каждой дорожки под курсором свой клип со своей громкостью. Значения
собираются по дорожкам и отдаются плееру; он подставляет их в свою
цепочку смешивания.
Пересобирать цепочку на каждый кадр нельзя — она создаётся заново и
звук щёлкает. Поэтому только при изменении. }
procedure TMainForm.ApplyVolumeAt(const ATime: Double);
var
I: Integer;
Acc, Vol: Double;
Clip: TClip;
Vols: array of Double;
Key: string;
begin
if (FPlayer = nil) or (not FPlayer.Initialized) then
Exit;
if Length(FPreviewSegments) = 0 then
if FProject = nil then
Exit;
Vol := 1;
Acc := 0;
for I := 0 to High(FPreviewSegments) do
Vols := nil;
Key := '';
for I := 0 to FProject.TrackCount - 1 do
begin
if (ATime >= Acc) and (ATime < Acc + FPreviewSegments[I].Duration) then
begin
Vol := FPreviewSegments[I].Volume;
Break;
end;
Acc := Acc + FPreviewSegments[I].Duration;
if FProject.Tracks[I].Kind <> tkAudio then
Continue;
if not FProject.TrackAudible(FProject.Tracks[I]) then
Continue;
if FProject.Tracks[I].Count = 0 then
Continue;
Clip := FProject.Tracks[I].ClipAt(ATime);
SetLength(Vols, Length(Vols) + 1);
if Clip <> nil then
Vols[High(Vols)] := Clip.Volume
else
Vols[High(Vols)] := 1;
Key := Key + SecondsToStr(Vols[High(Vols)]) + ',';
end;
if Abs(Vol - FLastVolumeSent) < 0.001 then
if Key = FLastVolumeKey then
Exit;
FLastVolumeSent := Vol;
FPlayer.SetVolume(Vol * 100);
FLastVolumeKey := Key;
FPlayer.SetTrackVolumes(Vols);
end;
procedure TMainForm.GenerateProgress(Sender: TObject);

View file

@ -58,6 +58,11 @@ type
FVideoFilter: string;
FHwdec: string;
FAudioSource: string;
FAudioSources: array of string;
FTrackVolumes: array of Double;
{ Номера подключённых звуковых дорожек, в порядке подключения. }
FAudioIds: array of string;
FLastGraph: string;
{ Что произошло с выбором звуковой дорожки. Читается окном для
журнала: без этого понять, откуда взялся звук, нечем. }
FAudioTrackNote: string;
@ -91,6 +96,8 @@ type
усмотрению. Получалось, что звук слышен там, где на монтаже его нет
вовсе — он приходил из видеофайла, а не с звуковой дорожки. }
procedure SelectExternalAudio;
{ Цепочка смешивания звуковых дорожек с их громкостями. }
procedure BuildAudioGraph;
function SetOption(const AName, AValue: string): Integer;
public
constructor Create(AVideoParent: TWinControl);
@ -153,6 +160,14 @@ type
Пустая строка снимает внешний звук. }
procedure SetAudioSource(const AUrl: string);
{ Несколько звуковых дорожек монтажа разом. Плеер играет только одну,
поэтому смесь собирается его же средствами — см. AttachAudio. }
procedure SetAudioSources(const AUrls: array of string);
{ Громкость каждой звуковой дорожки монтажа. Подставляется в цепочку
смешивания: одной общей громкостью тут не обойтись. }
procedure SetTrackVolumes(const AVolumes: array of Double);
function GetPropString(const AName: string): string;
function GetPropDouble(const AName: string; out AValue: Double): Boolean;
@ -657,10 +672,39 @@ begin
end;
procedure TMpvPlayer.SetAudioSource(const AUrl: string);
begin
SetAudioSources([AUrl]);
end;
procedure TMpvPlayer.SetAudioSources(const AUrls: array of string);
var
I, N: Integer;
begin
{ Здесь только запоминаем. Подключается звук после загрузки картинки,
командой audio-add — см. AttachAudioSource. }
FAudioSource := AUrl;
командой audio-add — см. SelectExternalAudio. }
SetLength(FAudioSources, Length(AUrls));
N := 0;
for I := 0 to High(AUrls) do
if AUrls[I] <> '' then
begin
FAudioSources[N] := AUrls[I];
Inc(N);
end;
SetLength(FAudioSources, N);
if N > 0 then
FAudioSource := FAudioSources[0]
else
FAudioSource := '';
end;
procedure TMpvPlayer.SetTrackVolumes(const AVolumes: array of Double);
var
I: Integer;
begin
SetLength(FTrackVolumes, Length(AVolumes));
for I := 0 to High(AVolumes) do
FTrackVolumes[I] := AVolumes[I];
BuildAudioGraph;
end;
procedure TMpvPlayer.SetHwdec(const AMode: string);
@ -743,9 +787,52 @@ begin
end;
end;
procedure TMpvPlayer.BuildAudioGraph;
var
I: Integer;
Graph, Vol: string;
begin
if FHandle = nil then
Exit;
if Length(FAudioIds) = 0 then
Exit;
{ Одна дорожка играется как есть: цепочка фильтров тут только мешала бы,
да и громкость для неё задаётся обычным способом. }
if Length(FAudioIds) = 1 then
begin
Graph := '';
if (Length(FTrackVolumes) > 0) and (Abs(FTrackVolumes[0] - 1) > 0.001) then
SetVolume(FTrackVolumes[0] * 100)
else
SetVolume(100);
end
else
begin
Graph := '';
for I := 0 to High(FAudioIds) do
begin
if I <= High(FTrackVolumes) then
Vol := SecondsToStr(FTrackVolumes[I])
else
Vol := '1';
Graph := Graph + Format('[aid%s]volume=%s[t%d];', [FAudioIds[I], Vol, I]);
end;
for I := 0 to High(FAudioIds) do
Graph := Graph + Format('[t%d]', [I]);
Graph := Graph + Format('amix=inputs=%d:normalize=0[ao]',
[Length(FAudioIds)]);
end;
if Graph = FLastGraph then
Exit;
FLastGraph := Graph;
mpv_set_property_string(FHandle, 'lavfi-complex', PAnsiChar(Graph));
end;
procedure TMpvPlayer.SelectExternalAudio;
var
Count, I: Integer;
Count, I, Found: Integer;
Kind, Ext, Id: string;
begin
if FHandle = nil then
@ -755,8 +842,7 @@ begin
if FAudioSource = '' then
Exit;
{ Подключаем свой список кусков как отдельную звуковую дорожку и сразу
выбираем её.
{ Подключаем каждую звуковую дорожку монтажа отдельным списком кусков.
Через свойство audio-files это не работает: проверено на живом mpv —
список дорожек после загрузки остаётся прежним, внешний файл не
@ -766,8 +852,15 @@ begin
Команда audio-add с признаком select делает ровно то, что нужно:
добавляет дорожку, выбирает её и снимает выбор с внутренней. }
Command(['audio-add', FAudioSource, 'select']);
for I := 0 to High(FAudioSources) do
if I = 0 then
Command(['audio-add', FAudioSources[I], 'select'])
else
Command(['audio-add', FAudioSources[I], 'auto']);
{ Собираем номера всех подключённых звуковых дорожек. }
FAudioIds := nil;
Found := 0;
Count := StrToIntDef(GetPropString('track-list/count'), 0);
for I := 0 to Count - 1 do
begin
@ -778,11 +871,22 @@ begin
if (Ext <> 'yes') and (Ext <> 'true') then
Continue;
Id := GetPropString(Format('track-list/%d/id', [I]));
if Id <> '' then
begin
if Id = '' then
Continue;
if Found = 0 then
mpv_set_property_string(FHandle, 'aid', PAnsiChar(Id));
FAudioTrackNote := 'Звук: отдельная дорожка монтажа (' + Id + ').';
end;
SetLength(FAudioIds, Found + 1);
FAudioIds[Found] := Id;
Inc(Found);
end;
if Found > 0 then
begin
FAudioTrackNote := 'Звук: дорожек монтажа ' + IntToStr(Found) + '.';
{ Цепочка строится отдельно: она зависит ещё и от громкостей, а те
меняются по ходу воспроизведения. }
FLastGraph := '';
BuildAudioGraph;
Exit;
end;