Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 35 additions & 14 deletions App.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -34,11 +34,22 @@ private static void OnDispatcherUnhandledException(object sender, System.Windows
{
var logPath = WriteCrashLog(e.Exception);
var loc = LocalizationService.Instance;
System.Windows.MessageBox.Show(
$"{loc["Error_AppCrash"]}{Environment.NewLine}{Environment.NewLine}{loc["Error_CrashLogSaved"]}{Environment.NewLine}{logPath}",
"Audio Quality Enhancer",
System.Windows.MessageBoxButton.OK,
System.Windows.MessageBoxImage.Error);
var message = logPath is null
? loc["Error_AppCrash"]
: $"{loc["Error_AppCrash"]}{Environment.NewLine}{Environment.NewLine}{loc["Error_CrashLogSaved"]}{Environment.NewLine}{logPath}";

try
{
System.Windows.MessageBox.Show(
message,
"Audio Quality Enhancer",
System.Windows.MessageBoxButton.OK,
System.Windows.MessageBoxImage.Error);
}
catch
{
// Reporting the crash must never prevent the orderly shutdown below.
}

e.Handled = true;
Current.Shutdown(-1);
Expand All @@ -58,17 +69,27 @@ private static void OnUnobservedTaskException(object? sender, UnobservedTaskExce
e.SetObserved();
}

private static string WriteCrashLog(Exception exception)
/// <summary>Writes the crash log and returns its path, or null if it could not be written.</summary>
private static string? WriteCrashLog(Exception exception)
{
var logDirectory = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"AudioQualityEnhancer",
"Logs");
try
{
var logDirectory = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"AudioQualityEnhancer",
"Logs");

Directory.CreateDirectory(logDirectory);
Directory.CreateDirectory(logDirectory);

var logPath = Path.Combine(logDirectory, $"crash_{DateTime.Now:yyyyMMdd_HHmmss}.log");
File.WriteAllText(logPath, exception.ToString());
return logPath;
var logPath = Path.Combine(logDirectory, $"crash_{DateTime.Now:yyyyMMdd_HHmmss}.log");
File.WriteAllText(logPath, exception.ToString());
return logPath;
}
catch
{
// A failing crash log (read-only profile, full disk) must not throw inside
// the crash handler and turn a handled error into a hard termination.
return null;
}
}
}
13 changes: 13 additions & 0 deletions AudioQualityEnhancer.Tests/AppUpdateServiceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,19 @@ public void ParseVersion_StripsPrefixAndNormalizesToThreeParts(string? tag, stri
Assert.Equal(expected, AppUpdateService.ParseVersion(tag)?.ToString());
}

[Theory]
[InlineData("https://github.com/Kentarohakase/AudioQualityEnhancer/releases/tag/v0.18.0", "https://github.com/Kentarohakase/AudioQualityEnhancer/releases/tag/v0.18.0")]
[InlineData("http://github.com/Kentarohakase/AudioQualityEnhancer/releases", AppUpdateService.ReleasesPageUrl)]
[InlineData("https://example.com/releases", AppUpdateService.ReleasesPageUrl)]
[InlineData("file:///C:/Windows/System32/cmd.exe", AppUpdateService.ReleasesPageUrl)]
[InlineData("C:\\Windows\\System32\\cmd.exe", AppUpdateService.ReleasesPageUrl)]
[InlineData("", AppUpdateService.ReleasesPageUrl)]
[InlineData(null, AppUpdateService.ReleasesPageUrl)]
public void ResolveReleaseUrl_AcceptsOnlyHttpsProjectLinks(string? url, string expected)
{
Assert.Equal(expected, AppUpdateService.ResolveReleaseUrl(url));
}

[Theory]
[InlineData("0.16.0", "0.17.0", true)]
[InlineData("0.16.0", "0.16.1", true)]
Expand Down
45 changes: 45 additions & 0 deletions AudioQualityEnhancer.Tests/AudioValidationServiceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,51 @@ public void BuildReport_PremiereProfileRequires48Khz()
Assert.Contains(report.Findings, finding => finding.Kind == AudioComparisonFindingKind.SampleRateMismatch);
}

