Skip to content

Commit 99efc50

Browse files
Fix speech input test native VAD startup (#686)
Avoid loading the unused native Silero VAD during tray speech input startup so the Record test path no longer enters the native ONNX dependency before microphone capture. Keep the existing managed energy VAD behavior in AudioPipeline and add a source contract test. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 85445c7 commit 99efc50

3 files changed

Lines changed: 59 additions & 122 deletions

File tree

src/OpenClaw.Tray.WinUI/Services/AudioPipeline.cs

Lines changed: 9 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,6 @@ public sealed class AudioPipeline : IAsyncDisposable
1818
{
1919
private readonly IOpenClawLogger _logger;
2020
private readonly SpeechToTextService _stt;
21-
private readonly VoiceActivityDetector _vad;
22-
2321
private WasapiCapture? _capture;
2422
private WaveFormat? _captureFormat;
2523
private AudioPipelineOptions _options = new();
@@ -40,6 +38,8 @@ public sealed class AudioPipeline : IAsyncDisposable
4038
// State
4139
private AudioPipelineState _state = AudioPipelineState.Stopped;
4240
private CancellationTokenSource? _cts;
41+
private const int PipelineSampleRate = 16000;
42+
private const int VadChunkSamples = 512;
4343

4444
// Backpressure: cap how many transcription Task.Run callbacks may be
4545
// outstanding at once. Each holds its own copy of the audio samples
@@ -94,11 +94,10 @@ public sealed class AudioPipeline : IAsyncDisposable
9494
/// <summary>When true, incoming audio is ignored (prevents echo during TTS playback).</summary>
9595
public bool IsMuted { get; set; }
9696

97-
public AudioPipeline(IOpenClawLogger logger, SpeechToTextService stt, VoiceActivityDetector vad)
97+
public AudioPipeline(IOpenClawLogger logger, SpeechToTextService stt)
9898
{
9999
_logger = logger;
100100
_stt = stt;
101-
_vad = vad;
102101
}
103102

104103
/// <summary>Start capturing and processing audio.</summary>
@@ -111,7 +110,7 @@ public async Task StartAsync(AudioPipelineOptions options, CancellationToken can
111110
_cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
112111

113112
// Calculate silence threshold: how many VAD chunks = silence timeout
114-
float chunkDurationSec = (float)VoiceActivityDetector.ChunkSamples / VoiceActivityDetector.SampleRate;
113+
float chunkDurationSec = (float)VadChunkSamples / PipelineSampleRate;
115114
_silenceChunksThreshold = Math.Max(1, (int)(options.SilenceTimeoutSeconds / chunkDurationSec));
116115

117116
SetState(AudioPipelineState.Starting);
@@ -331,10 +330,10 @@ private void OnDataAvailable(object? sender, WaveInEventArgs e)
331330

332331
private void ProcessVadChunks()
333332
{
334-
while (_resampleBuffer.Count >= VoiceActivityDetector.ChunkSamples)
333+
while (_resampleBuffer.Count >= VadChunkSamples)
335334
{
336-
var chunk = _resampleBuffer.GetRange(0, VoiceActivityDetector.ChunkSamples).ToArray();
337-
_resampleBuffer.RemoveRange(0, VoiceActivityDetector.ChunkSamples);
335+
var chunk = _resampleBuffer.GetRange(0, VadChunkSamples).ToArray();
336+
_resampleBuffer.RemoveRange(0, VadChunkSamples);
338337

339338
// Compute RMS energy of this chunk
340339
float energy = 0;
@@ -387,7 +386,7 @@ private void ProcessVadChunks()
387386
_silenceChunksCount = 0;
388387

389388
// Only transcribe if we had enough speech (not just a brief noise)
390-
var durationSec = (float)samples.Length / VoiceActivityDetector.SampleRate;
389+
var durationSec = (float)samples.Length / PipelineSampleRate;
391390
if (_speechChunkCount < 10) // less than ~320ms of actual speech
392391
{
393392
try { DiagnosticMessage?.Invoke("Speak now — I'm listening"); } catch { }
@@ -445,7 +444,7 @@ private async Task TranscribeSamplesAsync(float[] samples, CancellationToken? ov
445444
}
446445

447446
// Skip very short segments (< 0.3 seconds)
448-
if (samples.Length < VoiceActivityDetector.SampleRate * 0.3f)
447+
if (samples.Length < PipelineSampleRate * 0.3f)
449448
{
450449
DiagnosticMessage?.Invoke("Segment too short, skipped");
451450
return;

src/OpenClaw.Tray.WinUI/Services/VoiceService.cs

Lines changed: 4 additions & 112 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
using System;
22
using System.Collections.Generic;
33
using System.Diagnostics;
4-
using System.IO;
54
using System.Linq;
65
using System.Threading;
76
using System.Threading.Tasks;
@@ -28,15 +27,11 @@ public sealed class VoiceService : IAsyncDisposable
2827
private readonly IOpenClawLogger _logger;
2928
private readonly SettingsManager _settings;
3029
private readonly SpeechToTextService _stt;
31-
private readonly VoiceActivityDetector _vad;
3230
private readonly WhisperModelManager _modelManager;
3331
private AudioPipeline? _pipeline;
3432
private VoiceMode _currentMode = VoiceMode.Inactive;
3533
private CancellationTokenSource? _sessionCts;
3634

37-
// Path to the bundled Silero VAD model (deployed with the app)
38-
private string? _vadModelPath;
39-
4035
/// <summary>Current voice interaction mode.</summary>
4136
public VoiceMode CurrentMode => _currentMode;
4237

@@ -106,7 +101,6 @@ public VoiceService(IOpenClawLogger logger, SettingsManager settings)
106101
_logger = logger;
107102
_settings = settings;
108103
_stt = new SpeechToTextService(logger);
109-
_vad = new VoiceActivityDetector(logger);
110104
_modelManager = new WhisperModelManager(SettingsManager.SettingsDirectoryPath, logger);
111105
}
112106

@@ -118,26 +112,6 @@ public async Task InitializeAsync(
118112
IProgress<(long downloaded, long total)>? downloadProgress = null,
119113
CancellationToken cancellationToken = default)
120114
{
121-
// Load VAD model
122-
if (!_vad.IsLoaded)
123-
{
124-
var vadPath = FindVadModelPath();
125-
if (vadPath == null)
126-
{
127-
// Auto-download Silero VAD model
128-
DiagnosticMessage?.Invoke("Downloading voice activity model…");
129-
vadPath = await DownloadVadModelAsync(cancellationToken);
130-
}
131-
if (vadPath != null)
132-
{
133-
_vad.LoadModel(vadPath);
134-
}
135-
else
136-
{
137-
_logger.Info("Silero VAD model not found — VAD will be unavailable");
138-
}
139-
}
140-
141115
// Download Whisper model if needed
142116
var modelName = _settings.SttModelName;
143117
if (!_modelManager.IsModelDownloaded(modelName))
@@ -172,7 +146,7 @@ public async Task StartPushToTalkAsync()
172146
SetMode(VoiceMode.PushToTalk);
173147

174148
_sessionCts = new CancellationTokenSource();
175-
_pipeline = new AudioPipeline(_logger, _stt, _vad);
149+
_pipeline = new AudioPipeline(_logger, _stt);
176150
WirePipelineEvents(_pipeline);
177151

178152
var options = new AudioPipelineOptions
@@ -214,7 +188,7 @@ public async Task StartVoiceChatAsync()
214188
SetMode(VoiceMode.VoiceChat);
215189

216190
_sessionCts = new CancellationTokenSource();
217-
_pipeline = new AudioPipeline(_logger, _stt, _vad);
191+
_pipeline = new AudioPipeline(_logger, _stt);
218192
WirePipelineEvents(_pipeline);
219193

220194
var options = new AudioPipelineOptions
@@ -276,7 +250,7 @@ public async Task<SttListenResult> ListenOnceAsync(SttListenArgs args, Cancellat
276250
using var timeoutCts = new CancellationTokenSource(args.TimeoutMs);
277251
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeoutCts.Token);
278252

279-
var pipeline = new AudioPipeline(_logger, _stt, _vad);
253+
var pipeline = new AudioPipeline(_logger, _stt);
280254
WirePipelineEvents(pipeline);
281255
var tcs = new TaskCompletionSource<SttListenResult>();
282256
var sw = Stopwatch.StartNew();
@@ -368,7 +342,7 @@ public async Task<SttTranscribeResult> TranscribeFixedDurationAsync(
368342

369343
await EnsureInitializedAsync();
370344

371-
var pipeline = new AudioPipeline(_logger, _stt, _vad);
345+
var pipeline = new AudioPipeline(_logger, _stt);
372346
var sw = Stopwatch.StartNew();
373347
try
374348
{
@@ -504,91 +478,9 @@ private void SetMode(VoiceMode mode)
504478
_logger.Info($"Voice mode changed: {mode}");
505479
}
506480

507-
/// <summary>
508-
/// Locate the Silero VAD ONNX model. Looks in the app's Assets folder
509-
/// and the models directory.
510-
/// </summary>
511-
private string? FindVadModelPath()
512-
{
513-
if (_vadModelPath != null && File.Exists(_vadModelPath))
514-
return _vadModelPath;
515-
516-
// Check Assets directory (deployed with the app)
517-
var assetsPath = Path.Combine(AppContext.BaseDirectory, "Assets", "silero_vad.onnx");
518-
if (File.Exists(assetsPath))
519-
{
520-
_vadModelPath = assetsPath;
521-
return assetsPath;
522-
}
523-
524-
// Check models directory
525-
var modelsPath = Path.Combine(SettingsManager.SettingsDirectoryPath, "models", "silero_vad.onnx");
526-
if (File.Exists(modelsPath))
527-
{
528-
_vadModelPath = modelsPath;
529-
return modelsPath;
530-
}
531-
532-
return null;
533-
}
534-
535-
private const string VadDownloadUrl = SileroVadModelManifest.DownloadUrl;
536-
537-
/// <summary>Download the Silero VAD ONNX model if not already present.
538-
/// SHA-256 is verified before the atomic rename so a tampered or
539-
/// truncated file never lands at the canonical path. See
540-
/// <see cref="SileroVadModelManifest"/>.</summary>
541-
private async Task<string?> DownloadVadModelAsync(CancellationToken cancellationToken)
542-
{
543-
var destPath = Path.Combine(SettingsManager.SettingsDirectoryPath, "models", SileroVadModelManifest.FileName);
544-
if (File.Exists(destPath))
545-
return destPath;
546-
547-
_logger.Info("Downloading Silero VAD model...");
548-
Directory.CreateDirectory(Path.GetDirectoryName(destPath)!);
549-
var tempPath = destPath + ".tmp";
550-
try
551-
{
552-
using var http = new System.Net.Http.HttpClient();
553-
http.Timeout = TimeSpan.FromMinutes(5);
554-
using var response = await http.GetAsync(VadDownloadUrl, cancellationToken);
555-
response.EnsureSuccessStatusCode();
556-
using (var fs = new FileStream(tempPath, FileMode.Create, FileAccess.Write, FileShare.None))
557-
{
558-
await response.Content.CopyToAsync(fs, cancellationToken);
559-
}
560-
561-
// SECURITY: verify SHA-256 BEFORE the atomic rename so a
562-
// tampered file never reaches ONNX Runtime.
563-
using (var sha = System.Security.Cryptography.SHA256.Create())
564-
using (var verifyStream = new FileStream(tempPath, FileMode.Open, FileAccess.Read, FileShare.Read, 81920, useAsync: true))
565-
{
566-
var actual = await sha.ComputeHashAsync(verifyStream, cancellationToken);
567-
var actualHex = Convert.ToHexString(actual).ToLowerInvariant();
568-
if (!string.Equals(actualHex, SileroVadModelManifest.Sha256, StringComparison.OrdinalIgnoreCase))
569-
{
570-
throw new System.Security.SecurityException(
571-
"Silero VAD model failed integrity check. The downloaded file does not match the pinned SHA-256.");
572-
}
573-
}
574-
575-
File.Move(tempPath, destPath, overwrite: true);
576-
_logger.Info($"Silero VAD model downloaded and verified ({new FileInfo(destPath).Length:N0} bytes)");
577-
_vadModelPath = destPath;
578-
return destPath;
579-
}
580-
catch (Exception ex)
581-
{
582-
_logger.Error("Failed to download VAD model", ex);
583-
try { if (File.Exists(tempPath)) File.Delete(tempPath); } catch { }
584-
return null;
585-
}
586-
}
587-
588481
public async ValueTask DisposeAsync()
589482
{
590483
await StopAsync();
591484
_stt.Dispose();
592-
_vad.Dispose();
593485
}
594486
}
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
namespace OpenClaw.Tray.Tests;
2+
3+
public sealed class SpeechInputContractTests
4+
{
5+
private static string RepoRoot()
6+
{
7+
var env = Environment.GetEnvironmentVariable("OPENCLAW_REPO_ROOT");
8+
if (!string.IsNullOrWhiteSpace(env) && Directory.Exists(env))
9+
return env;
10+
11+
var d = new DirectoryInfo(AppContext.BaseDirectory);
12+
while (d != null)
13+
{
14+
if (File.Exists(Path.Combine(d.FullName, "openclaw-windows-node.slnx")) &&
15+
Directory.Exists(Path.Combine(d.FullName, "src")))
16+
return d.FullName;
17+
d = d.Parent;
18+
}
19+
20+
throw new InvalidOperationException(
21+
"Could not find repository root. Set OPENCLAW_REPO_ROOT to the repo path.");
22+
}
23+
24+
private static string Read(params string[] parts)
25+
=> File.ReadAllText(Path.Combine(new[] { RepoRoot() }.Concat(parts).ToArray()));
26+
27+
[Fact]
28+
public void VoiceService_DoesNotLoadNativeVad_OnRecordStartup()
29+
{
30+
var cs = Read("src", "OpenClaw.Tray.WinUI", "Services", "VoiceService.cs");
31+
32+
Assert.DoesNotContain("new VoiceActivityDetector", cs);
33+
Assert.DoesNotContain(".LoadModel(vad", cs, StringComparison.OrdinalIgnoreCase);
34+
Assert.DoesNotContain("DownloadVadModelAsync", cs);
35+
}
36+
37+
[Fact]
38+
public void AudioPipeline_UsesManagedEnergyVad_NotNativeVad()
39+
{
40+
var cs = Read("src", "OpenClaw.Tray.WinUI", "Services", "AudioPipeline.cs");
41+
42+
Assert.DoesNotContain("VoiceActivityDetector", cs);
43+
Assert.Contains("energy >= startThreshold", cs);
44+
Assert.Contains("energy >= stayThreshold", cs);
45+
}
46+
}

0 commit comments

Comments
 (0)