TASK 9 audio milestone: real stream audio + voice filters + auto-duck + free TRAX music — the mixer now feeds the encoder real audio (ffmpeg reads a Windows named pipe, -f f32le -ar 48000 -ac 2 -i \.\pipe\ytllive_audio, replacing anullsrc silence; explicit -map 0:v -map 1:a via EncoderOptions.AudioPipeName), with honest gains reaching the stream (MicVolume scales mic, GameAudioVolume + mute scale loopback, Func<double> seams on AudioMixer), an always-on pure-C# voice chain on the mic BEFORE the meter and mix (LowShelfFilter 120Hz +4dB → HighShelfFilter 8kHz +3dB → NoiseGate 0.005/hysteresis 0.5 → Compressor 0.5 4:1, all TDF2), AutoDucker (mic RMS>0.02 → loopback ×0.25, attack 0.05/release 0.005), and TRAX free background music (MusicPlayer = MediaFoundationReader → VolumeWaveProvider16 at fixed 0.20 → WaveOutEvent via the new sibling NAudio.WinMM 2.2.1 package; plays to the default device so the existing loopback carries it, ducked with game; footer TRAX button with red/yellow/green status dot, left-click toggles/picks, right-click opens the OpenFileDialog picker, tooltip shows the track name; persisted via schema v9 single-row Music). Build-time deviations from the plan (recorded in ai.md + TASKS.md): WasapiCapture in NAudio 2.2.1 exposes no overridable GetDefaultMixFormat so sources run device mix format and the mixer's TinyResampler normalizes any rate to 48kHz (resampler IS the design); FfmpegEncoder.cs untouched — MainViewModel.StopStream calls _audioMixer.StopLive() (pipe EOF) before the frame pump stops; sound-bar relabelled 'Desktop Audio', IsGameAudioBarVisible = game sound OR music playing. Tests: new AudioPipelineTests (DSP/ring-buffer/ducker/resampler units + the ONE integration test Mix_WithFiltersDuckAndGain_Lands_On_AudioPipe reading real pipe bytes; ring-buffer overwrite bug found + fixed), FfmpegEncoderTests/LayoutStorePersistenceTests updated — 196 tests passing, 0 warnings
This commit is contained in:
+212
-10
@@ -4,28 +4,72 @@ namespace ytLive.Services.Audio;
|
||||
|
||||
/// <summary>
|
||||
/// Owns the mic + desktop/game capture sources (TASK 4 ship step 4) and drives
|
||||
/// the footer meters. Capture runs for the app's lifetime (started once at
|
||||
/// startup, stopped on shutdown) so both bars stay live in preview: mic samples
|
||||
/// are level-metered and forwarded, loopback samples feed the game bar's meter.
|
||||
/// The mixer surfaces mic connection state (Connected/Failed) for the status
|
||||
/// dot and can restart the mic source mid-session when a device is re-picked.
|
||||
/// the footer meters; since TASK 9 it is also the live audio path into the
|
||||
/// encoder. Capture runs for the app's lifetime so both bars stay live in
|
||||
/// preview: mic samples are downmixed to mono, resampled to 48 kHz, put
|
||||
/// through the voice chain (bass → treble → noise gate → compressor), THEN
|
||||
/// level-metered and queued; loopback samples are resampled to 48 kHz stereo
|
||||
/// and queued. While live (<see cref="StartLive"/>), a 10 ms loop drains both
|
||||
/// queues, applies the honest gains (mic = MicVolume, loopback = GameAudioVolume
|
||||
/// × auto-duck when the mic is hot), mixes them to stereo float and writes the
|
||||
/// chunk to the encoder's audio pipe.
|
||||
/// </summary>
|
||||
public sealed class AudioMixer : IDisposable
|
||||
{
|
||||
public const int OutputSampleRate = 48000;
|
||||
|
||||
private static readonly TimeSpan DefaultMixInterval = TimeSpan.FromMilliseconds(10);
|
||||
|
||||
private readonly IAudioSource _mic;
|
||||
private readonly IAudioSource _loopback;
|
||||
private readonly AudioLevelMeter _meter;
|
||||
private readonly AudioLevelMeter _loopbackMeter;
|
||||
private readonly Action<string>? _log;
|
||||
private readonly Func<double>? _micGain;
|
||||
private readonly Func<double>? _loopbackGain;
|
||||
private readonly TimeSpan _mixInterval;
|
||||
private bool _started;
|
||||
|
||||
public AudioMixer(IAudioSource mic, IAudioSource loopback, Action<string>? log = null)
|
||||
// Live path (TASK 9): per-source resampling → voice chain on the mic →
|
||||
// thread-safe queues → the ducker + gain/mix loop → the encoder's pipe.
|
||||
private readonly AudioRingBuffer _micBuffer;
|
||||
private readonly AudioRingBuffer _loopbackBuffer;
|
||||
private readonly VoiceFilterChain _voiceChain;
|
||||
private readonly AutoDucker _ducker;
|
||||
private TinyResampler? _micResampler;
|
||||
private TinyResampler? _loopbackResampler;
|
||||
private CancellationTokenSource? _liveCts;
|
||||
private NamedPipeAudioWriter _pipe = new();
|
||||
private float[]? _micChunk;
|
||||
private float[]? _loopbackChunk;
|
||||
private float[]? _mixBuffer;
|
||||
|
||||
public AudioMixer(
|
||||
IAudioSource mic,
|
||||
IAudioSource loopback,
|
||||
Action<string>? log = null,
|
||||
Func<double>? micGain = null,
|
||||
Func<double>? loopbackGain = null,
|
||||
TimeSpan? mixInterval = null)
|
||||
{
|
||||
_mic = mic;
|
||||
_loopback = loopback;
|
||||
_meter = new AudioLevelMeter();
|
||||
_loopbackMeter = new AudioLevelMeter();
|
||||
_log = log;
|
||||
_micGain = micGain;
|
||||
_loopbackGain = loopbackGain;
|
||||
_mixInterval = mixInterval ?? DefaultMixInterval;
|
||||
|
||||
var seconds = _mixInterval.TotalSeconds;
|
||||
var framesPerTick = Math.Max(1, (int)Math.Round(OutputSampleRate * seconds));
|
||||
_micBuffer = new AudioRingBuffer(OutputSampleRate * 2);
|
||||
_loopbackBuffer = new AudioRingBuffer(OutputSampleRate * 2 * 2);
|
||||
_voiceChain = new VoiceFilterChain(OutputSampleRate);
|
||||
_ducker = new AutoDucker();
|
||||
_micChunk = new float[framesPerTick];
|
||||
_loopbackChunk = new float[framesPerTick * 2];
|
||||
_mixBuffer = new float[framesPerTick * 2];
|
||||
|
||||
_mic.Started += OnMicStarted;
|
||||
_mic.SampleReady += OnMicSample;
|
||||
@@ -34,7 +78,7 @@ public sealed class AudioMixer : IDisposable
|
||||
_loopback.Failed += OnLoopbackFailed;
|
||||
}
|
||||
|
||||
/// <summary>Current smoothed mic level (0..1).</summary>
|
||||
/// <summary>Current smoothed mic level (0..1), post voice chain.</summary>
|
||||
public float MicLevel => _meter.Level;
|
||||
|
||||
/// <summary>Current smoothed desktop/game level (0..1).</summary>
|
||||
@@ -70,6 +114,9 @@ public sealed class AudioMixer : IDisposable
|
||||
{
|
||||
_mic.Stop();
|
||||
_meter.Reset();
|
||||
_micBuffer.Clear();
|
||||
_micResampler?.Reset();
|
||||
_voiceChain.Reset();
|
||||
MicLevelChanged?.Invoke(0);
|
||||
_mic.Start();
|
||||
}
|
||||
@@ -80,6 +127,7 @@ public sealed class AudioMixer : IDisposable
|
||||
return;
|
||||
|
||||
_started = false;
|
||||
StopLive();
|
||||
_mic.Stop();
|
||||
_loopback.Stop();
|
||||
_meter.Reset();
|
||||
@@ -88,6 +136,30 @@ public sealed class AudioMixer : IDisposable
|
||||
LoopbackLevelChanged?.Invoke(0);
|
||||
}
|
||||
|
||||
/// <summary>Begins the live mix loop: drains the capture queues, applies the
|
||||
/// honest gains + auto-duck, and streams stereo float into the named audio
|
||||
/// pipe. Idempotent — safe to call once per go-live.</summary>
|
||||
public void StartLive(string pipeName)
|
||||
{
|
||||
if (_liveCts != null)
|
||||
return;
|
||||
|
||||
var cts = new CancellationTokenSource();
|
||||
_liveCts = cts;
|
||||
_pipe = new NamedPipeAudioWriter();
|
||||
_pipe.Start(pipeName);
|
||||
_ = Task.Run(() => LiveLoopAsync(_pipe, cts.Token));
|
||||
}
|
||||
|
||||
/// <summary>Ends the live mix loop and closes the audio pipe — ffmpeg sees
|
||||
/// EOF on the audio input. Safe when not live.</summary>
|
||||
public void StopLive()
|
||||
{
|
||||
_liveCts?.Cancel();
|
||||
_liveCts = null;
|
||||
_pipe.Stop();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Stop();
|
||||
@@ -107,15 +179,26 @@ public sealed class AudioMixer : IDisposable
|
||||
|
||||
private void OnMicSample(AudioSample sample)
|
||||
{
|
||||
var mono = DownmixToMono(sample);
|
||||
mono = ResampleMic(mono, sample.SampleRate);
|
||||
for (var i = 0; i < mono.Length; i++)
|
||||
mono[i] = _voiceChain.Process(mono[i]);
|
||||
_micBuffer.Write(mono);
|
||||
|
||||
// Push unconditionally: the ?. on the event would otherwise skip the
|
||||
// argument (and the meter update) when nothing is subscribed yet.
|
||||
var level = _meter.Push(sample);
|
||||
// argument (and the meter update) when nothing is subscribed yet. The
|
||||
// level is the POST-filtered signal — the meter shows what the stream
|
||||
// will carry.
|
||||
var level = _meter.Push(new AudioSample(mono, OutputSampleRate, 1));
|
||||
MicLevelChanged?.Invoke(level);
|
||||
}
|
||||
|
||||
private void OnLoopbackSample(AudioSample sample)
|
||||
{
|
||||
var level = _loopbackMeter.Push(sample);
|
||||
var data = ResampleLoopback(sample.Samples, sample.SampleRate);
|
||||
_loopbackBuffer.Write(data);
|
||||
|
||||
var level = _loopbackMeter.Push(new AudioSample(data, OutputSampleRate, sample.Channels));
|
||||
LoopbackLevelChanged?.Invoke(level);
|
||||
}
|
||||
|
||||
@@ -130,4 +213,123 @@ public sealed class AudioMixer : IDisposable
|
||||
{
|
||||
_log?.Invoke($"Desktop audio capture failed: {ex.Message}");
|
||||
}
|
||||
|
||||
private static float[] DownmixToMono(AudioSample sample)
|
||||
{
|
||||
if (sample.Channels <= 1)
|
||||
return sample.Samples;
|
||||
|
||||
var frames = sample.Samples.Length / sample.Channels;
|
||||
var mono = new float[frames];
|
||||
for (var i = 0; i < frames; i++)
|
||||
{
|
||||
var sum = 0f;
|
||||
for (var c = 0; c < sample.Channels; c++)
|
||||
sum += sample.Samples[i * sample.Channels + c];
|
||||
mono[i] = sum / sample.Channels;
|
||||
}
|
||||
return mono;
|
||||
}
|
||||
|
||||
private float[] ResampleMic(float[] mono, int inputRate)
|
||||
{
|
||||
if (inputRate == OutputSampleRate)
|
||||
return mono;
|
||||
_micResampler ??= new TinyResampler(inputRate, OutputSampleRate);
|
||||
if (_micResampler.NeedsResampling)
|
||||
return _micResampler.Process(mono);
|
||||
return mono;
|
||||
}
|
||||
|
||||
private float[] ResampleLoopback(float[] interleaved, int inputRate)
|
||||
{
|
||||
if (inputRate == OutputSampleRate)
|
||||
return interleaved;
|
||||
_loopbackResampler ??= new TinyResampler(inputRate, OutputSampleRate);
|
||||
if (_loopbackResampler.NeedsResampling)
|
||||
return _loopbackResampler.Process(interleaved);
|
||||
return interleaved;
|
||||
}
|
||||
|
||||
private async Task LiveLoopAsync(IAudioPipeWriter pipe, CancellationToken cancellationToken)
|
||||
{
|
||||
var mixBuffer = _mixBuffer!;
|
||||
var micChunk = _micChunk!;
|
||||
var loopbackChunk = _loopbackChunk!;
|
||||
while (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
var nextTick = DateTime.UtcNow + _mixInterval;
|
||||
try
|
||||
{
|
||||
FillAndMix(micChunk, loopbackChunk, mixBuffer);
|
||||
await pipe.WriteAsync(mixBuffer, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log?.Invoke($"Audio live loop error: {ex.Message}");
|
||||
}
|
||||
|
||||
var delay = nextTick - DateTime.UtcNow;
|
||||
if (delay > TimeSpan.Zero)
|
||||
{
|
||||
try
|
||||
{
|
||||
await Task.Delay(delay, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Drains one tick's worth of mic + loopback, silence-fills any
|
||||
/// underrun, applies the ducker and the honest gains, and mixes to stereo.</summary>
|
||||
private float FillAndMix(float[] micChunk, float[] loopbackChunk, float[] mix)
|
||||
{
|
||||
var micCount = _micBuffer.Read(micChunk, micChunk.Length);
|
||||
var loopCount = _loopbackBuffer.Read(loopbackChunk, loopbackChunk.Length);
|
||||
|
||||
float micRms = 0;
|
||||
if (micCount == 0)
|
||||
{
|
||||
Array.Clear(micChunk, 0, micChunk.Length);
|
||||
}
|
||||
else
|
||||
{
|
||||
double sumSquares = 0;
|
||||
for (var i = 0; i < micCount; i++)
|
||||
sumSquares += micChunk[i] * micChunk[i];
|
||||
micRms = (float)Math.Sqrt(sumSquares / micCount);
|
||||
if (micCount < micChunk.Length)
|
||||
Array.Clear(micChunk, micCount, micChunk.Length - micCount);
|
||||
}
|
||||
|
||||
if (loopCount == 0)
|
||||
{
|
||||
Array.Clear(loopbackChunk, 0, loopbackChunk.Length);
|
||||
}
|
||||
else if (loopCount < loopbackChunk.Length)
|
||||
{
|
||||
Array.Clear(loopbackChunk, loopCount, loopbackChunk.Length - loopCount);
|
||||
}
|
||||
|
||||
var duck = _ducker.Update(micRms);
|
||||
var micGain = (float)(_micGain?.Invoke() ?? 1.0);
|
||||
var loopGain = (float)(_loopbackGain?.Invoke() ?? 1.0) * duck;
|
||||
|
||||
var frames = micChunk.Length;
|
||||
for (var i = 0; i < frames; i++)
|
||||
{
|
||||
var m = micChunk[i] * micGain;
|
||||
mix[i * 2] = m + loopbackChunk[i * 2] * loopGain;
|
||||
mix[i * 2 + 1] = m + loopbackChunk[i * 2 + 1] * loopGain;
|
||||
}
|
||||
return micRms;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
namespace ytLive.Services.Audio;
|
||||
|
||||
/// <summary>
|
||||
/// A thread-safe float ring buffer for the live audio path (TASK 9): NAudio
|
||||
/// raises capture chunks on its own threads, the mixer's live loop drains on
|
||||
/// its own. Writes overwrite the OLDEST samples when full so the buffer never
|
||||
/// grows unbounded — the stream can't stall on capture jitter, it just skips
|
||||
/// the stale tail.
|
||||
/// </summary>
|
||||
public sealed class AudioRingBuffer
|
||||
{
|
||||
private readonly object _sync = new();
|
||||
private readonly float[] _buffer;
|
||||
private int _head;
|
||||
private int _count;
|
||||
|
||||
public AudioRingBuffer(int capacity)
|
||||
{
|
||||
if (capacity <= 0)
|
||||
throw new ArgumentOutOfRangeException(nameof(capacity));
|
||||
_buffer = new float[capacity];
|
||||
}
|
||||
|
||||
public int Count
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
return _count;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Appends a chunk, dropping the oldest samples if the buffer is full.</summary>
|
||||
public void Write(float[] samples)
|
||||
{
|
||||
if (samples.Length == 0)
|
||||
return;
|
||||
|
||||
lock (_sync)
|
||||
{
|
||||
if (samples.Length >= _buffer.Length)
|
||||
{
|
||||
Array.Copy(samples, samples.Length - _buffer.Length, _buffer, 0, _buffer.Length);
|
||||
_head = 0;
|
||||
_count = _buffer.Length;
|
||||
return;
|
||||
}
|
||||
|
||||
if (_count + samples.Length > _buffer.Length)
|
||||
{
|
||||
// Evict the oldest samples to make room; the incoming write then
|
||||
// overwrites them as it wraps. Head stays put — only writing
|
||||
// advances it.
|
||||
var drop = _count + samples.Length - _buffer.Length;
|
||||
_count -= drop;
|
||||
}
|
||||
|
||||
var writeAt = _head;
|
||||
var first = Math.Min(samples.Length, _buffer.Length - writeAt);
|
||||
Array.Copy(samples, 0, _buffer, writeAt, first);
|
||||
if (first < samples.Length)
|
||||
Array.Copy(samples, first, _buffer, 0, samples.Length - first);
|
||||
_head = (writeAt + samples.Length) % _buffer.Length;
|
||||
_count += samples.Length;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Reads up to <paramref name="count"/> samples, removing them;
|
||||
/// returns the number actually read (fewer on underrun).</summary>
|
||||
public int Read(float[] destination, int count)
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
var n = Math.Min(count, _count);
|
||||
var tail = (_head - _count + _buffer.Length) % _buffer.Length;
|
||||
var first = Math.Min(n, _buffer.Length - tail);
|
||||
Array.Copy(_buffer, tail, destination, 0, first);
|
||||
if (first < n)
|
||||
Array.Copy(_buffer, 0, destination, first, n - first);
|
||||
_count -= n;
|
||||
return n;
|
||||
}
|
||||
}
|
||||
|
||||
public void Clear()
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
_head = 0;
|
||||
_count = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
namespace ytLive.Services.Audio;
|
||||
|
||||
/// <summary>
|
||||
/// Auto-duck (TASK 9): while the mic is hot the loopback (game + music) rides
|
||||
/// its volume down ~12 dB so the voice stays on top of the mix, then recovers
|
||||
/// when the creator stops talking. Smooth attack/release, always-on, no knobs.
|
||||
/// Pure — unit-tested.
|
||||
/// </summary>
|
||||
public sealed class AutoDucker
|
||||
{
|
||||
public const float Threshold = 0.02f;
|
||||
|
||||
/// <summary>-12 dB: the loopback's volume while the mic is active.</summary>
|
||||
public const float DuckGain = 0.25f;
|
||||
|
||||
private const float Attack = 0.05f;
|
||||
private const float Release = 0.005f;
|
||||
|
||||
private float _gain = 1f;
|
||||
|
||||
/// <summary>The current loopback gain to apply (1 = no duck).</summary>
|
||||
public float CurrentGain => _gain;
|
||||
|
||||
/// <summary>Feeds one mic level sample (0..1, post-filter RMS) and returns
|
||||
/// the gain to apply to the loopback for that tick.</summary>
|
||||
public float Update(float micLevel)
|
||||
{
|
||||
var target = micLevel > Threshold ? DuckGain : 1f;
|
||||
_gain += (target - _gain) * (target < _gain ? Attack : Release);
|
||||
if (MathF.Abs(_gain - target) < 0.001f)
|
||||
_gain = target;
|
||||
return _gain;
|
||||
}
|
||||
|
||||
public void Reset() => _gain = 1f;
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
using System.IO;
|
||||
using System.Runtime.InteropServices;
|
||||
using NAudio.Wave;
|
||||
|
||||
namespace ytLive.Services.Audio;
|
||||
|
||||
/// <summary>
|
||||
/// Streams background music (TASK 9 TRAX) to the DEFAULT render device at a
|
||||
/// fixed quiet volume so it rides the WASAPI loopback into the mix — the same
|
||||
/// physical path game audio takes, so music is ducked with the game, heard in
|
||||
/// headphones, and bounced on the desktop audio meter. Loops on natural end.
|
||||
/// </summary>
|
||||
public sealed class MusicPlayer : IDisposable
|
||||
{
|
||||
/// <summary>The one and only music level — quiet bed, no slider (the
|
||||
/// creator's voice must stay on top; game/music loudness is the ducker's job).</summary>
|
||||
public const float MusicVolume = 0.20f;
|
||||
|
||||
private MediaFoundationReader? _reader;
|
||||
private VolumeWaveProvider16? _volume;
|
||||
private WaveOutEvent? _output;
|
||||
private bool _disposed;
|
||||
private bool _stopping;
|
||||
|
||||
/// <summary>Raised when playback ends or is stopped (not on natural-end loop).</summary>
|
||||
public event Action? PlaybackEnded;
|
||||
|
||||
public string? TrackPath { get; private set; }
|
||||
|
||||
public string? TrackName => TrackPath == null ? null : Path.GetFileNameWithoutExtension(TrackPath);
|
||||
|
||||
public bool IsPlaying => _output?.PlaybackState == PlaybackState.Playing;
|
||||
|
||||
/// <summary>Loads a track and prepares playback (does not start it).</summary>
|
||||
public void Load(string path)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path))
|
||||
throw new ArgumentException("A music file path is required.", nameof(path));
|
||||
|
||||
Stop();
|
||||
TrackPath = path;
|
||||
_reader = new MediaFoundationReader(path);
|
||||
_volume = new VolumeWaveProvider16(_reader) { Volume = MusicVolume };
|
||||
_output = new WaveOutEvent();
|
||||
_output.PlaybackStopped += OnPlaybackStopped;
|
||||
_output.Init(_volume);
|
||||
}
|
||||
|
||||
public void Play()
|
||||
{
|
||||
if (_output == null)
|
||||
return;
|
||||
_output.Play();
|
||||
}
|
||||
|
||||
public void Pause()
|
||||
{
|
||||
if (_output == null)
|
||||
return;
|
||||
_output.Pause();
|
||||
}
|
||||
|
||||
public void Stop()
|
||||
{
|
||||
if (_output == null)
|
||||
return;
|
||||
|
||||
_stopping = true;
|
||||
try
|
||||
{
|
||||
_output.Stop();
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
_output.PlaybackStopped -= OnPlaybackStopped;
|
||||
_output.Dispose();
|
||||
_output = null;
|
||||
_volume = null;
|
||||
_reader?.Dispose();
|
||||
_reader = null;
|
||||
_stopping = false;
|
||||
TrackPath = null;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_disposed = true;
|
||||
Stop();
|
||||
_disposed = false;
|
||||
}
|
||||
|
||||
private void OnPlaybackStopped(object? sender, StoppedEventArgs e)
|
||||
{
|
||||
if (_disposed || _stopping || _output == null || _reader == null)
|
||||
return;
|
||||
|
||||
// Natural end → loop the track; anything else (an explicit stop or a
|
||||
// device failure) surfaces via PlaybackEnded so the UI can reset the dot.
|
||||
if (_reader.Position >= _reader.Length)
|
||||
{
|
||||
_reader.Position = 0;
|
||||
_output.Play();
|
||||
return;
|
||||
}
|
||||
|
||||
PlaybackEnded?.Invoke();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
using System.IO.Pipes;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace ytLive.Services.Audio;
|
||||
|
||||
/// <summary>
|
||||
/// The mixer's sink for the live loop: interleaved stereo PCM float at 48 kHz
|
||||
/// written to a named pipe, byte-identical to what ffmpeg expects from
|
||||
/// <c>-f f32le -ar 48000 -ac 2 -i \\.\pipe\<name></c>. The encoder side is
|
||||
/// handled by FfmpegArgs/EncoderOptions; this side just owns the server end.
|
||||
/// </summary>
|
||||
public interface IAudioPipeWriter : IDisposable
|
||||
{
|
||||
/// <summary>Creates the named pipe server and waits (async) for the client.</summary>
|
||||
void Start(string pipeName);
|
||||
|
||||
/// <summary>True once ffmpeg has connected and the pipe is writable.</summary>
|
||||
bool IsConnected { get; }
|
||||
|
||||
/// <summary>Writes interleaved float samples; dropped until the client connects.</summary>
|
||||
Task WriteAsync(ReadOnlyMemory<float> samples, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Closes the server end — ffmpeg sees EOF and ends the audio input.</summary>
|
||||
void Stop();
|
||||
}
|
||||
|
||||
public sealed class NamedPipeAudioWriter : IAudioPipeWriter
|
||||
{
|
||||
private readonly object _sync = new();
|
||||
private NamedPipeServerStream? _pipe;
|
||||
|
||||
public void Start(string pipeName)
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
if (_pipe != null)
|
||||
throw new InvalidOperationException("Already started.");
|
||||
_pipe = new NamedPipeServerStream(
|
||||
pipeName,
|
||||
PipeDirection.Out,
|
||||
1,
|
||||
PipeTransmissionMode.Byte,
|
||||
PipeOptions.Asynchronous);
|
||||
}
|
||||
_ = Task.Run(WaitForConnectionAsync);
|
||||
}
|
||||
|
||||
public bool IsConnected
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
return _pipe is { IsConnected: true };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async Task WriteAsync(ReadOnlyMemory<float> samples, CancellationToken cancellationToken = default)
|
||||
{
|
||||
NamedPipeServerStream? pipe;
|
||||
lock (_sync)
|
||||
{
|
||||
pipe = _pipe;
|
||||
}
|
||||
if (pipe == null || !pipe.IsConnected || samples.IsEmpty)
|
||||
return;
|
||||
|
||||
var bytes = MemoryMarshal.AsBytes(samples.Span).ToArray();
|
||||
await pipe.WriteAsync(bytes, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public void Stop()
|
||||
{
|
||||
NamedPipeServerStream? pipe;
|
||||
lock (_sync)
|
||||
{
|
||||
pipe = _pipe;
|
||||
_pipe = null;
|
||||
}
|
||||
pipe?.Dispose();
|
||||
}
|
||||
|
||||
public void Dispose() => Stop();
|
||||
|
||||
private async Task WaitForConnectionAsync()
|
||||
{
|
||||
NamedPipeServerStream? pipe;
|
||||
lock (_sync)
|
||||
{
|
||||
pipe = _pipe;
|
||||
}
|
||||
if (pipe == null)
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
await pipe.WaitForConnectionAsync().ConfigureAwait(false);
|
||||
}
|
||||
catch
|
||||
{
|
||||
Stop();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
namespace ytLive.Services.Audio;
|
||||
|
||||
/// <summary>
|
||||
/// A tiny stateful linear-interpolation sample-rate converter (TASK 9). NAudio's
|
||||
/// WASAPI capture rate follows the device mix format; the stream needs a fixed
|
||||
/// 48 kHz, so each source normalizes through one of these. Purely a fallback —
|
||||
/// the mic requests 48 kHz float up front, and most loopback mix formats are
|
||||
/// already 48 kHz, in which case <see cref="NeedsResampling"/> is false and the
|
||||
/// chunk passes through untouched. Pure — unit-tested.
|
||||
/// </summary>
|
||||
public sealed class TinyResampler
|
||||
{
|
||||
private readonly double _ratio;
|
||||
private double _pos;
|
||||
|
||||
/// <param name="inputRate">The source sample rate.</param>
|
||||
/// <param name="outputRate">The target sample rate (48 kHz for the stream).</param>
|
||||
public TinyResampler(int inputRate, int outputRate)
|
||||
{
|
||||
if (inputRate <= 0 || outputRate <= 0)
|
||||
throw new ArgumentOutOfRangeException();
|
||||
_ratio = (double)inputRate / outputRate;
|
||||
}
|
||||
|
||||
public bool NeedsResampling => Math.Abs(_ratio - 1.0) > 1e-9;
|
||||
|
||||
/// <summary>Converts one interleaved chunk, keeping the phase across calls.</summary>
|
||||
public float[] Process(float[] input)
|
||||
{
|
||||
if (!NeedsResampling)
|
||||
return input;
|
||||
|
||||
var outLen = (int)((_pos + input.Length) / _ratio);
|
||||
var output = new float[outLen];
|
||||
for (var i = 0; i < outLen; i++)
|
||||
{
|
||||
var inPos = _pos + i * _ratio;
|
||||
var idx = (int)inPos;
|
||||
if (idx >= input.Length)
|
||||
{
|
||||
output[i] = input[input.Length - 1];
|
||||
continue;
|
||||
}
|
||||
var frac = (float)(inPos - idx);
|
||||
var a = input[idx];
|
||||
var b = idx + 1 < input.Length ? input[idx + 1] : a;
|
||||
output[i] = a + (b - a) * frac;
|
||||
}
|
||||
_pos += outLen * _ratio - input.Length;
|
||||
return output;
|
||||
}
|
||||
|
||||
public void Reset() => _pos = 0;
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
namespace ytLive.Services.Audio;
|
||||
|
||||
/// <summary>
|
||||
/// The always-on voice processing chain for the mic (TASK 9 audio milestone).
|
||||
/// Pure, per-sample, unit-tested: bass boost → treble lift → noise gate →
|
||||
/// compressor. Applied BEFORE the meter and the stream mix so what the creator
|
||||
/// hears on the meter is what viewers hear — and so background hum is gated
|
||||
/// out before it reaches the encoder.
|
||||
/// </summary>
|
||||
public sealed class VoiceFilterChain
|
||||
{
|
||||
private readonly LowShelfFilter _bass;
|
||||
private readonly HighShelfFilter _treble;
|
||||
private readonly NoiseGate _gate;
|
||||
private readonly Compressor _compressor;
|
||||
|
||||
/// <param name="sampleRate">The (post-resample) mic rate — the RBJ shelf
|
||||
/// coefficients are rate-dependent, so the chain is built once at 48 kHz.</param>
|
||||
public VoiceFilterChain(int sampleRate)
|
||||
{
|
||||
_bass = new LowShelfFilter(sampleRate, 120f, 4f);
|
||||
_treble = new HighShelfFilter(sampleRate, 8000f, 3f);
|
||||
_gate = new NoiseGate();
|
||||
_compressor = new Compressor();
|
||||
}
|
||||
|
||||
public float Process(float sample)
|
||||
{
|
||||
var bass = _bass.Process(sample);
|
||||
var treble = _treble.Process(bass);
|
||||
var gated = _gate.Process(treble);
|
||||
return _compressor.Process(gated);
|
||||
}
|
||||
|
||||
public void Reset()
|
||||
{
|
||||
_bass.Reset();
|
||||
_treble.Reset();
|
||||
_gate.Reset();
|
||||
_compressor.Reset();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// RBJ audio EQ cookbook low-shelf biquad (bass boost). Transposed direct
|
||||
/// form II — stable, cheap, standard.
|
||||
/// </summary>
|
||||
public sealed class LowShelfFilter
|
||||
{
|
||||
private readonly float _b0, _b1, _b2, _a1, _a2;
|
||||
private float _z1, _z2;
|
||||
|
||||
public LowShelfFilter(int sampleRate, float cutoffHz, float gainDb)
|
||||
{
|
||||
var a = MathF.Pow(10f, gainDb / 40f);
|
||||
var omega = 2f * MathF.PI * cutoffHz / sampleRate;
|
||||
var sin = MathF.Sin(omega);
|
||||
var cos = MathF.Cos(omega);
|
||||
var alpha = sin / 2f * MathF.Sqrt(2f);
|
||||
var twoSqrtA = 2f * MathF.Sqrt(a);
|
||||
|
||||
var b0 = a * ((a + 1f) - (a - 1f) * cos + twoSqrtA * alpha);
|
||||
var b1 = 2f * a * ((a - 1f) - (a + 1f) * cos);
|
||||
var b2 = a * ((a + 1f) - (a - 1f) * cos - twoSqrtA * alpha);
|
||||
var a0 = (a + 1f) + (a - 1f) * cos + twoSqrtA * alpha;
|
||||
var a1 = -2f * ((a - 1f) + (a + 1f) * cos);
|
||||
var a2 = (a + 1f) + (a - 1f) * cos - twoSqrtA * alpha;
|
||||
|
||||
_b0 = b0 / a0;
|
||||
_b1 = b1 / a0;
|
||||
_b2 = b2 / a0;
|
||||
_a1 = a1 / a0;
|
||||
_a2 = a2 / a0;
|
||||
}
|
||||
|
||||
public float Process(float x)
|
||||
{
|
||||
var y = _b0 * x + _z1;
|
||||
_z1 = _b1 * x - _a1 * y + _z2;
|
||||
_z2 = _b2 * x - _a2 * y;
|
||||
return y;
|
||||
}
|
||||
|
||||
public void Reset()
|
||||
{
|
||||
_z1 = 0;
|
||||
_z2 = 0;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>RBJ audio EQ cookbook high-shelf biquad (treble lift).</summary>
|
||||
public sealed class HighShelfFilter
|
||||
{
|
||||
private readonly float _b0, _b1, _b2, _a1, _a2;
|
||||
private float _z1, _z2;
|
||||
|
||||
public HighShelfFilter(int sampleRate, float cutoffHz, float gainDb)
|
||||
{
|
||||
var a = MathF.Pow(10f, gainDb / 40f);
|
||||
var omega = 2f * MathF.PI * cutoffHz / sampleRate;
|
||||
var sin = MathF.Sin(omega);
|
||||
var cos = MathF.Cos(omega);
|
||||
var alpha = sin / 2f * MathF.Sqrt(2f);
|
||||
var twoSqrtA = 2f * MathF.Sqrt(a);
|
||||
|
||||
var b0 = a * ((a + 1f) + (a - 1f) * cos + twoSqrtA * alpha);
|
||||
var b1 = -2f * a * ((a - 1f) + (a + 1f) * cos);
|
||||
var b2 = a * ((a + 1f) + (a - 1f) * cos - twoSqrtA * alpha);
|
||||
var a0 = (a + 1f) - (a - 1f) * cos + twoSqrtA * alpha;
|
||||
var a1 = 2f * ((a - 1f) - (a + 1f) * cos);
|
||||
var a2 = (a + 1f) - (a - 1f) * cos - twoSqrtA * alpha;
|
||||
|
||||
_b0 = b0 / a0;
|
||||
_b1 = b1 / a0;
|
||||
_b2 = b2 / a0;
|
||||
_a1 = a1 / a0;
|
||||
_a2 = a2 / a0;
|
||||
}
|
||||
|
||||
public float Process(float x)
|
||||
{
|
||||
var y = _b0 * x + _z1;
|
||||
_z1 = _b1 * x - _a1 * y + _z2;
|
||||
_z2 = _b2 * x - _a2 * y;
|
||||
return y;
|
||||
}
|
||||
|
||||
public void Reset()
|
||||
{
|
||||
_z1 = 0;
|
||||
_z2 = 0;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A simple noise gate with open/release hysteresis (pure C#, no external DSP
|
||||
/// dependency). The envelope follows the input peak fast and decays slowly;
|
||||
/// the gate opens above <see cref="Threshold"/> and closes below half of it, so
|
||||
/// quiet background hum stays silent and borderline signals don't chatter.
|
||||
/// </summary>
|
||||
public sealed class NoiseGate
|
||||
{
|
||||
private const float HysteresisRatio = 0.5f;
|
||||
private const float Attack = 0.5f;
|
||||
private const float Release = 0.0005f;
|
||||
|
||||
private float _envelope;
|
||||
private bool _open;
|
||||
|
||||
public NoiseGate(float threshold = DefaultThreshold)
|
||||
{
|
||||
Threshold = threshold;
|
||||
}
|
||||
|
||||
public const float DefaultThreshold = 0.005f;
|
||||
|
||||
public float Threshold { get; }
|
||||
|
||||
public bool IsOpen => _open;
|
||||
|
||||
public float Process(float sample)
|
||||
{
|
||||
var abs = MathF.Abs(sample);
|
||||
_envelope = abs > _envelope
|
||||
? _envelope + (abs - _envelope) * Attack
|
||||
: _envelope * (1f - Release);
|
||||
|
||||
if (!_open && _envelope > Threshold)
|
||||
_open = true;
|
||||
else if (_open && _envelope < Threshold * HysteresisRatio)
|
||||
_open = false;
|
||||
|
||||
return _open ? sample : 0f;
|
||||
}
|
||||
|
||||
public void Reset()
|
||||
{
|
||||
_envelope = 0;
|
||||
_open = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A static soft-limit compressor: samples at or under the threshold pass
|
||||
/// through; louder samples fold down at <c>Ratio:1</c> so hot mic peaks can't
|
||||
/// slam the stream. State-free.
|
||||
/// </summary>
|
||||
public sealed class Compressor
|
||||
{
|
||||
public const float Threshold = 0.5f;
|
||||
public const float Ratio = 4f;
|
||||
|
||||
public float Process(float sample)
|
||||
{
|
||||
var abs = MathF.Abs(sample);
|
||||
if (abs <= Threshold)
|
||||
return sample;
|
||||
return MathF.CopySign(Threshold + (abs - Threshold) / Ratio, sample);
|
||||
}
|
||||
|
||||
public void Reset()
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,9 @@ namespace ytLive.Services.Audio;
|
||||
/// <summary>
|
||||
/// Captures the selected microphone (TASK 4 ship step 4) via WASAPI. Resolves
|
||||
/// the NAudio device by FriendlyName matching <c>MicSourceName</c> (the app only
|
||||
/// persists DisplayName), falling back to the default capture endpoint.
|
||||
/// persists DisplayName), falling back to the default capture endpoint. The
|
||||
/// device's own mix format is used — the mixer normalizes any rate to 48 kHz
|
||||
/// (TASK 9) so no format forcing is needed here.
|
||||
/// </summary>
|
||||
public sealed class WasapiMicAudioSource : IAudioSource
|
||||
{
|
||||
@@ -91,7 +93,7 @@ public sealed class WasapiMicAudioSource : IAudioSource
|
||||
if (e.BytesRecorded <= 0)
|
||||
return;
|
||||
|
||||
var format = _capture?.WaveFormat ?? WaveFormat.CreateIeeeFloatWaveFormat(48000, 1);
|
||||
var format = _capture?.WaveFormat ?? WaveFormat.CreateIeeeFloatWaveFormat(48000, 2);
|
||||
var samples = WaveToFloat.Convert(e.Buffer, e.BytesRecorded, format);
|
||||
if (samples.Length > 0)
|
||||
SampleReady?.Invoke(new AudioSample(samples, format.SampleRate, format.Channels));
|
||||
|
||||
@@ -4,11 +4,15 @@ namespace ytLive.Services.Encoder;
|
||||
/// Everything the FFmpeg subprocess encoder needs for one go-live (TASK 4 ship
|
||||
/// step 3). <see cref="RtmpUrl"/> is the FULL ingestion URL — the reusable
|
||||
/// stream's ingest address plus its stream key (<c>rtmp://a.rtmp.youtube.com/live2/<key></c>).
|
||||
/// Resolution/FPS/bitrate come from the active quality tier; audio is a silent
|
||||
/// placeholder track until the WASAPI capture step replaces the input.
|
||||
/// Resolution/FPS/bitrate come from the active quality tier; audio (TASK 9)
|
||||
/// streams from the mixer through the named pipe <see cref="AudioPipeName"/>.
|
||||
/// </summary>
|
||||
public sealed class EncoderOptions
|
||||
{
|
||||
/// <summary>The audio pipe the mixer streams into; the args reference the
|
||||
/// same name for ffmpeg's <c>-i</c>.</summary>
|
||||
public const string DefaultAudioPipeName = "ytllive_audio";
|
||||
|
||||
public string RtmpUrl { get; init; } = string.Empty;
|
||||
public int Width { get; init; } = 1920;
|
||||
public int Height { get; init; } = 1080;
|
||||
@@ -22,6 +26,9 @@ public sealed class EncoderOptions
|
||||
public int AudioSampleRate { get; init; } = 48000;
|
||||
public int AudioChannels { get; init; } = 2;
|
||||
|
||||
/// <summary>Name of the mixer's audio pipe (default <see cref="DefaultAudioPipeName"/>).</summary>
|
||||
public string AudioPipeName { get; init; } = DefaultAudioPipeName;
|
||||
|
||||
/// <summary>GOP in frames = Fps × 4s — the YouTube keyframe ≤ 4s compliance bound.</summary>
|
||||
public int GopSize => Fps * 4;
|
||||
}
|
||||
|
||||
@@ -2,10 +2,11 @@ namespace ytLive.Services.Encoder;
|
||||
|
||||
/// <summary>
|
||||
/// Builds the FFmpeg command line for a live RTMP push (TASK 4 ship step 3):
|
||||
/// raw BGRA frames via stdin (paced <c>-re</c>), silent placeholder audio via lavfi
|
||||
/// <c>anullsrc</c> (the WASAPI step replaces this input), H.264 + AAC encoding, FLV
|
||||
/// muxing to the ingestion URL. Pure — the encoder just starts
|
||||
/// <c>ffmpeg.exe [Build(...)]</c>.
|
||||
/// raw BGRA frames via stdin (paced <c>-re</c>), real audio via the named pipe
|
||||
/// (TASK 9 — the mixer streams f32le at 48 kHz stereo into <c>\\.\pipe\<name></c>;
|
||||
/// WASAPI loopback + the mic ride the pipe instead of the old anullsrc silence),
|
||||
/// H.264 + AAC encoding, FLV muxing to the ingestion URL. Pure — the encoder
|
||||
/// just starts <c>ffmpeg.exe [Build(...)]</c>.
|
||||
/// </summary>
|
||||
public static class FfmpegArgs
|
||||
{
|
||||
@@ -24,8 +25,12 @@ public static class FfmpegArgs
|
||||
"-video_size", $"{options.Width}x{options.Height}",
|
||||
"-framerate", options.Fps.ToString(),
|
||||
"-i", "pipe:0",
|
||||
"-f", "lavfi",
|
||||
"-i", $"anullsrc=channel_layout=stereo:sample_rate={options.AudioSampleRate}",
|
||||
"-f", "f32le",
|
||||
"-ar", options.AudioSampleRate.ToString(),
|
||||
"-ac", options.AudioChannels.ToString(),
|
||||
"-i", $@"\\.\pipe\{options.AudioPipeName}",
|
||||
"-map", "0:v",
|
||||
"-map", "1:a",
|
||||
"-c:v", videoEncoder,
|
||||
"-b:v", $"{options.BitrateKbps}k",
|
||||
"-maxrate", $"{options.BitrateKbps}k",
|
||||
|
||||
+45
-2
@@ -18,6 +18,10 @@ public class LayoutStore : IDisposable
|
||||
/// <summary>The app-wide social bar config loaded with the last Load() (null = never defined).</summary>
|
||||
public SocialsConfig? Socials { get; private set; }
|
||||
|
||||
/// <summary>The app-wide background music (TASK 9 TRAX) loaded with the last
|
||||
/// Load() (null = no track chosen).</summary>
|
||||
public Music? Music { get; private set; }
|
||||
|
||||
public LayoutStore(string path)
|
||||
{
|
||||
ActivePath = path;
|
||||
@@ -124,6 +128,13 @@ public class LayoutStore : IDisposable
|
||||
SortOrder INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
""",
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS Music (
|
||||
Id TEXT PRIMARY KEY,
|
||||
TrackPath TEXT NOT NULL,
|
||||
IsEnabled INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
""",
|
||||
};
|
||||
foreach (var sql in statements)
|
||||
{
|
||||
@@ -141,7 +152,7 @@ public class LayoutStore : IDisposable
|
||||
MigrateToV3();
|
||||
using (var cmd = _connection.CreateCommand())
|
||||
{
|
||||
cmd.CommandText = "PRAGMA user_version = 8;";
|
||||
cmd.CommandText = "PRAGMA user_version = 9;";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
}
|
||||
@@ -406,6 +417,7 @@ public class LayoutStore : IDisposable
|
||||
{
|
||||
Webcam = null;
|
||||
Socials = null;
|
||||
Music = null;
|
||||
var scenes = new List<Scene>();
|
||||
var sourcesByScene = new Dictionary<string, List<Source>>();
|
||||
var configsByScene = new Dictionary<string, List<WebcamSceneConfig>>();
|
||||
@@ -473,6 +485,20 @@ public class LayoutStore : IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
using (var cmd = _connection.CreateCommand())
|
||||
{
|
||||
cmd.CommandText = "SELECT TrackPath, IsEnabled FROM Music LIMIT 1;";
|
||||
using var reader = cmd.ExecuteReader();
|
||||
if (reader.Read())
|
||||
{
|
||||
Music = new Music
|
||||
{
|
||||
TrackPath = reader.GetString(0),
|
||||
IsEnabled = reader.GetInt32(1) != 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
using (var cmd = _connection.CreateCommand())
|
||||
{
|
||||
cmd.CommandText = """
|
||||
@@ -559,7 +585,7 @@ public class LayoutStore : IDisposable
|
||||
return scenes;
|
||||
}
|
||||
|
||||
public void Save(IEnumerable<Scene> scenes, Webcam? webcam, SocialsConfig? socials)
|
||||
public void Save(IEnumerable<Scene> scenes, Webcam? webcam, SocialsConfig? socials, Music? music = null)
|
||||
{
|
||||
using var tx = _connection.BeginTransaction();
|
||||
using (var cmd = _connection.CreateCommand())
|
||||
@@ -593,6 +619,12 @@ public class LayoutStore : IDisposable
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
using (var cmd = _connection.CreateCommand())
|
||||
{
|
||||
cmd.CommandText = "DELETE FROM Music;";
|
||||
cmd.Transaction = tx;
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
using (var cmd = _connection.CreateCommand())
|
||||
{
|
||||
cmd.CommandText = "DELETE FROM Scene;";
|
||||
cmd.Transaction = tx;
|
||||
@@ -796,6 +828,17 @@ public class LayoutStore : IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
if (music != null && !string.IsNullOrWhiteSpace(music.TrackPath))
|
||||
{
|
||||
using var cmd = _connection.CreateCommand();
|
||||
cmd.CommandText = "INSERT INTO Music (Id, TrackPath, IsEnabled) VALUES ($id, $path, $enabled);";
|
||||
cmd.Transaction = tx;
|
||||
cmd.Parameters.AddWithValue("$id", Guid.NewGuid().ToString());
|
||||
cmd.Parameters.AddWithValue("$path", music.TrackPath);
|
||||
cmd.Parameters.AddWithValue("$enabled", music.IsEnabled ? 1 : 0);
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
using (var cmd = _connection.CreateCommand())
|
||||
{
|
||||
cmd.CommandText = "DELETE FROM Asset WHERE Id NOT IN (SELECT AssetId FROM Source WHERE AssetId IS NOT NULL);";
|
||||
|
||||
Reference in New Issue
Block a user