Skip to content

Commit ef1c183

Browse files
committed
feat: add browser WebRTC transport for 1.7.0
1 parent b717453 commit ef1c183

43 files changed

Lines changed: 3291 additions & 203 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/pages.yml

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
name: Deploy Web Client
2+
3+
on:
4+
workflow_dispatch:
5+
push:
6+
branches:
7+
- main
8+
paths:
9+
- VoiceCraft.Client/**
10+
- VoiceCraft.Core/**
11+
- VoiceCraft.Network/**
12+
- Directory.Packages.props
13+
- .github/workflows/pages.yml
14+
15+
permissions:
16+
contents: read
17+
pages: write
18+
id-token: write
19+
20+
concurrency:
21+
group: pages
22+
cancel-in-progress: true
23+
24+
jobs:
25+
deploy:
26+
environment:
27+
name: github-pages
28+
url: ${{ steps.deployment.outputs.page_url }}
29+
runs-on: windows-latest
30+
steps:
31+
- uses: actions/checkout@v4
32+
33+
- uses: actions/setup-dotnet@v4
34+
with:
35+
dotnet-version: 10.0.x
36+
37+
- name: Restore workloads
38+
run: dotnet workload restore VoiceCraft.sln
39+
40+
- name: Build browser client
41+
run: dotnet build VoiceCraft.Client/VoiceCraft.Client.Browser/VoiceCraft.Client.Browser.csproj -c Debug
42+
43+
- name: Compose static site
44+
shell: pwsh
45+
run: |
46+
New-Item -ItemType Directory -Path artifacts/browser/wwwroot -Force
47+
Copy-Item VoiceCraft.Client/VoiceCraft.Client.Browser/wwwroot/* artifacts/browser/wwwroot -Recurse -Force
48+
Copy-Item VoiceCraft.Client/VoiceCraft.Client.Browser/bin/Debug/net10.0-browser/wwwroot/* artifacts/browser/wwwroot -Recurse -Force
49+
50+
- name: Keep GitHub Pages from ignoring framework files
51+
run: New-Item -ItemType File -Path artifacts/browser/wwwroot/.nojekyll -Force
52+
53+
- uses: actions/configure-pages@v5
54+
55+
- uses: actions/upload-pages-artifact@v3
56+
with:
57+
path: artifacts/browser/wwwroot
58+
59+
- id: deployment
60+
uses: actions/deploy-pages@v4

Directory.Packages.props

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,8 @@
1414
<PackageVersion Include="Avalonia.iOS" Version="$(AvaloniaVersion)" />
1515
<PackageVersion Include="Avalonia.Browser" Version="$(AvaloniaVersion)" />
1616
<PackageVersion Include="Avalonia.Android" Version="$(AvaloniaVersion)" />
17-
<PackageVersion Include="Microsoft.DotNet.HotReload.WebAssembly.Browser" Version="10.0.203" />
1817
<PackageVersion Include="SkiaSharp.NativeAssets.Win32" Version="3.119.3-preview.1.1" />
18+
<PackageVersion Include="SkiaSharp.NativeAssets.WebAssembly" Version="3.119.4-preview.1.1" />
1919
<PackageVersion Include="HarfBuzzSharp.NativeAssets.Win32" Version="8.3.1.3" />
2020
<PackageVersion Include="Fleck" Version="1.2.0" />
2121
<PackageVersion Include="LiteNetLib" Version="2.1.3" />
@@ -30,10 +30,11 @@
3030
<PackageVersion Include="OpusSharp" Version="1.6.6" />
3131
<PackageVersion Include="Xamarin.AndroidX.Core.SplashScreen" Version="1.2.0.2" />
3232
<PackageVersion Include="Spectre.Console" Version="0.55.2" />
33+
<PackageVersion Include="SIPSorcery" Version="10.0.3" />
3334
<PackageVersion Include="System.CommandLine" Version="2.0.7" />
3435
<PackageVersion Include="coverlet.collector" Version="10.0.0" />
3536
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.5.1" />
3637
<PackageVersion Include="xunit" Version="2.9.3" />
3738
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.5" />
3839
</ItemGroup>
39-
</Project>
40+
</Project>

VoiceCraft.Client/VoiceCraft.Client.Android/NativeBackgroundService.cs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ public class NativeBackgroundService(PermissionsService permissionsService, Func
1414
private readonly SemaphoreSlim _semaphore = new (1, 1);
1515
private Func<Type, object> BackgroundFactory { get; } = backgroundFactory;
1616

17-
public async Task StartServiceAsync<T>(Action<T, Action<string>, Action<string>> startAction) where T : notnull
17+
public async Task StartServiceAsync<T>(Func<T, Action<string>, Action<string>, Task> startAction) where T : notnull
1818
{
1919
await _semaphore.WaitAsync();
2020
try
@@ -109,13 +109,13 @@ public class BackgroundTask(object taskInstance) : IDisposable
109109
public Task? RunningTask { get; private set; }
110110
public object TaskInstance { get; } = taskInstance;
111111

112-
public void Start(Action startAction)
112+
public void Start(Func<Task> startAction)
113113
{
114-
RunningTask = Task.Run(() =>
114+
RunningTask = Task.Run(async () =>
115115
{
116116
try
117117
{
118-
startAction.Invoke();
118+
await startAction.Invoke();
119119
}
120120
finally
121121
{
Lines changed: 242 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,242 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using System.Runtime.InteropServices.JavaScript;
4+
using System.Text.Json;
5+
using System.Text.Json.Serialization;
6+
using System.Threading;
7+
using System.Threading.Tasks;
8+
using VoiceCraft.Client.Services;
9+
10+
namespace VoiceCraft.Client.Browser.Audio;
11+
12+
public sealed class BrowserAudioService : IVoiceCraftAudioService
13+
{
14+
private readonly IEnumerable<RegisteredAudioPreprocessor> _preprocessors;
15+
private readonly IEnumerable<RegisteredAudioClipper> _clippers;
16+
17+
public BrowserAudioService(
18+
IEnumerable<RegisteredAudioPreprocessor> preprocessors,
19+
IEnumerable<RegisteredAudioClipper> clippers)
20+
{
21+
_preprocessors = preprocessors;
22+
_clippers = clippers;
23+
}
24+
25+
public IEnumerable<RegisteredAudioPreprocessor> RegisteredAudioPreprocessors => _preprocessors;
26+
public IEnumerable<RegisteredAudioClipper> RegisteredAudioClippers => _clippers;
27+
28+
public RegisteredAudioPreprocessor? GetAudioPreprocessor(Guid id)
29+
{
30+
return id == Guid.Empty ? null : null;
31+
}
32+
33+
public RegisteredAudioClipper? GetAudioClipper(Guid id)
34+
{
35+
return id == Guid.Empty ? null : null;
36+
}
37+
38+
public IEnumerable<AudioDeviceInfo> GetInputDevices()
39+
{
40+
return ReadDevices(JsAudio.GetInputDevices(), "Default");
41+
}
42+
43+
public IEnumerable<AudioDeviceInfo> GetOutputDevices()
44+
{
45+
return ReadDevices(JsAudio.GetOutputDevices(), "Default");
46+
}
47+
48+
public IAudioCaptureSession InitializeCaptureSession(
49+
int sampleRate,
50+
int channels,
51+
uint frameSize,
52+
string inputDevice,
53+
bool hardwarePreprocessorsEnabled)
54+
{
55+
return new BrowserCaptureSession(sampleRate, channels, frameSize, inputDevice);
56+
}
57+
58+
public IAudioPlaybackSession InitializePlaybackSession(
59+
int sampleRate,
60+
int channels,
61+
uint frameSize,
62+
string outputDevice,
63+
Func<Span<float>, int> read)
64+
{
65+
return new BrowserPlaybackSession(sampleRate, channels, frameSize, outputDevice, read);
66+
}
67+
68+
public IAudioPlaybackSession InitializeTonePlaybackSession(
69+
int sampleRate,
70+
int channels,
71+
uint frameSize,
72+
string outputDevice,
73+
Func<Span<float>, int> read)
74+
{
75+
return new BrowserPlaybackSession(sampleRate, channels, frameSize, outputDevice, read);
76+
}
77+
78+
private static IEnumerable<AudioDeviceInfo> ReadDevices(string json, string defaultDisplayName)
79+
{
80+
var devices = new List<AudioDeviceInfo>
81+
{
82+
new("Default", defaultDisplayName, true)
83+
};
84+
85+
try
86+
{
87+
foreach (var device in JsonSerializer.Deserialize(json, BrowserAudioJsonContext.Default.BrowserDeviceArray) ?? [])
88+
devices.Add(new AudioDeviceInfo(device.DeviceId, string.IsNullOrWhiteSpace(device.Label) ? device.DeviceId : device.Label, false));
89+
}
90+
catch (Exception ex)
91+
{
92+
LogService.Log(ex);
93+
}
94+
95+
return devices;
96+
}
97+
98+
private sealed class BrowserCaptureSession(int sampleRate, int channels, uint frameSize, string inputDevice)
99+
: IAudioCaptureSession
100+
{
101+
private CancellationTokenSource? _cts;
102+
private readonly float[] _buffer = new float[frameSize * channels];
103+
104+
public bool IsRunning { get; private set; }
105+
public event AudioCaptureFrameHandler? OnAudioProcessed;
106+
107+
public void Start()
108+
{
109+
if (IsRunning) return;
110+
JsAudio.StartCapture(sampleRate, channels, (int)frameSize, inputDevice == "Default" ? string.Empty : inputDevice);
111+
IsRunning = true;
112+
_cts = new CancellationTokenSource();
113+
_ = PumpAsync(_cts.Token);
114+
}
115+
116+
public void Stop()
117+
{
118+
if (!IsRunning) return;
119+
IsRunning = false;
120+
_cts?.Cancel();
121+
_cts?.Dispose();
122+
_cts = null;
123+
JsAudio.StopCapture();
124+
}
125+
126+
public void Dispose()
127+
{
128+
Stop();
129+
}
130+
131+
private async Task PumpAsync(CancellationToken cancellationToken)
132+
{
133+
while (!cancellationToken.IsCancellationRequested)
134+
{
135+
try
136+
{
137+
var samplesJson = JsAudio.PollCapture();
138+
while (!string.IsNullOrEmpty(samplesJson))
139+
{
140+
var samples = JsonSerializer.Deserialize(samplesJson, BrowserAudioJsonContext.Default.SingleArray) ?? [];
141+
samples.AsSpan(0, Math.Min(samples.Length, _buffer.Length)).CopyTo(_buffer);
142+
OnAudioProcessed?.Invoke(_buffer);
143+
samplesJson = JsAudio.PollCapture();
144+
}
145+
}
146+
catch (Exception ex)
147+
{
148+
LogService.Log(ex);
149+
IsRunning = false;
150+
return;
151+
}
152+
153+
await Task.Delay(5, cancellationToken);
154+
}
155+
}
156+
}
157+
158+
private sealed class BrowserPlaybackSession(
159+
int sampleRate,
160+
int channels,
161+
uint frameSize,
162+
string outputDevice,
163+
Func<Span<float>, int> read)
164+
: IAudioPlaybackSession
165+
{
166+
private readonly float[] _buffer = new float[frameSize * channels];
167+
168+
public bool IsRunning { get; private set; }
169+
170+
public void Start()
171+
{
172+
if (IsRunning) return;
173+
JsAudio.StartPlayback(sampleRate, channels, (int)frameSize, outputDevice == "Default" ? string.Empty : outputDevice);
174+
IsRunning = true;
175+
}
176+
177+
public void Stop()
178+
{
179+
if (!IsRunning) return;
180+
IsRunning = false;
181+
JsAudio.StopPlayback();
182+
}
183+
184+
public void Pump()
185+
{
186+
if (!IsRunning) return;
187+
var readCount = read(_buffer);
188+
if (readCount > 0)
189+
JsAudio.EnqueuePlayback(JsonSerializer.Serialize(_buffer[..readCount], BrowserAudioJsonContext.Default.SingleArray));
190+
}
191+
192+
public void PlayTone(TimeSpan duration, float frequency)
193+
{
194+
JsAudio.PlayTone(duration.TotalMilliseconds, frequency);
195+
}
196+
197+
public void Dispose()
198+
{
199+
Stop();
200+
}
201+
}
202+
}
203+
204+
internal sealed class BrowserDevice
205+
{
206+
public string DeviceId { get; set; } = string.Empty;
207+
public string Label { get; set; } = string.Empty;
208+
}
209+
210+
[JsonSerializable(typeof(BrowserDevice[]))]
211+
[JsonSerializable(typeof(float[]))]
212+
internal partial class BrowserAudioJsonContext : JsonSerializerContext;
213+
214+
internal static partial class JsAudio
215+
{
216+
[JSImport("getInputDevices", "audio.js")]
217+
internal static partial string GetInputDevices();
218+
219+
[JSImport("getOutputDevices", "audio.js")]
220+
internal static partial string GetOutputDevices();
221+
222+
[JSImport("startCapture", "audio.js")]
223+
internal static partial void StartCapture(int sampleRate, int channels, int frameSize, string deviceId);
224+
225+
[JSImport("pollCapture", "audio.js")]
226+
internal static partial string PollCapture();
227+
228+
[JSImport("stopCapture", "audio.js")]
229+
internal static partial void StopCapture();
230+
231+
[JSImport("startPlayback", "audio.js")]
232+
internal static partial void StartPlayback(int sampleRate, int channels, int frameSize, string deviceId);
233+
234+
[JSImport("enqueuePlayback", "audio.js")]
235+
internal static partial void EnqueuePlayback(string samplesJson);
236+
237+
[JSImport("stopPlayback", "audio.js")]
238+
internal static partial void StopPlayback();
239+
240+
[JSImport("playTone", "audio.js")]
241+
internal static partial void PlayTone(double durationMs, float frequency);
242+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
using System;
2+
using OpusSharp.Core;
3+
using VoiceCraft.Core;
4+
using VoiceCraft.Core.Interfaces;
5+
6+
namespace VoiceCraft.Client.Browser.Audio;
7+
8+
public sealed class BrowserOpusAudioDecoder : IAudioDecoder
9+
{
10+
private readonly OpusDecoder _opusDecoder = new(Constants.SampleRate, Constants.RecordingChannels, true);
11+
private bool _disposed;
12+
13+
public int Decode(Span<byte> buffer, Span<float> output, int samples)
14+
{
15+
ThrowIfDisposed();
16+
return _opusDecoder.Decode(buffer, buffer.Length, output, samples, false);
17+
}
18+
19+
public void Dispose()
20+
{
21+
if (_disposed) return;
22+
_opusDecoder.Dispose();
23+
_disposed = true;
24+
}
25+
26+
private void ThrowIfDisposed()
27+
{
28+
if (_disposed)
29+
throw new ObjectDisposedException(typeof(BrowserOpusAudioDecoder).ToString());
30+
}
31+
}

0 commit comments

Comments
 (0)