[Fact]
public void BuildReport_ArchivePresetKeepsSourceSampleRateWithPremiereSelected()
{
using var source = CreateInfo("flac", isLossy: false, sampleRate: 44_100);
using var output = CreateInfo("flac", isLossy: false, sampleRate: 44_100);
using var diagnostics = new AudioDiagnostics
{
IntegratedLoudnessLufs = -14,
TruePeakDb = -2
};

// The archive preset forces FLAC, so the selected Premiere profile (48 kHz)
// must not be validated against.
var report = AudioValidationService.BuildReport(
CreateOptions(source, AudioPreset.ArchiveExport, ExportFormat.PremierePro),
source,
output,
sourceDiagnostics: null,
diagnostics,
outputDiagnosticsSkipped: false,
outputPath: @"C:\audio\song_archive_flac.flac");

Assert.DoesNotContain(report.Findings, finding => finding.Kind == AudioComparisonFindingKind.SampleRateMismatch);
}

[Fact]
public void BuildReport_StreamCopyKeepsSourceSampleRateWithPremiereSelected()
{
using var source = CreateInfo("mp3", isLossy: true, sampleRate: 44_100);
using var output = CreateInfo("mp3", isLossy: true, sampleRate: 44_100);

var report = AudioValidationService.BuildReport(
CreateOptions(source, AudioPreset.ExtractCopy, ExportFormat.PremierePro),
source,
output,
sourceDiagnostics: null,
outputDiagnostics: null,
outputDiagnosticsSkipped: true,
outputPath: @"C:\audio\song_extracted.mp3");

Assert.DoesNotContain(report.Findings, finding => finding.Kind == AudioComparisonFindingKind.SampleRateMismatch);
Assert.DoesNotContain(report.Findings, finding => finding.Kind == AudioComparisonFindingKind.LossyToLossless);
Assert.Contains(report.Findings, finding => finding.Kind == AudioComparisonFindingKind.StreamCopyMetadataOnly);
}

