TrimWindow.axaml.cs 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262
  1. using System;
  2. using System.Diagnostics;
  3. using System.Globalization;
  4. using System.IO;
  5. using System.Runtime.InteropServices;
  6. using System.Threading.Tasks;
  7. using Avalonia.Controls;
  8. using Avalonia.Interactivity;
  9. using Avalonia.Threading;
  10. using SlamCross.Controls;
  11. namespace SlamCross.Views;
  12. public partial class TrimWindow : Window
  13. {
  14. // Trim byte positions are offsets into s16le @ 22050 Hz mono (matches AudioService)
  15. private const int TrimSampleRate = 22050;
  16. private const int TrimBytesPerSample = 2;
  17. private double _totalDuration;
  18. private Process? _previewFf;
  19. private Process? _previewPa;
  20. private bool _isPreviewing;
  21. private bool _suppressChange;
  22. private string _filePath = "";
  23. public bool Confirmed { get; private set; }
  24. public int StartPos { get; private set; }
  25. public int EndPos { get; private set; }
  26. public TrimWindow()
  27. {
  28. InitializeComponent();
  29. }
  30. public TrimWindow(string filePath, int startPos, int endPos)
  31. {
  32. InitializeComponent();
  33. _filePath = filePath;
  34. StartPos = startPos;
  35. EndPos = endPos;
  36. _ = LoadAsync(filePath, startPos, endPos);
  37. }
  38. private static double BytesToSec(int b) => b / (TrimSampleRate * (double)TrimBytesPerSample);
  39. private static int SecToBytes(double sec) => (int)(sec * TrimSampleRate * TrimBytesPerSample);
  40. private async Task LoadAsync(string filePath, int startPos, int endPos)
  41. {
  42. _totalDuration = await GetDurationAsync(filePath);
  43. double startSec = BytesToSec(startPos);
  44. double endSec = endPos > 0 ? BytesToSec(endPos) : _totalDuration;
  45. if (this.FindControl<TextBlock>("DurationLabel") is TextBlock lbl)
  46. lbl.Text = $"Duration: {_totalDuration:F2}s | Drag to select trim region";
  47. _suppressChange = true;
  48. if (this.FindControl<NumericUpDown>("StartSec") is NumericUpDown sb)
  49. {
  50. sb.Maximum = (decimal)_totalDuration;
  51. sb.Value = (decimal)startSec;
  52. }
  53. if (this.FindControl<NumericUpDown>("EndSec") is NumericUpDown eb)
  54. {
  55. eb.Maximum = (decimal)_totalDuration;
  56. eb.Value = (decimal)endSec;
  57. }
  58. _suppressChange = false;
  59. var peaks = await Task.Run(() => ComputePeaks(filePath, 4096));
  60. var wf = this.FindControl<WaveformControl>("Waveform");
  61. if (wf == null) return;
  62. wf.SetPeaks(peaks);
  63. wf.SetSelection(
  64. _totalDuration > 0 ? startSec / _totalDuration : 0,
  65. _totalDuration > 0 ? endSec / _totalDuration : 1);
  66. wf.SelectionChanged += (startNorm, endNorm) =>
  67. {
  68. double s = startNorm * _totalDuration;
  69. double e = endNorm * _totalDuration;
  70. _suppressChange = true;
  71. if (this.FindControl<NumericUpDown>("StartSec") is NumericUpDown nsb) nsb.Value = (decimal)s;
  72. if (this.FindControl<NumericUpDown>("EndSec") is NumericUpDown neb) neb.Value = (decimal)e;
  73. _suppressChange = false;
  74. };
  75. }
  76. private static async Task<double> GetDurationAsync(string filePath)
  77. {
  78. try
  79. {
  80. var psi = new ProcessStartInfo("ffprobe") { UseShellExecute = false, RedirectStandardOutput = true, CreateNoWindow = true };
  81. psi.ArgumentList.Add("-v"); psi.ArgumentList.Add("quiet");
  82. psi.ArgumentList.Add("-show_entries"); psi.ArgumentList.Add("format=duration");
  83. psi.ArgumentList.Add("-of"); psi.ArgumentList.Add("csv=p=0");
  84. psi.ArgumentList.Add(filePath);
  85. using var proc = Process.Start(psi)!;
  86. var s = await proc.StandardOutput.ReadToEndAsync();
  87. await proc.WaitForExitAsync();
  88. return double.TryParse(s.Trim(), NumberStyles.Any, CultureInfo.InvariantCulture, out var d) ? d : 0;
  89. }
  90. catch { return 0; }
  91. }
  92. private static (float Low, float High)[] ComputePeaks(string filePath, int binCount)
  93. {
  94. try
  95. {
  96. var psi = new ProcessStartInfo("ffmpeg") { UseShellExecute = false, RedirectStandardOutput = true, CreateNoWindow = true };
  97. psi.ArgumentList.Add("-i"); psi.ArgumentList.Add(filePath);
  98. psi.ArgumentList.Add("-f"); psi.ArgumentList.Add("f32le");
  99. psi.ArgumentList.Add("-ar"); psi.ArgumentList.Add("8000");
  100. psi.ArgumentList.Add("-ac"); psi.ArgumentList.Add("1");
  101. psi.ArgumentList.Add("-loglevel"); psi.ArgumentList.Add("quiet");
  102. psi.ArgumentList.Add("pipe:1");
  103. using var proc = Process.Start(psi)!;
  104. using var ms = new MemoryStream();
  105. proc.StandardOutput.BaseStream.CopyTo(ms);
  106. proc.WaitForExit();
  107. var bytes = ms.ToArray();
  108. int totalSamples = bytes.Length / 4;
  109. if (totalSamples == 0) return Array.Empty<(float, float)>();
  110. var samples = MemoryMarshal.Cast<byte, float>(bytes.AsSpan());
  111. int actualBins = Math.Min(binCount, totalSamples);
  112. var peaks = new (float Low, float High)[actualBins];
  113. for (int i = 0; i < actualBins; i++)
  114. {
  115. int binStart = (int)((long)i * totalSamples / actualBins);
  116. int binEnd = (int)((long)(i + 1) * totalSamples / actualBins);
  117. float lo = 0f, hi = 0f;
  118. for (int j = binStart; j < binEnd; j++)
  119. {
  120. float v = samples[j];
  121. if (v < lo) lo = v;
  122. if (v > hi) hi = v;
  123. }
  124. peaks[i] = (lo, hi);
  125. }
  126. return peaks;
  127. }
  128. catch { return Array.Empty<(float, float)>(); }
  129. }
  130. private void Time_Changed(object? sender, NumericUpDownValueChangedEventArgs e)
  131. {
  132. if (_suppressChange || _totalDuration <= 0) return;
  133. var wf = this.FindControl<WaveformControl>("Waveform");
  134. if (wf == null) return;
  135. double s = (double)(this.FindControl<NumericUpDown>("StartSec")?.Value ?? 0m);
  136. double en = (double)(this.FindControl<NumericUpDown>("EndSec")?.Value ?? (decimal)_totalDuration);
  137. wf.SetSelection(s / _totalDuration, en / _totalDuration);
  138. }
  139. private void StopPreview()
  140. {
  141. _isPreviewing = false;
  142. try { _previewFf?.Kill(); } catch { }
  143. try { _previewPa?.Kill(); } catch { }
  144. _previewFf = null;
  145. _previewPa = null;
  146. }
  147. private void Play_Click(object? sender, RoutedEventArgs e)
  148. {
  149. if (_isPreviewing)
  150. {
  151. StopPreview();
  152. if (this.FindControl<Button>("PlayButton") is Button sb) sb.Content = "▶ Preview";
  153. return;
  154. }
  155. double startSec = (double)(this.FindControl<NumericUpDown>("StartSec")?.Value ?? 0m);
  156. double endSec = (double)(this.FindControl<NumericUpDown>("EndSec")?.Value ?? (decimal)_totalDuration);
  157. var ffPsi = new ProcessStartInfo("ffmpeg") { UseShellExecute = false, RedirectStandardOutput = true, CreateNoWindow = true };
  158. ffPsi.ArgumentList.Add("-ss"); ffPsi.ArgumentList.Add(startSec.ToString("F5", CultureInfo.InvariantCulture));
  159. ffPsi.ArgumentList.Add("-i"); ffPsi.ArgumentList.Add(_filePath);
  160. if (endSec > startSec)
  161. {
  162. ffPsi.ArgumentList.Add("-t");
  163. ffPsi.ArgumentList.Add((endSec - startSec).ToString("F5", CultureInfo.InvariantCulture));
  164. }
  165. ffPsi.ArgumentList.Add("-f"); ffPsi.ArgumentList.Add("s16le");
  166. ffPsi.ArgumentList.Add("-ar"); ffPsi.ArgumentList.Add("48000");
  167. ffPsi.ArgumentList.Add("-ac"); ffPsi.ArgumentList.Add("1");
  168. ffPsi.ArgumentList.Add("-loglevel"); ffPsi.ArgumentList.Add("quiet");
  169. ffPsi.ArgumentList.Add("pipe:1");
  170. var paPsi = new ProcessStartInfo("pacat") { UseShellExecute = false, RedirectStandardInput = true, CreateNoWindow = true };
  171. paPsi.ArgumentList.Add("--playback");
  172. paPsi.ArgumentList.Add("--rate=48000");
  173. paPsi.ArgumentList.Add("--channels=1");
  174. paPsi.ArgumentList.Add("--format=s16le");
  175. Process ff, pa;
  176. try
  177. {
  178. ff = Process.Start(ffPsi)!;
  179. pa = Process.Start(paPsi)!;
  180. }
  181. catch { return; }
  182. _previewFf = ff;
  183. _previewPa = pa;
  184. _isPreviewing = true;
  185. if (this.FindControl<Button>("PlayButton") is Button btn) btn.Content = "■ Stop";
  186. Task.Run(async () =>
  187. {
  188. // ffmpeg pipes data into pacat and exits quickly; pacat buffers and plays it out in real time
  189. try { await ff.StandardOutput.BaseStream.CopyToAsync(pa.StandardInput.BaseStream); } catch { }
  190. try { pa.StandardInput.Close(); } catch { }
  191. // wait for pacat to finish playing before resetting the button
  192. ff.WaitForExit();
  193. pa.WaitForExit();
  194. Dispatcher.UIThread.Post(() =>
  195. {
  196. _isPreviewing = false;
  197. _previewFf = null;
  198. _previewPa = null;
  199. if (this.FindControl<Button>("PlayButton") is Button b) b.Content = "▶ Preview";
  200. });
  201. });
  202. }
  203. private void Reset_Click(object? sender, RoutedEventArgs e)
  204. {
  205. _suppressChange = true;
  206. if (this.FindControl<NumericUpDown>("StartSec") is NumericUpDown sb) sb.Value = 0m;
  207. if (this.FindControl<NumericUpDown>("EndSec") is NumericUpDown eb) eb.Value = (decimal)_totalDuration;
  208. _suppressChange = false;
  209. if (this.FindControl<WaveformControl>("Waveform") is WaveformControl wf)
  210. wf.SetSelection(0, 1);
  211. }
  212. private void OK_Click(object? sender, RoutedEventArgs e)
  213. {
  214. double startSec = (double)(this.FindControl<NumericUpDown>("StartSec")?.Value ?? 0m);
  215. double endSec = (double)(this.FindControl<NumericUpDown>("EndSec")?.Value ?? (decimal)_totalDuration);
  216. StartPos = startSec > 0.001 ? SecToBytes(startSec) : 0;
  217. // EndPos=0 means "play to end" - only set if user trimmed it short
  218. EndPos = (_totalDuration > 0 && endSec < _totalDuration - 0.1) ? SecToBytes(endSec) : 0;
  219. Confirmed = true;
  220. Close();
  221. }
  222. private void Cancel_Click(object? sender, RoutedEventArgs e) => Close();
  223. protected override void OnClosed(EventArgs e)
  224. {
  225. StopPreview();
  226. base.OnClosed(e);
  227. }
  228. }