Files
LanMountainDesktop/LanMontainDesktop/Services/IAudioRecorderService.cs
lincube e8276c4d1e 0.2.7
修改天气组件,ci工作流
2026-03-04 02:02:34 +08:00

668 lines
17 KiB
C#

using System;
using System.Buffers;
using System.Collections.Generic;
using System.IO;
using System.Runtime.InteropServices;
using PortAudioSharp;
using PortAudioStream = PortAudioSharp.Stream;
namespace LanMontainDesktop.Services;
public enum AudioRecorderRuntimeState
{
Unsupported = 0,
Ready = 1,
Recording = 2,
Paused = 3,
Error = 4
}
public sealed record AudioRecorderSnapshot(
AudioRecorderRuntimeState State,
TimeSpan Duration,
double InputLevel,
string LastSavedFilePath,
string LastError)
{
public bool IsSupported => State != AudioRecorderRuntimeState.Unsupported;
}
public interface IAudioRecorderService : IDisposable
{
AudioRecorderSnapshot GetSnapshot();
bool StartOrResume();
bool Pause();
string? StopAndSave(string? outputPath = null);
void Discard();
}
public static class AudioRecorderServiceFactory
{
private static readonly Lazy<IAudioRecorderService> SharedService = new(
() =>
{
if (!OperatingSystem.IsWindows() && !OperatingSystem.IsLinux() && !OperatingSystem.IsMacOS())
{
return new NoOpAudioRecorderService("Unsupported platform");
}
return new PortAudioRecorderService();
},
isThreadSafe: true);
public static IAudioRecorderService CreateDefault()
{
return SharedService.Value;
}
}
internal sealed class NoOpAudioRecorderService(string reason) : IAudioRecorderService
{
private readonly AudioRecorderSnapshot _snapshot = new(
AudioRecorderRuntimeState.Unsupported,
TimeSpan.Zero,
0,
string.Empty,
reason);
public AudioRecorderSnapshot GetSnapshot()
{
return _snapshot;
}
public bool StartOrResume()
{
return false;
}
public bool Pause()
{
return false;
}
public string? StopAndSave(string? outputPath = null)
{
return null;
}
public void Discard()
{
}
public void Dispose()
{
}
}
public sealed class PortAudioRecorderService : IAudioRecorderService
{
private const int ChannelCount = 1;
private const int BitsPerSample = 16;
private const int BytesPerSample = BitsPerSample / 8;
private const int PreferredSampleRate = 16000;
private const uint FramesPerBuffer = 320;
private readonly object _syncRoot = new();
private PortAudioStream? _stream;
private PortAudioStream.Callback? _streamCallback;
private MemoryStream? _pcmBuffer;
private AudioRecorderRuntimeState _state = AudioRecorderRuntimeState.Unsupported;
private string _lastSavedFilePath = string.Empty;
private string _lastError = string.Empty;
private int _inputDeviceIndex = -1;
private int _sampleRate = PreferredSampleRate;
private double _deviceDefaultSampleRate = PreferredSampleRate;
private long _capturedFrames;
private double _inputLevel;
private bool _isPortAudioInitialized;
private bool _isDisposed;
public PortAudioRecorderService()
{
InitializeRuntime();
}
public AudioRecorderSnapshot GetSnapshot()
{
lock (_syncRoot)
{
var level = _state == AudioRecorderRuntimeState.Recording
? Math.Clamp(_inputLevel, 0, 1)
: 0;
var duration = _capturedFrames <= 0 || _sampleRate <= 0
? TimeSpan.Zero
: TimeSpan.FromSeconds(_capturedFrames / (double)_sampleRate);
return new AudioRecorderSnapshot(
State: _state,
Duration: duration,
InputLevel: level,
LastSavedFilePath: _lastSavedFilePath,
LastError: _lastError);
}
}
public bool StartOrResume()
{
lock (_syncRoot)
{
if (_isDisposed)
{
return false;
}
if (_state == AudioRecorderRuntimeState.Unsupported)
{
return false;
}
if (_state == AudioRecorderRuntimeState.Error)
{
_state = AudioRecorderRuntimeState.Ready;
}
if (_state == AudioRecorderRuntimeState.Recording)
{
return true;
}
if (_state == AudioRecorderRuntimeState.Paused && _stream is not null)
{
try
{
_stream.Start();
_state = AudioRecorderRuntimeState.Recording;
_lastError = string.Empty;
return true;
}
catch (Exception ex)
{
SetErrorLocked(ex);
return false;
}
}
EnsureBufferLocked();
ResetCaptureStateLocked();
if (!TryOpenInputStreamLocked())
{
return false;
}
_state = AudioRecorderRuntimeState.Recording;
_lastError = string.Empty;
return true;
}
}
public bool Pause()
{
lock (_syncRoot)
{
if (_isDisposed || _state != AudioRecorderRuntimeState.Recording || _stream is null)
{
return false;
}
try
{
_stream.Stop();
_state = AudioRecorderRuntimeState.Paused;
_inputLevel = 0;
_lastError = string.Empty;
return true;
}
catch (Exception ex)
{
SetErrorLocked(ex);
return false;
}
}
}
public string? StopAndSave(string? outputPath = null)
{
byte[] pcmData;
int sampleRate;
lock (_syncRoot)
{
if (_isDisposed ||
(_state != AudioRecorderRuntimeState.Recording && _state != AudioRecorderRuntimeState.Paused))
{
return null;
}
StopStreamLocked();
pcmData = _pcmBuffer?.ToArray() ?? Array.Empty<byte>();
sampleRate = _sampleRate;
ResetCaptureStateLocked();
_state = AudioRecorderRuntimeState.Ready;
_inputLevel = 0;
}
if (pcmData.Length == 0)
{
return null;
}
var resolvedOutputPath = ResolveOutputPath(outputPath);
try
{
WriteWaveFile(resolvedOutputPath, pcmData, sampleRate, ChannelCount, BitsPerSample);
}
catch (Exception ex)
{
lock (_syncRoot)
{
SetErrorLocked(ex);
}
return null;
}
lock (_syncRoot)
{
_lastSavedFilePath = resolvedOutputPath;
_lastError = string.Empty;
}
return resolvedOutputPath;
}
public void Discard()
{
lock (_syncRoot)
{
if (_isDisposed)
{
return;
}
StopStreamLocked();
ResetCaptureStateLocked();
_inputLevel = 0;
_lastError = string.Empty;
if (_state != AudioRecorderRuntimeState.Unsupported)
{
_state = AudioRecorderRuntimeState.Ready;
}
}
}
public void Dispose()
{
lock (_syncRoot)
{
if (_isDisposed)
{
return;
}
_isDisposed = true;
StopStreamLocked();
_pcmBuffer?.Dispose();
_pcmBuffer = null;
if (_isPortAudioInitialized)
{
try
{
PortAudio.Terminate();
}
catch
{
// Ignore shutdown failures.
}
_isPortAudioInitialized = false;
}
}
}
private void InitializeRuntime()
{
lock (_syncRoot)
{
if (_isDisposed || _isPortAudioInitialized)
{
return;
}
try
{
PortAudio.LoadNativeLibrary();
PortAudio.Initialize();
_isPortAudioInitialized = true;
}
catch (Exception ex)
{
_state = AudioRecorderRuntimeState.Unsupported;
_lastError = ResolveErrorMessage(ex);
return;
}
try
{
_inputDeviceIndex = PortAudio.DefaultInputDevice;
if (_inputDeviceIndex < 0)
{
_state = AudioRecorderRuntimeState.Unsupported;
_lastError = "No input device";
return;
}
var deviceInfo = PortAudio.GetDeviceInfo(_inputDeviceIndex);
if (deviceInfo.maxInputChannels < 1)
{
_state = AudioRecorderRuntimeState.Unsupported;
_lastError = "Input channels unavailable";
return;
}
_deviceDefaultSampleRate = deviceInfo.defaultSampleRate > 0
? deviceInfo.defaultSampleRate
: PreferredSampleRate;
_state = AudioRecorderRuntimeState.Ready;
_lastError = string.Empty;
}
catch (Exception ex)
{
_state = AudioRecorderRuntimeState.Unsupported;
_lastError = ResolveErrorMessage(ex);
}
}
}
private bool TryOpenInputStreamLocked()
{
if (!_isPortAudioInitialized || _inputDeviceIndex < 0)
{
_state = AudioRecorderRuntimeState.Unsupported;
return false;
}
var inputParameters = new StreamParameters
{
device = _inputDeviceIndex,
channelCount = ChannelCount,
sampleFormat = SampleFormat.Int16,
suggestedLatency = ResolveSuggestedLatency(),
hostApiSpecificStreamInfo = IntPtr.Zero
};
_streamCallback ??= OnStreamCallback;
foreach (var candidateRate in BuildSampleRateCandidates())
{
try
{
_stream?.Dispose();
_stream = new PortAudioStream(
inputParameters,
null,
candidateRate,
FramesPerBuffer,
StreamFlags.ClipOff,
_streamCallback,
this);
_sampleRate = Math.Clamp((int)Math.Round(candidateRate), 8000, 96000);
_stream.Start();
return true;
}
catch (Exception ex)
{
_stream?.Dispose();
_stream = null;
_lastError = ResolveErrorMessage(ex);
}
}
_state = AudioRecorderRuntimeState.Error;
return false;
}
private StreamCallbackResult OnStreamCallback(
IntPtr input,
IntPtr output,
uint frameCount,
ref StreamCallbackTimeInfo timeInfo,
StreamCallbackFlags statusFlags,
IntPtr userData)
{
_ = output;
_ = timeInfo;
_ = statusFlags;
_ = userData;
if (frameCount == 0 || input == IntPtr.Zero)
{
return StreamCallbackResult.Continue;
}
var byteCount = checked((int)(frameCount * ChannelCount * BytesPerSample));
var buffer = ArrayPool<byte>.Shared.Rent(byteCount);
try
{
Marshal.Copy(input, buffer, 0, byteCount);
var peak = CalculatePeak(buffer, byteCount);
lock (_syncRoot)
{
if (_state != AudioRecorderRuntimeState.Recording)
{
return StreamCallbackResult.Continue;
}
_pcmBuffer?.Write(buffer, 0, byteCount);
_capturedFrames += frameCount;
_inputLevel = (_inputLevel * 0.72) + (peak * 0.28);
}
}
catch
{
// Keep callback resilient to transient IO/interop errors.
}
finally
{
ArrayPool<byte>.Shared.Return(buffer);
}
return StreamCallbackResult.Continue;
}
private void StopStreamLocked()
{
if (_stream is null)
{
return;
}
try
{
if (_stream.IsActive)
{
_stream.Stop();
}
}
catch
{
// Ignore stop errors.
}
try
{
_stream.Close();
}
catch
{
// Ignore close errors.
}
_stream.Dispose();
_stream = null;
}
private void ResetCaptureStateLocked()
{
_capturedFrames = 0;
_sampleRate = Math.Clamp(_sampleRate, 8000, 96000);
_inputLevel = 0;
_pcmBuffer?.SetLength(0);
}
private void EnsureBufferLocked()
{
if (_pcmBuffer is not null)
{
return;
}
_pcmBuffer = new MemoryStream(capacity: 128 * 1024);
}
private double ResolveSuggestedLatency()
{
try
{
var info = PortAudio.GetDeviceInfo(_inputDeviceIndex);
if (info.defaultLowInputLatency > 0)
{
return info.defaultLowInputLatency;
}
if (info.defaultHighInputLatency > 0)
{
return info.defaultHighInputLatency;
}
}
catch
{
// Fall through to default latency.
}
return 0.04;
}
private double[] BuildSampleRateCandidates()
{
var ordered = new[] { PreferredSampleRate, _deviceDefaultSampleRate, 44100d, 48000d };
var unique = new HashSet<int>();
var list = new List<double>(ordered.Length);
foreach (var rate in ordered)
{
var rounded = (int)Math.Round(rate);
if (rounded < 8000 || rounded > 96000 || !unique.Add(rounded))
{
continue;
}
list.Add(rounded);
}
return list.ToArray();
}
private static double CalculatePeak(byte[] buffer, int byteCount)
{
double peak = 0;
for (var i = 0; i + 1 < byteCount; i += 2)
{
var sample = (short)(buffer[i] | (buffer[i + 1] << 8));
var normalized = Math.Abs(sample) / 32768d;
if (normalized > peak)
{
peak = normalized;
}
}
return Math.Clamp(peak, 0, 1);
}
private static string ResolveOutputPath(string? outputPath)
{
if (string.IsNullOrWhiteSpace(outputPath))
{
return BuildDefaultOutputPath();
}
var normalizedPath = outputPath.Trim();
if (!string.Equals(Path.GetExtension(normalizedPath), ".wav", StringComparison.OrdinalIgnoreCase))
{
normalizedPath = Path.ChangeExtension(normalizedPath, ".wav");
}
var directory = Path.GetDirectoryName(normalizedPath);
if (string.IsNullOrWhiteSpace(directory))
{
directory = Environment.CurrentDirectory;
normalizedPath = Path.Combine(directory, Path.GetFileName(normalizedPath));
}
Directory.CreateDirectory(directory);
return normalizedPath;
}
private static string BuildDefaultOutputPath()
{
var root = Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments);
if (string.IsNullOrWhiteSpace(root))
{
root = AppContext.BaseDirectory;
}
var folder = Path.Combine(root, "LanMontainDesktop", "Recordings");
Directory.CreateDirectory(folder);
var timestamp = DateTime.Now.ToString("yyyyMMdd_HHmmss_fff");
return Path.Combine(folder, $"recording_{timestamp}.wav");
}
private static void WriteWaveFile(string path, byte[] pcmData, int sampleRate, int channels, int bitsPerSample)
{
var byteRate = sampleRate * channels * (bitsPerSample / 8);
var blockAlign = channels * (bitsPerSample / 8);
using var stream = File.Create(path);
using var writer = new BinaryWriter(stream);
writer.Write(new[] { (byte)'R', (byte)'I', (byte)'F', (byte)'F' });
writer.Write(36 + pcmData.Length);
writer.Write(new[] { (byte)'W', (byte)'A', (byte)'V', (byte)'E' });
writer.Write(new[] { (byte)'f', (byte)'m', (byte)'t', (byte)' ' });
writer.Write(16);
writer.Write((short)1);
writer.Write((short)channels);
writer.Write(sampleRate);
writer.Write(byteRate);
writer.Write((short)blockAlign);
writer.Write((short)bitsPerSample);
writer.Write(new[] { (byte)'d', (byte)'a', (byte)'t', (byte)'a' });
writer.Write(pcmData.Length);
writer.Write(pcmData);
}
private void SetErrorLocked(Exception ex)
{
_lastError = ResolveErrorMessage(ex);
_state = AudioRecorderRuntimeState.Error;
}
private static string ResolveErrorMessage(Exception ex)
{
return ex.Message.Trim().Length > 0
? ex.Message.Trim()
: ex.GetType().Name;
}
}