[Fact]
public void BuildReport_ChannelCountChangeCreatesWarning()
{
Expand Down
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,19 @@
- A result validation that ends with a critical finding now fails the affected file instead of reporting it as exported successfully. The report stays attached to the item, so the findings remain visible in the validation panel.
- A run that was stopped is no longer misreported: an inactivity timeout now ends the wait for the process directly and is reported as a timeout, while only an actual user cancellation is reported as one.
- After a process is terminated the app waits briefly for it to exit and stops the output readers before reading what was captured, so the collected output can no longer be read while it is still being written.
- Result validation no longer expects 48 kHz when the Premiere profile is selected but the preset does not use it: the archive preset forces FLAC and a stream copy keeps the source format, so both reported a sample-rate warning for a correct export. A stream copy also no longer claims that a lossy source was written to a lossless format.
- An unexpected error in an asynchronous command (start, download, analysis, retry, preview) is now logged and shown as status instead of shutting the app down; cancelling a running command is treated as a normal outcome.
- A log file that cannot be written after a batch run (read-only or full output folder) is reported as a warning instead of ending the session through the crash handler. The exported files are already finished at that point.
- The crash handler no longer fails when the crash log itself cannot be written and shows the error without a log path in that case.

### Changed

- The release packaging script now requires an explicit `-Version` in `major.minor.patch` form instead of falling back to a stale default version.
- The tool check for FFmpeg, FFprobe and yt-dlp is bounded by a timeout: a binary that never answers is terminated and reported as unavailable instead of blocking the startup check indefinitely.
- Reading the remaining tool output after a process exits is bounded as well, so a pipe that a child process keeps open cannot hang a run.
- The update check accepts only an https link to the project host from the release API and otherwise falls back to the known releases page, because the link is handed to the shell when it is opened.

## 0.17.0 - 2026-06-13

### Added

Expand Down
3 changes: 3 additions & 0 deletions Resources/Strings.en.resx
Original file line number Diff line number Diff line change
Expand Up @@ -545,4 +545,7 @@
<data name="Log_PreviewSegmentFormat" xml:space="preserve"><value>Preview segment: loudest section starting at {0:0} seconds.</value></data>
<data name="Error_FFmpegTimeout" xml:space="preserve"><value>FFmpeg stopped responding and was terminated.</value></data>
<data name="Error_InsufficientDiskSpaceFormat" xml:space="preserve"><value>Not enough free disk space in the output folder (about {0} MB needed, {1} MB free).</value></data>
<data name="Error_ToolTimeoutFormat" xml:space="preserve"><value>{0} did not answer the check in time.</value></data>
<data name="Error_LogSaveFailed" xml:space="preserve"><value>The log file could not be saved. The exported files are not affected.</value></data>
<data name="Error_UnexpectedFormat" xml:space="preserve"><value>Unexpected error: {0}</value></data>
</root>
3 changes: 3 additions & 0 deletions Resources/Strings.resx
Original file line number Diff line number Diff line change
Expand Up @@ -546,4 +546,7 @@
<data name="Log_PreviewSegmentFormat" xml:space="preserve"><value>Vorschau-Abschnitt: lautester Bereich ab {0:0} Sekunden.</value></data>
<data name="Error_FFmpegTimeout" xml:space="preserve"><value>FFmpeg hat zu lange nicht reagiert und wurde beendet.</value></data>
<data name="Error_InsufficientDiskSpaceFormat" xml:space="preserve"><value>Zu wenig freier Speicherplatz im Ausgabeordner (benötigt etwa {0} MB, frei {1} MB).</value></data>
<data name="Error_ToolTimeoutFormat" xml:space="preserve"><value>{0} hat beim Prüfen nicht rechtzeitig geantwortet.</value></data>
<data name="Error_LogSaveFailed" xml:space="preserve"><value>Die Logdatei konnte nicht gespeichert werden. Die Ausgabedateien sind davon nicht betroffen.</value></data>
<data name="Error_UnexpectedFormat" xml:space="preserve"><value>Unerwarteter Fehler: {0}</value></data>
</root>
26 changes: 24 additions & 2 deletions Services/AppUpdateService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,14 @@ public sealed class AppUpdateService
private const string LatestReleaseApi = "https://api.github.com/repos/Kentarohakase/AudioQualityEnhancer/releases/latest";
internal const string ReleasesPageUrl = "https://github.com/Kentarohakase/AudioQualityEnhancer/releases/latest";

// One shared client for the whole app: the check runs once per session and a new
// client per instance would leak its connection pool.
private static readonly HttpClient SharedHttpClient = new() { Timeout = TimeSpan.FromSeconds(10) };

private readonly HttpClient _httpClient;

public AppUpdateService()
: this(new HttpClient { Timeout = TimeSpan.FromSeconds(10) })
: this(SharedHttpClient)
{
}

Expand Down Expand Up @@ -52,7 +56,7 @@ internal AppUpdateService(HttpClient httpClient)
}

var url = root.TryGetProperty("html_url", out var urlElement) ? urlElement.GetString() : null;
return new AppUpdateInfo(latest.ToString(), string.IsNullOrWhiteSpace(url) ? ReleasesPageUrl : url!);
return new AppUpdateInfo(latest.ToString(), ResolveReleaseUrl(url));
}
catch
{
Expand All @@ -61,6 +65,24 @@ internal AppUpdateService(HttpClient httpClient)
}
}

/// <summary>
/// The release link is handed to the shell when the user clicks the update notice,
/// so only an https link on the project host is accepted. Anything else (other
/// schemes, another host, a missing field) falls back to the known releases page.
/// </summary>
internal static string ResolveReleaseUrl(string? url)
{
if (!string.IsNullOrWhiteSpace(url) &&
Uri.TryCreate(url.Trim(), UriKind.Absolute, out var parsed) &&
parsed.Scheme == Uri.UriSchemeHttps &&
string.Equals(parsed.Host, "github.com", StringComparison.OrdinalIgnoreCase))
{
return parsed.AbsoluteUri;
}

return ReleasesPageUrl;
}

