diff --git a/src/ui/Features/Video/TextToSpeech/Engines/CloneReferenceTail.cs b/src/ui/Features/Video/TextToSpeech/Engines/CloneReferenceTail.cs
new file mode 100644
index 0000000000..b6dec7cd1c
--- /dev/null
+++ b/src/ui/Features/Video/TextToSpeech/Engines/CloneReferenceTail.cs
@@ -0,0 +1,226 @@
+using Nikse.SubtitleEdit.Logic.Config;
+using Nikse.SubtitleEdit.Logic.Media;
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.IO;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace Nikse.SubtitleEdit.Features.Video.TextToSpeech.Engines;
+
+///
+/// Prepares the reference WAV an in-context voice-cloning model is conditioned on, so the clone
+/// does not end the way the reference ends.
+///
+///
+/// Higgs Audio v3 clips kept ending in a rising broadband hiss over the last 200-300 ms after the
+/// codec-side fix (audio.cpp #454) had landed. Decoding identical codes with 8 or 64 frames of
+/// tail context gave the same samples, and holding the final frame's codes decoded to a steady
+/// hiss at the same level - so the noise is in the codes the language model emits, not in the
+/// codec. What decides it is the reference: the model continues the reference audio in context
+/// and ends its own clip the way the reference ends. A reference cut mid-noise or ending in room
+/// tone (film dialogue) yields a hissy ending; one that ends in clean silence yields a clean one.
+///
+/// So the reference is conditioned on a copy whose tail is trimmed to the last sound (at the same
+/// peak-relative threshold the pipeline trims with), faded out over
+/// and followed by of digital silence. On 48 seeded clips per
+/// treatment (four voices, same seeds) this moved the median level of the last four frames from
+/// -46 dBFS to -64 dBFS and loud endings (above -40 dBFS) from 18 to 3, with no runaway
+/// generations in 192 requests. The pad alone was not enough: an abrupt cut followed by silence
+/// made the model emit a single loud burst before its silence, which is why the fade is there.
+///
+///
+/// The prepared copy lives in a prepared folder next to the reference and is keyed on the
+/// reference's size and modification time plus , so an edited or
+/// re-imported voice is prepared again and a recipe change invalidates every cached copy.
+/// Preparation is best-effort: when ffmpeg is missing or fails, synthesis uses the reference as
+/// is, exactly as before.
+///
+///
+public static class CloneReferenceTail
+{
+ /// Bump when the recipe changes so cached copies made with the old one are redone.
+ public const int RecipeVersion = 1;
+
+ public const string PreparedFolderName = "prepared";
+
+ /// Long enough to hide the trim edge, short enough not to soften a final consonant.
+ public const double FadeOutSeconds = 0.05;
+
+ /// Enough silence for the model to read "the utterance is over" from the reference.
+ public const double SilencePadSeconds = 0.4;
+
+ public const int SampleRate = 24000;
+
+ ///
+ /// A prepared copy with less than this much audio before the pad means the trim ate the whole
+ /// reference (a clip that is all noise floor, or a threshold gone wrong) - use the original.
+ ///
+ public const double MinimumAudioSeconds = 1.0;
+
+ private static readonly TimeSpan FfmpegTimeout = TimeSpan.FromSeconds(60);
+ private static readonly SemaphoreSlim Gate = new(1, 1);
+ private static readonly HashSet FailedThisSession = new(StringComparer.Ordinal);
+
+ /// Where the prepared copy of goes.
+ public static string GetPreparedFileName(string referenceFileName)
+ {
+ var folder = Path.GetDirectoryName(referenceFileName) ?? string.Empty;
+ return Path.Combine(folder, PreparedFolderName, Path.GetFileNameWithoutExtension(referenceFileName) + ".wav");
+ }
+
+ ///
+ /// Identity of the reference the prepared copy was made from, or null when it cannot be read.
+ ///
+ public static string? BuildStamp(string referenceFileName)
+ {
+ try
+ {
+ var info = new FileInfo(referenceFileName);
+ if (!info.Exists)
+ {
+ return null;
+ }
+
+ return string.Create(CultureInfo.InvariantCulture, $"v{RecipeVersion}|{info.Length}|{info.LastWriteTimeUtc.Ticks}");
+ }
+ catch
+ {
+ return null;
+ }
+ }
+
+ /// Minimum byte size of a usable prepared copy: header plus audio plus pad.
+ public static long MinimumPreparedBytes =>
+ 44 + (long)((MinimumAudioSeconds + SilencePadSeconds) * SampleRate) * 2;
+
+ ///
+ /// The prepared copy of , made now if it is missing or
+ /// stale; the reference itself when it cannot be prepared. Never throws except for
+ /// cancellation.
+ ///
+ /// Engine name for log lines, e.g. "Higgs Audio v3 (audio.cpp)".
+ public static async Task PrepareAsync(string referenceFileName, string enginePrefix, CancellationToken cancellationToken)
+ {
+ if (string.IsNullOrEmpty(referenceFileName) || !File.Exists(referenceFileName))
+ {
+ return referenceFileName;
+ }
+
+ var stamp = BuildStamp(referenceFileName);
+ if (stamp == null)
+ {
+ return referenceFileName;
+ }
+
+ var prepared = GetPreparedFileName(referenceFileName);
+ var stampFileName = prepared + ".stamp";
+ if (IsCurrent(prepared, stampFileName, stamp))
+ {
+ return prepared;
+ }
+
+ await Gate.WaitAsync(cancellationToken);
+ try
+ {
+ if (IsCurrent(prepared, stampFileName, stamp))
+ {
+ return prepared;
+ }
+
+ if (FailedThisSession.Contains(referenceFileName))
+ {
+ return referenceFileName;
+ }
+
+ var partFileName = prepared + ".part.wav";
+ try
+ {
+ Directory.CreateDirectory(Path.GetDirectoryName(prepared)!);
+ TryDelete(partFileName);
+
+ var peakDbfs = await TtsSilenceThreshold.MeasurePeakDbfsAsync(referenceFileName, cancellationToken, FfmpegTimeout);
+ using (var ffmpeg = FfmpegGenerator.PrepareCloneReferenceTail(
+ referenceFileName,
+ partFileName,
+ TtsSilenceThreshold.Amplitude(peakDbfs),
+ FadeOutSeconds,
+ SilencePadSeconds,
+ SampleRate))
+ {
+ await ffmpeg.StartAndWaitAsync(cancellationToken, FfmpegTimeout);
+ if (ffmpeg.ExitCode != 0)
+ {
+ throw new InvalidOperationException($"ffmpeg exited with code {ffmpeg.ExitCode}");
+ }
+ }
+
+ var length = new FileInfo(partFileName).Length;
+ if (length < MinimumPreparedBytes)
+ {
+ throw new InvalidOperationException(
+ $"prepared copy is {length} bytes; less than {MinimumAudioSeconds:0.#} s of audio survived the trim");
+ }
+
+ File.Move(partFileName, prepared, overwrite: true);
+ File.WriteAllText(stampFileName, stamp);
+ Se.WriteToolsLog(
+ $"{enginePrefix}: prepared cloning reference '{Path.GetFileName(referenceFileName)}' "
+ + $"(peak {FormatDb(peakDbfs)}, trim threshold {TtsSilenceThreshold.DbLiteral(peakDbfs)}, "
+ + $"fade {FadeOutSeconds * 1000:0} ms, pad {SilencePadSeconds * 1000:0} ms) -> {prepared}");
+ return prepared;
+ }
+ catch (OperationCanceledException)
+ {
+ TryDelete(partFileName);
+ throw;
+ }
+ catch (Exception ex)
+ {
+ TryDelete(partFileName);
+ FailedThisSession.Add(referenceFileName);
+ Se.LogError(ex, $"{enginePrefix}: could not prepare cloning reference '{referenceFileName}'; using it as is");
+ Se.WriteToolsLog($"{enginePrefix}: could not prepare cloning reference '{referenceFileName}' ({ex.Message}); using it as is");
+ return referenceFileName;
+ }
+ }
+ finally
+ {
+ Gate.Release();
+ }
+ }
+
+ private static bool IsCurrent(string prepared, string stampFileName, string stamp)
+ {
+ try
+ {
+ return File.Exists(prepared)
+ && File.Exists(stampFileName)
+ && string.Equals(File.ReadAllText(stampFileName), stamp, StringComparison.Ordinal)
+ && new FileInfo(prepared).Length >= MinimumPreparedBytes;
+ }
+ catch
+ {
+ return false;
+ }
+ }
+
+ private static string FormatDb(double? dbfs) =>
+ dbfs.HasValue ? dbfs.Value.ToString("0.0", CultureInfo.InvariantCulture) + " dBFS" : "unknown";
+
+ private static void TryDelete(string fileName)
+ {
+ try
+ {
+ if (File.Exists(fileName))
+ {
+ File.Delete(fileName);
+ }
+ }
+ catch
+ {
+ // Best effort - a leftover .part file is overwritten by the next attempt.
+ }
+ }
+}
diff --git a/src/ui/Features/Video/TextToSpeech/Engines/HiggsTtsAudioCpp.cs b/src/ui/Features/Video/TextToSpeech/Engines/HiggsTtsAudioCpp.cs
index b9fc13df1f..738f361d08 100644
--- a/src/ui/Features/Video/TextToSpeech/Engines/HiggsTtsAudioCpp.cs
+++ b/src/ui/Features/Video/TextToSpeech/Engines/HiggsTtsAudioCpp.cs
@@ -420,6 +420,11 @@ public async Task Speak(
options["reference_text"] = referenceText;
}
+ // The clone ends the way the reference ends (see CloneReferenceTail), so condition on a
+ // copy whose tail is trimmed, faded and padded with silence. Falls back to the file as is.
+ var referencePath = await CloneReferenceTail.PrepareAsync(indexVoice.FilePath, Name, cancellationToken);
+ var referencePrepared = !string.Equals(referencePath, indexVoice.FilePath, StringComparison.Ordinal);
+
var payload = new Dictionary
{
["model"] = ServerModelId,
@@ -428,7 +433,7 @@ public async Task Speak(
["voice_ref"] = new Dictionary
{
["type"] = "path",
- ["path"] = indexVoice.FilePath,
+ ["path"] = referencePath,
},
};
@@ -438,66 +443,93 @@ public async Task Speak(
}
var body = JsonSerializer.Serialize(payload);
- using var content = new StringContent(body, Encoding.UTF8, "application/json");
- Se.WriteToolsLog($"Higgs Audio v3 (audio.cpp): POST {ServerBaseUrl}/v1/audio/speech (voice={indexVoice}, textLen={text.Length})");
+ Se.WriteToolsLog($"Higgs Audio v3 (audio.cpp): POST {ServerBaseUrl}/v1/audio/speech (voice={indexVoice}, textLen={text.Length}, preparedReference={referencePrepared})");
- HttpResponseMessage response;
- try
- {
- response = await HttpClient.PostAsync($"{ServerBaseUrl}/v1/audio/speech", content, cancellationToken);
- }
- catch (HttpRequestException ex)
+ for (var attempt = 1; ; attempt++)
{
- var serverLog = SnapshotServerLog();
- var launchCommand = _serverLaunchCommand;
- var died = _serverProcess?.HasExited == true;
- if (died)
+ // HttpClient disposes the request content with the request, so a retry needs a new one.
+ using var content = new StringContent(body, Encoding.UTF8, "application/json");
+ HttpResponseMessage response;
+ try
{
- StopServerInternal();
+ response = await HttpClient.PostAsync($"{ServerBaseUrl}/v1/audio/speech", content, cancellationToken);
}
-
- var failMsg = $"Higgs Audio v3 (audio.cpp) request failed — Voice: {indexVoice}, Text: {text}, "
- + $"RequestJson: {body}, ServerExited: {died}, ServerLog: {serverLog}"
- + LaunchCmdSuffix(launchCommand);
- Se.LogError(ex, failMsg);
- Se.WriteToolsLog(failMsg);
-
- throw new InvalidOperationException(
- (died
- ? "Higgs Audio v3 (audio.cpp) — the audiocpp_server process crashed during synthesis."
- : "Higgs Audio v3 (audio.cpp) request failed — the connection to audiocpp_server was dropped.")
- + (string.IsNullOrEmpty(serverLog) ? string.Empty : $"{Environment.NewLine}Server log:{Environment.NewLine}{serverLog}")
- + LaunchCmdSuffix(launchCommand),
- ex);
- }
-
- using (response)
- {
- if (!response.IsSuccessStatusCode)
+ catch (HttpRequestException ex)
{
- var errorBody = await SafeReadErrorAsync(response, cancellationToken);
var serverLog = SnapshotServerLog();
var launchCommand = _serverLaunchCommand;
- var errMsg = $"Higgs Audio v3 (audio.cpp) server error {(int)response.StatusCode} {response.StatusCode} — "
- + $"Voice: {indexVoice}, Text: {text}, RequestJson: {body}, "
- + $"ResponseBody: {errorBody}, ServerLog: {serverLog}"
+ var died = _serverProcess?.HasExited == true;
+ if (died)
+ {
+ StopServerInternal();
+ }
+
+ var failMsg = $"Higgs Audio v3 (audio.cpp) request failed — Voice: {indexVoice}, Text: {text}, "
+ + $"RequestJson: {body}, ServerExited: {died}, ServerLog: {serverLog}"
+ LaunchCmdSuffix(launchCommand);
- Se.LogError(errMsg);
- Se.WriteToolsLog(errMsg);
+ Se.LogError(ex, failMsg);
+ Se.WriteToolsLog(failMsg);
+
throw new InvalidOperationException(
- $"Higgs Audio v3 (audio.cpp) synthesis failed ({(int)response.StatusCode}): {errorBody}"
+ (died
+ ? "Higgs Audio v3 (audio.cpp) — the audiocpp_server process crashed during synthesis."
+ : "Higgs Audio v3 (audio.cpp) request failed — the connection to audiocpp_server was dropped.")
+ (string.IsNullOrEmpty(serverLog) ? string.Empty : $"{Environment.NewLine}Server log:{Environment.NewLine}{serverLog}")
- + LaunchCmdSuffix(launchCommand));
+ + LaunchCmdSuffix(launchCommand),
+ ex);
}
- await using var fileStream = File.Create(outputFileName);
- await using var contentStream = await response.Content.ReadAsStreamAsync(cancellationToken);
- await contentStream.CopyToAsync(fileStream, cancellationToken);
+ using (response)
+ {
+ if (!response.IsSuccessStatusCode)
+ {
+ var errorBody = await SafeReadErrorAsync(response, cancellationToken);
+ if (attempt < MaxSynthesisAttempts && IsRunawayGenerationError(errorBody))
+ {
+ // The model never emitted its end-of-audio token and ran to max_tokens - a
+ // rare sampling accident (1 in ~370 requests measured), not a property of
+ // the line. No seed is sent, so a retry samples afresh and normally succeeds.
+ Se.WriteToolsLog($"Higgs Audio v3 (audio.cpp): generation ran to max_tokens without an end-of-audio token (attempt {attempt} of {MaxSynthesisAttempts}) — retrying. Voice: {indexVoice}, Text: {text}");
+ continue;
+ }
+
+ var serverLog = SnapshotServerLog();
+ var launchCommand = _serverLaunchCommand;
+ var errMsg = $"Higgs Audio v3 (audio.cpp) server error {(int)response.StatusCode} {response.StatusCode} — "
+ + $"Voice: {indexVoice}, Text: {text}, RequestJson: {body}, "
+ + $"ResponseBody: {errorBody}, ServerLog: {serverLog}"
+ + LaunchCmdSuffix(launchCommand);
+ Se.LogError(errMsg);
+ Se.WriteToolsLog(errMsg);
+ throw new InvalidOperationException(
+ $"Higgs Audio v3 (audio.cpp) synthesis failed ({(int)response.StatusCode}): {errorBody}"
+ + (string.IsNullOrEmpty(serverLog) ? string.Empty : $"{Environment.NewLine}Server log:{Environment.NewLine}{serverLog}")
+ + LaunchCmdSuffix(launchCommand));
+ }
+
+ await using var fileStream = File.Create(outputFileName);
+ await using var contentStream = await response.Content.ReadAsStreamAsync(cancellationToken);
+ await contentStream.CopyToAsync(fileStream, cancellationToken);
+ }
+
+ break;
}
return new TtsResult(outputFileName, text);
}
+ /// One retry for a runaway generation; a second failure is reported.
+ private const int MaxSynthesisAttempts = 2;
+
+ ///
+ /// audio.cpp's "Higgs TTS generation reached max_tokens before EOC": the sampler never drew
+ /// the end-of-audio token. Matched loosely so a rewording upstream still counts.
+ ///
+ internal static bool IsRunawayGenerationError(string? errorBody) =>
+ !string.IsNullOrEmpty(errorBody)
+ && errorBody.Contains("max_tokens", StringComparison.OrdinalIgnoreCase)
+ && errorBody.Contains("EOC", StringComparison.Ordinal);
+
private static async Task EnsureServerRunningAsync(string modelKey, CancellationToken ct)
{
var backend = AudioCppRuntime.GetBackend();
diff --git a/src/ui/Logic/Media/FfmpegGenerator.cs b/src/ui/Logic/Media/FfmpegGenerator.cs
index 4c6372febe..090f71fe2a 100644
--- a/src/ui/Logic/Media/FfmpegGenerator.cs
+++ b/src/ui/Logic/Media/FfmpegGenerator.cs
@@ -1432,6 +1432,59 @@ public static string GetRemoveSegmentsParameters(
return arguments.Trim();
}
+ ///
+ /// Prepares a voice-cloning reference for an in-context TTS model (Higgs Audio v3): trailing
+ /// silence and noise under are trimmed off, the last
+ /// are faded out and of
+ /// digital silence are appended, written as mono PCM16 at .
+ /// See CloneReferenceTail for why: the model ends its clip the way the reference ends.
+ ///
+ public static Process PrepareCloneReferenceTail(
+ string inputFileName,
+ string outputFileName,
+ double silenceThreshold,
+ double fadeOutSeconds,
+ double silencePadSeconds,
+ int sampleRate = 24000,
+ DataReceivedEventHandler? dataReceivedHandler = null)
+ {
+ var process = new Process
+ {
+ StartInfo =
+ {
+ FileName = GetFfmpegLocation(),
+ Arguments = PrepareCloneReferenceTailParameters(inputFileName, outputFileName, silenceThreshold, fadeOutSeconds, silencePadSeconds, sampleRate),
+ UseShellExecute = false,
+ CreateNoWindow = true,
+ }
+ };
+
+ SetupDataReceiveHandler(dataReceivedHandler, process);
+
+ return process;
+ }
+
+ ///
+ /// Build the parameters for . The trim runs on the
+ /// reversed signal (silenceremove only trims the start), the fade is applied while still
+ /// reversed (afade=in on a reversed signal is a fade-out that needs no duration), and the
+ /// pad goes on last so it is never trimmed or faded.
+ ///
+ internal static string PrepareCloneReferenceTailParameters(
+ string inputFileName,
+ string outputFileName,
+ double silenceThreshold,
+ double fadeOutSeconds,
+ double silencePadSeconds,
+ int sampleRate)
+ {
+ var threshold = Math.Clamp(silenceThreshold, 0.000001, 1.0).ToString("0.########", CultureInfo.InvariantCulture);
+ var fade = Math.Max(0, fadeOutSeconds).ToString("0.###", CultureInfo.InvariantCulture);
+ var pad = Math.Max(0, silencePadSeconds).ToString("0.###", CultureInfo.InvariantCulture);
+ var filter = $"areverse,silenceremove=start_periods=1:start_silence=0:start_threshold={threshold},afade=t=in:d={fade},areverse,apad=pad_dur={pad}";
+ return $"-nostdin -y -i \"{inputFileName}\" -vn -af \"{filter}\" -ar {sampleRate} -ac 1 -c:a pcm_s16le \"{outputFileName}\"";
+ }
+
///
/// Build ffmpeg parameters for joining clips cut by
/// into one file, in the order listed in
diff --git a/tests/UI/Features/Video/TextToSpeech/Engines/CloneReferenceTailFfmpegTests.cs b/tests/UI/Features/Video/TextToSpeech/Engines/CloneReferenceTailFfmpegTests.cs
new file mode 100644
index 0000000000..437fa106b8
--- /dev/null
+++ b/tests/UI/Features/Video/TextToSpeech/Engines/CloneReferenceTailFfmpegTests.cs
@@ -0,0 +1,124 @@
+using Nikse.SubtitleEdit.Features.Video.TextToSpeech;
+using Nikse.SubtitleEdit.Features.Video.TextToSpeech.Engines;
+using Nikse.SubtitleEdit.Logic.Media;
+
+namespace UITests.Features.Video.TextToSpeech.Engines;
+
+///
+/// Runs the real ffmpeg preparation on a synthetic reference: 2 s of tone followed by 0.3 s of
+/// quiet noise (room tone under the peak-relative threshold) and an abrupt end. The prepared
+/// copy must lose the noise tail, gain the silence pad, and be reused on the next call. Skipped
+/// when no ffmpeg can be started (Windows CI without a configured ffmpeg).
+///
+public class CloneReferenceTailFfmpegTests
+{
+ private const double ToneSeconds = 2.0;
+ private const double NoiseSeconds = 0.3;
+
+ [Fact]
+ public async Task PrepareAsync_TrimsTheNoiseTail_PadsWithSilence_AndCaches()
+ {
+ if (!FfmpegRuns())
+ {
+ return;
+ }
+
+ var folder = Path.Combine(Path.GetTempPath(), "se-clone-tail-ffmpeg-" + Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(folder);
+ try
+ {
+ var reference = Path.Combine(folder, "voice.wav");
+ await MakeReferenceAsync(reference);
+
+ var prepared = await CloneReferenceTail.PrepareAsync(reference, "Test engine", CancellationToken.None);
+
+ Assert.Equal(CloneReferenceTail.GetPreparedFileName(reference), prepared);
+ Assert.True(File.Exists(prepared), "no prepared copy was written");
+ Assert.True(File.Exists(prepared + ".stamp"), "no stamp was written");
+
+ var samples = ReadPcm16(prepared);
+ var seconds = samples.Length / (double)CloneReferenceTail.SampleRate;
+
+ // Tone kept, noise tail gone, pad added: 2.0 s + 0.4 s, give or take the trim edge.
+ Assert.InRange(seconds, ToneSeconds + CloneReferenceTail.SilencePadSeconds - 0.05, ToneSeconds + CloneReferenceTail.SilencePadSeconds + 0.05);
+
+ var padSamples = (int)(CloneReferenceTail.SilencePadSeconds * CloneReferenceTail.SampleRate);
+ Assert.All(samples[^padSamples..], s => Assert.Equal(0, s));
+
+ // The 50 ms before the pad are faded, so the sample just before it is far below the
+ // tone's -20 dBFS peak (3277 in PCM16).
+ Assert.InRange(Math.Abs((int)samples[^(padSamples + 1)]), 0, 200);
+
+ var written = File.GetLastWriteTimeUtc(prepared);
+ var again = await CloneReferenceTail.PrepareAsync(reference, "Test engine", CancellationToken.None);
+ Assert.Equal(prepared, again);
+ Assert.Equal(written, File.GetLastWriteTimeUtc(prepared));
+ }
+ finally
+ {
+ try
+ {
+ Directory.Delete(folder, true);
+ }
+ catch
+ {
+ // best effort
+ }
+ }
+ }
+
+ private static async Task MakeReferenceAsync(string outputFileName)
+ {
+ // Tone at exactly -20 dBFS peak, then white noise around -66 dBFS peak: 46 dB under
+ // the peak, so it is under the -60 dBFS peak-relative trim threshold, and the clip
+ // ends on it without any silence - the shape of a reference cut out of film dialogue.
+ var inv = System.Globalization.CultureInfo.InvariantCulture;
+ var filter =
+ $"aevalsrc='0.1*sin(2*PI*220*t)':d={ToneSeconds.ToString(inv)}:s=24000[v];" +
+ $"anoisesrc=color=white:sample_rate=24000:duration={NoiseSeconds.ToString(inv)}:amplitude=0.0005[n];" +
+ "[v][n]concat=n=2:v=0:a=1,aformat=sample_fmts=s16:channel_layouts=mono[out]";
+ var arguments = $"-nostdin -y -filter_complex \"{filter}\" -map [out] \"{outputFileName}\"";
+ using var process = FfmpegGenerator.GetProcess(arguments, (_, _) => { });
+ await process.StartAndWaitAsync(CancellationToken.None);
+ Assert.True(File.Exists(outputFileName) && new FileInfo(outputFileName).Length > 44, "ffmpeg did not produce the test reference");
+ }
+
+ private static bool FfmpegRuns()
+ {
+ try
+ {
+ using var process = FfmpegGenerator.GetProcess("-version", (_, _) => { });
+ process.Start();
+ process.BeginOutputReadLine();
+ process.BeginErrorReadLine();
+ return process.WaitForExit(10_000) && process.ExitCode == 0;
+ }
+ catch
+ {
+ return false;
+ }
+ }
+
+ /// PCM16 mono samples from the RIFF data chunk.
+ private static short[] ReadPcm16(string fileName)
+ {
+ var bytes = File.ReadAllBytes(fileName);
+ var pos = 12;
+ while (pos + 8 <= bytes.Length)
+ {
+ var id = System.Text.Encoding.ASCII.GetString(bytes, pos, 4);
+ var size = BitConverter.ToInt32(bytes, pos + 4);
+ if (id == "data")
+ {
+ var dataBytes = size <= 0 || size > bytes.Length - pos - 8 ? bytes.Length - pos - 8 : size;
+ var samples = new short[dataBytes / 2];
+ Buffer.BlockCopy(bytes, pos + 8, samples, 0, samples.Length * 2);
+ return samples;
+ }
+
+ pos += 8 + size + (size & 1);
+ }
+
+ throw new InvalidDataException("no data chunk in " + fileName);
+ }
+}
diff --git a/tests/UI/Features/Video/TextToSpeech/Engines/CloneReferenceTailTests.cs b/tests/UI/Features/Video/TextToSpeech/Engines/CloneReferenceTailTests.cs
new file mode 100644
index 0000000000..e88f8111cd
--- /dev/null
+++ b/tests/UI/Features/Video/TextToSpeech/Engines/CloneReferenceTailTests.cs
@@ -0,0 +1,96 @@
+using Nikse.SubtitleEdit.Features.Video.TextToSpeech.Engines;
+
+namespace UITests.Features.Video.TextToSpeech.Engines;
+
+public class CloneReferenceTailTests
+{
+ [Fact]
+ public void PreparedFileName_IsInPreparedFolderNextToTheReference()
+ {
+ var reference = Path.Combine("voices", "Sophie_Anderson.wav");
+
+ var prepared = CloneReferenceTail.GetPreparedFileName(reference);
+
+ Assert.Equal(Path.Combine("voices", "prepared", "Sophie_Anderson.wav"), prepared);
+ }
+
+ [Fact]
+ public void Stamp_ChangesWhenTheReferenceChanges_AndIsNullWhenMissing()
+ {
+ var folder = Path.Combine(Path.GetTempPath(), "se-clone-tail-" + Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(folder);
+ try
+ {
+ var reference = Path.Combine(folder, "voice.wav");
+ File.WriteAllBytes(reference, new byte[100]);
+ var first = CloneReferenceTail.BuildStamp(reference);
+ Assert.NotNull(first);
+ Assert.StartsWith($"v{CloneReferenceTail.RecipeVersion}|100|", first);
+
+ File.WriteAllBytes(reference, new byte[200]);
+ File.SetLastWriteTimeUtc(reference, DateTime.UtcNow.AddMinutes(1));
+ Assert.NotEqual(first, CloneReferenceTail.BuildStamp(reference));
+
+ Assert.Null(CloneReferenceTail.BuildStamp(Path.Combine(folder, "missing.wav")));
+ }
+ finally
+ {
+ Directory.Delete(folder, recursive: true);
+ }
+ }
+
+ [Fact]
+ public async Task PrepareAsync_MissingReference_ReturnsItUnchanged()
+ {
+ var missing = Path.Combine(Path.GetTempPath(), "se-clone-tail-missing-" + Guid.NewGuid().ToString("N") + ".wav");
+
+ var result = await CloneReferenceTail.PrepareAsync(missing, "Test engine", CancellationToken.None);
+
+ Assert.Equal(missing, result);
+ }
+
+ [Fact]
+ public async Task PrepareAsync_ReusesACurrentPreparedCopy_WithoutRunningFfmpeg()
+ {
+ var folder = Path.Combine(Path.GetTempPath(), "se-clone-tail-" + Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(folder);
+ try
+ {
+ var reference = Path.Combine(folder, "voice.wav");
+ File.WriteAllBytes(reference, new byte[100]);
+ var prepared = CloneReferenceTail.GetPreparedFileName(reference);
+ Directory.CreateDirectory(Path.GetDirectoryName(prepared)!);
+ File.WriteAllBytes(prepared, new byte[CloneReferenceTail.MinimumPreparedBytes]);
+ File.WriteAllText(prepared + ".stamp", CloneReferenceTail.BuildStamp(reference)!);
+ var before = File.GetLastWriteTimeUtc(prepared);
+
+ var result = await CloneReferenceTail.PrepareAsync(reference, "Test engine", CancellationToken.None);
+
+ Assert.Equal(prepared, result);
+ Assert.Equal(before, File.GetLastWriteTimeUtc(prepared));
+ }
+ finally
+ {
+ Directory.Delete(folder, recursive: true);
+ }
+ }
+
+ [Fact]
+ public void MinimumPreparedBytes_CoversOneSecondPlusThePad()
+ {
+ // 44-byte header + (1.0 s + 0.4 s) at 24 kHz mono PCM16.
+ Assert.Equal(44 + (long)(1.4 * 24000) * 2, CloneReferenceTail.MinimumPreparedBytes);
+ }
+
+ [Theory]
+ [InlineData("{\"error\":{\"message\":\"Higgs TTS generation reached max_tokens before EOC\",\"type\":\"server_error\"}}", true)]
+ [InlineData("Higgs TTS generation reached MAX_TOKENS before EOC", true)]
+ [InlineData("unsupported model family hint: higgs_audio_tts", false)]
+ [InlineData("max_tokens must be non-negative", false)]
+ [InlineData("", false)]
+ [InlineData(null, false)]
+ public void RunawayGeneration_IsRecognisedFromTheServerError(string? body, bool expected)
+ {
+ Assert.Equal(expected, HiggsTtsAudioCpp.IsRunawayGenerationError(body));
+ }
+}
diff --git a/tests/UI/Logic/Media/FfmpegGeneratorCloneReferenceTailTests.cs b/tests/UI/Logic/Media/FfmpegGeneratorCloneReferenceTailTests.cs
new file mode 100644
index 0000000000..2369f8cc46
--- /dev/null
+++ b/tests/UI/Logic/Media/FfmpegGeneratorCloneReferenceTailTests.cs
@@ -0,0 +1,44 @@
+using Nikse.SubtitleEdit.Logic.Media;
+using System.Globalization;
+
+namespace UITests.Logic.Media;
+
+public class FfmpegGeneratorCloneReferenceTailTests
+{
+ [Fact]
+ public void Parameters_TrimFadeThenPad_InThatOrder()
+ {
+ var args = FfmpegGenerator.PrepareCloneReferenceTailParameters("ref.wav", "out.wav", 0.01, 0.05, 0.4, 24000);
+
+ // Trim and fade run on the reversed signal so they work on the tail; the pad comes after
+ // the second areverse so it is neither trimmed nor faded.
+ Assert.Contains(
+ "-af \"areverse,silenceremove=start_periods=1:start_silence=0:start_threshold=0.01,afade=t=in:d=0.05,areverse,apad=pad_dur=0.4\"",
+ args);
+ Assert.Contains("-ar 24000 -ac 1 -c:a pcm_s16le \"out.wav\"", args);
+ Assert.StartsWith("-nostdin -y -i \"ref.wav\"", args);
+ }
+
+ [Fact]
+ public void Parameters_ThresholdIsClampedAndInvariant()
+ {
+ var previous = CultureInfo.CurrentCulture;
+ try
+ {
+ CultureInfo.CurrentCulture = new CultureInfo("da-DK");
+ var args = FfmpegGenerator.PrepareCloneReferenceTailParameters("r.wav", "o.wav", 0.00568853, 0.05, 0.4, 24000);
+ Assert.Contains("start_threshold=0.00568853,", args);
+ Assert.Contains("d=0.05,", args);
+ Assert.Contains("pad_dur=0.4\"", args);
+
+ var clamped = FfmpegGenerator.PrepareCloneReferenceTailParameters("r.wav", "o.wav", 5.0, -1, -1, 24000);
+ Assert.Contains("start_threshold=1,", clamped);
+ Assert.Contains("afade=t=in:d=0,", clamped);
+ Assert.Contains("apad=pad_dur=0\"", clamped);
+ }
+ finally
+ {
+ CultureInfo.CurrentCulture = previous;
+ }
+ }
+}