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; + } + } +}