internal static Version? ParseVersion(string? tag)
{
if (string.IsNullOrWhiteSpace(tag))
Expand Down
14 changes: 12 additions & 2 deletions Services/AudioValidationService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -128,8 +128,10 @@ internal static AudioComparisonReport BuildReport(
AddPeakFindings(outputDiagnostics, findings);
AddLoudnessFinding(options, outputDiagnostics, findings);

// A stream copy keeps the source codec, so the selected export format says
// nothing about the result and the lossless note would be misleading.
var effectiveExportFormat = ExportFormat.ResolveForPreset(options.Preset, options.ExportFormat);
if (sourceInfo.IsLikelyLossy && effectiveExportFormat.IsLossless)
if (!options.Preset.IsCopyOnly && sourceInfo.IsLikelyLossy && effectiveExportFormat.IsLossless)
{
AddFinding(findings, AudioComparisonFindingKind.LossyToLossless, AudioInsightSeverity.Info);
}
Expand Down Expand Up @@ -416,7 +418,15 @@ private static bool RequiresOutputDiagnostics(AudioPreset preset)

private static int? GetExpectedSampleRate(ProcessingOptions options)
{
return options.ExportFormat.Id == ExportFormat.PremierePro.Id ? 48_000 : null;
// A stream copy keeps the source rate no matter which export format is selected,
// and the archive preset forces FLAC, so both must not expect the Premiere rate.
if (options.Preset.IsCopyOnly)
{
return null;
}

var exportFormat = ExportFormat.ResolveForPreset(options.Preset, options.ExportFormat);
return exportFormat.Id == ExportFormat.PremierePro.Id ? 48_000 : null;
}

private static bool CodecsMatch(string expectedCodec, string actualCodec)
Expand Down
18 changes: 12 additions & 6 deletions Services/ProcessRunner.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,13 @@ namespace AudioQualityEnhancer.Services;
internal sealed class ProcessRunner : IProcessRunner
{
private static readonly TimeSpan TerminationWaitTimeout = TimeSpan.FromSeconds(2);
private static readonly TimeSpan ReaderDrainTimeout = TimeSpan.FromSeconds(2);

// The output readers normally finish right after the process exits. A child process
// that inherited the pipes can keep them open, so the drain is bounded: generous on
// the normal path (the last lines carry the loudness measurements) and short after
// a cancellation, where the remaining output is discarded anyway.
private static readonly TimeSpan ReaderDrainTimeout = TimeSpan.FromSeconds(10);
private static readonly TimeSpan CancelDrainTimeout = TimeSpan.FromSeconds(2);

public async Task<ProcessResult> RunAsync(ProcessRunOptions options, CancellationToken cancellationToken)
{
Expand Down Expand Up @@ -96,7 +102,7 @@ public async Task<ProcessResult> RunAsync(ProcessRunOptions options, Cancellatio
}

await process.WaitForExitAsync(exitCancellation.Token).ConfigureAwait(false);
await TryWaitForReadersAsync(process, outputClosed.Task, errorClosed.Task).ConfigureAwait(false);
await TryWaitForReadersAsync(process, outputClosed.Task, errorClosed.Task, ReaderDrainTimeout).ConfigureAwait(false);

return CreateResult(process, stdout, stderr, startedAt, wasCancelled: false, timedOut: timedOutFlag == 1);
}
Expand All @@ -105,7 +111,7 @@ public async Task<ProcessResult> RunAsync(ProcessRunOptions options, Cancellatio
var timedOut = timedOutFlag == 1;
TryKill(process);
await TryWaitForExitAsync(process).ConfigureAwait(false);
await TryWaitForReadersAsync(process, outputClosed.Task, errorClosed.Task).ConfigureAwait(false);
await TryWaitForReadersAsync(process, outputClosed.Task, errorClosed.Task, CancelDrainTimeout).ConfigureAwait(false);
return CreateResult(
process,
stdout,
Expand Down Expand Up @@ -207,16 +213,16 @@ private static async Task TryWaitForExitAsync(Process process)
}
}

private static async Task TryWaitForReadersAsync(Process process, Task outputClosed, Task errorClosed)
private static async Task TryWaitForReadersAsync(Process process, Task outputClosed, Task errorClosed, TimeSpan timeout)
{
try
{
await Task.WhenAll(outputClosed, errorClosed).WaitAsync(ReaderDrainTimeout).ConfigureAwait(false);
await Task.WhenAll(outputClosed, errorClosed).WaitAsync(timeout).ConfigureAwait(false);
return;
}
catch
{
// Best effort drain after cancellation.
// Best effort drain; a reader that never closes must not hang the run.
}

TryCancelOutputRead(process);
Expand Down
Loading