Skip to content

Commit

Permalink
WIP v1.7.5 (#319)
Browse files Browse the repository at this point in the history
* Bumped whisper.cpp

* WIP

* Removed CLAngCL toolset

* Try again

* one more try

* Try again

* Try again with toolchain files

* Little cleanup

* Removed DCMAKE_SYSTEM_NAME

* Added possibility to authenticate HF Downloader for model download + retrieve token from secrets in github actions

* Move dotnet to Windows 2019 (huggingface models are not downloading with windows-latest)

* Restored to windows-latest and activated verbosity diag for windows tests

* Disposing the stream

* Reverted x64 build to go with MSVC

* Test

* temp

* removed temp code

* Removed out of support net6

* Switched the check for ProcessArchitecture instead of OSArchitecture fixing #342

* Bumped whisper.cpp

* Reverted to whisper.cpp v.1.7.4 + state fix

* Fixed coreml build + removed tvos coreml as it is not compatible anymore

---------

Co-authored-by: Sandro Hanea <sandro@echosharp.net>
  • Loading branch information
sandrohanea and Sandro Hanea authored Feb 15, 2025
1 parent eae8f62 commit d406461
Show file tree
Hide file tree
Showing 42 changed files with 253 additions and 231 deletions.
2 changes: 2 additions & 0 deletions .github/workflows/build-all.yml
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ jobs:
- wasm
- linux
uses: ./.github/workflows/dotnet.yml
secrets: inherit

dotnet-maui-build-and-test:
needs:
Expand All @@ -85,3 +86,4 @@ jobs:
- wasm
- linux
uses: ./.github/workflows/dotnet-maui.yml
secrets: inherit
3 changes: 3 additions & 0 deletions .github/workflows/dotnet.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ permissions:
contents: read
checks: write

env:
HF_TOKEN: ${{ secrets.HF_TOKEN }}

jobs:
dotnet-macos:
runs-on: macos-15
Expand Down
20 changes: 12 additions & 8 deletions .github/workflows/windows-native-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,12 @@ jobs:
with:
submodules: true
ref: ${{ github.head_ref }}

- name: Add msbuild to PATH
uses: microsoft/setup-msbuild@v2


- name: Install Ninja
id: install_ninja
run: |
choco install ninja
- name: Run Build
run: |
Import-Module ./windows-scripts.ps1
Expand All @@ -39,10 +41,12 @@ jobs:
with:
submodules: true
ref: ${{ github.head_ref }}

- name: Add msbuild to PATH
uses: microsoft/setup-msbuild@v2


- name: Install Ninja
id: install_ninja
run: |
choco install ninja
- name: Run Build
run: |
Import-Module ./windows-scripts.ps1
Expand Down
10 changes: 6 additions & 4 deletions .github/workflows/windows-noavx-native-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,12 @@ jobs:
with:
submodules: true
ref: ${{ github.head_ref }}

- name: Add msbuild to PATH
uses: microsoft/setup-msbuild@v2


- name: Install Ninja
id: install_ninja
run: |
choco install ninja
- name: Run Build
run: |
Import-Module ./windows-scripts.ps1
Expand Down
6 changes: 0 additions & 6 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,6 @@
#
cmake_minimum_required (VERSION 3.8)

# Enable Hot Reload for MSVC compilers if supported.
if (POLICY CMP0141)
cmake_policy(SET CMP0141 NEW)
set(CMAKE_MSVC_DEBUG_INFORMATION_FORMAT "$<IF:$<AND:$<C_COMPILER_ID:MSVC>,$<CXX_COMPILER_ID:MSVC>>,$<$<CONFIG:Debug,RelWithDebInfo>:EditAndContinue>,$<$<CONFIG:Debug,RelWithDebInfo>:ProgramDatabase>>")
endif()

project ("Whisper.net")

# Include sub-projects.
Expand Down
32 changes: 4 additions & 28 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ apple_x64: copy_metal macos_x64
apple_arm: macos_arm64 ios maccatalyst_arm64 ios_simulator_arm64 tvos_simulator_arm64 tvos

apple_coreml_x64: copy_metal_coreml macos_x64_coreml
apple_coreml_arm: macos_arm64_coreml ios_coreml maccatalyst_arm64_coreml ios_simulator_coreml tvos_simulator_coreml tvos_coreml
apple_coreml_arm: macos_arm64_coreml ios_coreml maccatalyst_arm64_coreml ios_simulator_coreml

linux: linux_x64 linux_arm64 linux_arm

Expand Down Expand Up @@ -193,7 +193,7 @@ macos_arm64_coreml:

ios:
rm -rf build/ios
cmake $(CMAKE_PARAMETERS) -DCMAKE_OSX_SYSROOT="iphoneos" -S . -B build/ios
cmake $(CMAKE_PARAMETERS) -DCMAKE_OSX_SYSROOT="iphoneos" -DCMAKE_SYSTEM_NAME=iOS -S . -B build/ios
cmake --build build/ios
mkdir -p runtimes/Whisper.net.Runtime/ios-device
cp build/ios/whisper.cpp/src/libwhisper.dylib runtimes/Whisper.net.Runtime/ios-device/libwhisper.dylib
Expand All @@ -205,7 +205,7 @@ ios:

ios_coreml:
rm -rf build/ios-coreml
cmake $(COREML_SUPPORT) -DCMAKE_OSX_ARCHITECTURES="arm64" -DGGML_METAL=OFF -DCMAKE_OSX_SYSROOT="iphoneos" -S . -B build/ios-coreml
cmake $(COREML_SUPPORT) -DCMAKE_OSX_SYSROOT="iphoneos" -DGGML_METAL=OFF -DCMAKE_SYSTEM_NAME=iOS -S . -B build/ios-coreml
cmake --build build/ios-coreml
mkdir -p runtimes/Whisper.net.Runtime.CoreML/ios-device
cp build/ios-coreml/whisper.cpp/src/libwhisper.coreml.dylib runtimes/Whisper.net.Runtime.CoreML/ios-device/libwhisper.coreml.dylib
Expand Down Expand Up @@ -274,7 +274,7 @@ tvos_simulator_arm64:

tvos:
rm -rf build/tvos
cmake $(CMAKE_PARAMETERS) -DCMAKE_OSX_SYSROOT="appletvos" -DCMAKE_OSX_ARCHITECTURES="arm64" -S . -B build/tvos
cmake $(CMAKE_PARAMETERS) -DCMAKE_OSX_SYSROOT="appletvos" -DCMAKE_SYSTEM_NAME=tvOS -S . -B build/tvos
cmake --build build/tvos
mkdir -p runtimes/Whisper.net.Runtime/tvos-device
cp build/tvos/whisper.cpp/src/libwhisper.dylib runtimes/Whisper.net.Runtime/tvos-device/libwhisper.dylib
Expand All @@ -284,30 +284,6 @@ tvos:
cp build/tvos/whisper.cpp/ggml/src/ggml-blas/libggml-blas-whisper.dylib runtimes/Whisper.net.Runtime/tvos-device/libggml-blas-whisper.dylib
cp build/tvos/whisper.cpp/ggml/src/ggml-metal/libggml-metal-whisper.dylib runtimes/Whisper.net.Runtime/tvos-device/libggml-metal-whisper.dylib

tvos_coreml:
rm -rf build/tvos-coreml
cmake $(COREML_SUPPORT) -DCMAKE_OSX_SYSROOT="appletvos" -DGGML_METAL=OFF -DCMAKE_OSX_ARCHITECTURES="arm64" -S . -B build/tvos-coreml
cmake --build build/tvos-coreml
mkdir -p runtimes/Whisper.net.Runtime.CoreML/tvos-device
cp build/tvos-coreml/whisper.cpp/src/libwhisper.coreml.dylib runtimes/Whisper.net.Runtime.CoreML/tvos-device/libwhisper.coreml.dylib
cp build/tvos-coreml/whisper.cpp/src/libwhisper.dylib runtimes/Whisper.net.Runtime.CoreML/tvos-device/libwhisper.dylib
cp build/tvos-coreml/whisper.cpp/ggml/src/libggml-whisper.dylib runtimes/Whisper.net.Runtime.CoreML/tvos-device/libggml-whisper.dylib
cp build/tvos-coreml/whisper.cpp/ggml/src/libggml-base-whisper.dylib runtimes/Whisper.net.Runtime.CoreML/tvos-device/libggml-base-whisper.dylib
cp build/tvos-coreml/whisper.cpp/ggml/src/libggml-cpu-whisper.dylib runtimes/Whisper.net.Runtime.CoreML/tvos-device/libggml-cpu-whisper.dylib
cp build/tvos-coreml/whisper.cpp/ggml/src/ggml-blas/libggml-blas-whisper.dylib runtimes/Whisper.net.Runtime.CoreML/tvos-device/libggml-blas-whisper.dylib

tvos_simulator_coreml:
rm -rf build/tvos-simulator-coreml
cmake $(COREML_SUPPORT) -DCMAKE_OSX_SYSROOT="appletvsimulator" -DGGML_METAL=OFF -DCMAKE_OSX_ARCHITECTURES="arm64;x86_64" -S . -B build/tvos-simulator-coreml
cmake --build build/tvos-simulator-coreml
mkdir -p runtimes/Whisper.net.Runtime.CoreML/tvos-simulator
cp build/tvos-simulator-coreml/whisper.cpp/src/libwhisper.coreml.dylib runtimes/Whisper.net.Runtime.CoreML/tvos-simulator/libwhisper.coreml.dylib
cp build/tvos-simulator-coreml/whisper.cpp/src/libwhisper.dylib runtimes/Whisper.net.Runtime.CoreML/tvos-simulator/libwhisper.dylib
cp build/tvos-simulator-coreml/whisper.cpp/ggml/src/libggml-whisper.dylib runtimes/Whisper.net.Runtime.CoreML/tvos-simulator/libggml-whisper.dylib
cp build/tvos-simulator-coreml/whisper.cpp/ggml/src/libggml-base-whisper.dylib runtimes/Whisper.net.Runtime.CoreML/tvos-simulator/libggml-base-whisper.dylib
cp build/tvos-simulator-coreml/whisper.cpp/ggml/src/libggml-cpu-whisper.dylib runtimes/Whisper.net.Runtime.CoreML/tvos-simulator/libggml-cpu-whisper.dylib
cp build/tvos-simulator-coreml/whisper.cpp/ggml/src/ggml-blas/libggml-blas-whisper.dylib runtimes/Whisper.net.Runtime.CoreML/tvos-simulator/libggml-blas-whisper.dylib

android_arm64-v8a:
rm -rf build/android-arm64-v8a
cmake $(CMAKE_PARAMETERS) -DCMAKE_ANDROID_ARCH_ABI=arm64-v8a -DCMAKE_SYSTEM_NAME=Android -DCMAKE_ANDROID_API=21 -DCMAKE_ANDROID_NDK=$(NDK) -DGGML_OPENMP=OFF -S . -B build/android-arm64-v8a
Expand Down
2 changes: 1 addition & 1 deletion Whisper.net.Demo/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ async Task Demo(Options opt)
if (!File.Exists(opt.ModelName))
{
Console.WriteLine($"Downloading Model {opt.ModelName}");
using var modelStream = await WhisperGgmlDownloader.GetGgmlModelAsync(opt.ModelType);
using var modelStream = await WhisperGgmlDownloader.Default.GetGgmlModelAsync(opt.ModelType);
using var fileWriter = File.OpenWrite(opt.ModelName);
await modelStream.CopyToAsync(fileWriter);
}
Expand Down
67 changes: 26 additions & 41 deletions Whisper.net/Ggml/WhisperGgmlDownloader.cs
Original file line number Diff line number Diff line change
@@ -1,12 +1,24 @@
// Licensed under the MIT license: https://opensource.org/licenses/MIT

using System.IO.Compression;

namespace Whisper.net.Ggml;

public static class WhisperGgmlDownloader
public class WhisperGgmlDownloader(HttpClient httpClient)
{
private static readonly Lazy<HttpClient> httpClient = new(() => new HttpClient() { Timeout = Timeout.InfiniteTimeSpan });
private static readonly Lazy<WhisperGgmlDownloader> defaultInstance = new
(
() => new WhisperGgmlDownloader(new() { Timeout = TimeSpan.FromHours(1) })
);

/// <summary>
/// The default instance of the downloader, which uses an unauthenticated client with a 1 hour timeout.
/// </summary>
/// <remarks>
/// If running in an environment where the default timeout is not sufficient or
/// multiple requests are being made from the same IP address (e.g. Github Actions with public runners),
/// consider creating a new instance of the downloader with a custom <see cref="HttpClient"/> instance.
/// The HttpClient should have a longer timeout and, if necessary, an authorization header with a Hugging Face token.
/// </remarks>
public static WhisperGgmlDownloader Default { get; } = defaultInstance.Value;

/// <summary>
/// Gets the download stream for the model
Expand All @@ -15,21 +27,16 @@ public static class WhisperGgmlDownloader
/// <param name="quantization">The quantization of the model.</param>
/// <param name="cancellationToken">A cancellation token used to cancell the request to huggingface.</param>
/// <exception cref="ArgumentOutOfRangeException"></exception>
public static async Task<Stream> GetGgmlModelAsync(GgmlType type, QuantizationType quantization = QuantizationType.NoQuantization, CancellationToken cancellationToken = default)
public async Task<Stream> GetGgmlModelAsync(GgmlType type, QuantizationType quantization = QuantizationType.NoQuantization, CancellationToken cancellationToken = default)
{
var subdirectory = GetQuantizationSubdirectory(quantization);
var modelName = GetModelName(type);

var url = $"https://huggingface.co/sandrohanea/whisper.net/resolve/v3/{subdirectory}/{modelName}.bin";

var request = new HttpRequestMessage(HttpMethod.Get, url);
var response = await httpClient.Value.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
response.EnsureSuccessStatusCode();

#if NETSTANDARD
return await response.Content.ReadAsStreamAsync();
return await httpClient.GetStreamAsync(url);
#else
return await response.Content.ReadAsStreamAsync(cancellationToken);
return await httpClient.GetStreamAsync(url, cancellationToken);
#endif
}

Expand All @@ -39,17 +46,14 @@ public static async Task<Stream> GetGgmlModelAsync(GgmlType type, QuantizationTy
/// <param name="type">The type of the model which needs to be downloaded.</param>
/// <param name="cancellationToken">A cancellation token used to stop the request to huggingface.</param>
/// <returns></returns>
public static async Task<Stream> GetEncoderOpenVinoModelAsync(GgmlType type, CancellationToken cancellationToken = default)
public async Task<Stream> GetEncoderOpenVinoModelAsync(GgmlType type, CancellationToken cancellationToken = default)
{
var modelName = GetModelName(type);
var url = $"https://huggingface.co/sandrohanea/whisper.net/resolve/v3/openvino/{modelName}-encoder.zip";
var request = new HttpRequestMessage(HttpMethod.Get, url);
var response = await httpClient.Value.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
response.EnsureSuccessStatusCode();
#if NETSTANDARD
return await response.Content.ReadAsStreamAsync();
return await httpClient.GetStreamAsync(url);
#else
return await response.Content.ReadAsStreamAsync(cancellationToken);
return await httpClient.GetStreamAsync(url, cancellationToken);
#endif
}

Expand All @@ -58,7 +62,7 @@ public static async Task<Stream> GetEncoderOpenVinoModelAsync(GgmlType type, Can
/// </summary>
/// <param name="type"> The type of the model which needs to be loaded</param>
/// <returns></returns>
public static string GetOpenVinoManifestFileName(GgmlType type)
public string GetOpenVinoManifestFileName(GgmlType type)
{
var modelName = GetModelName(type);
return $"{modelName}-encoder.xml";
Expand All @@ -73,37 +77,18 @@ public static string GetOpenVinoManifestFileName(GgmlType type)
/// Needs to be extracted on in the same directory as the ggml model, also ggml model needs to be loaded using file path, not stream.
/// </remarks>
/// <exception cref="ArgumentOutOfRangeException"></exception>
public static async Task<Stream> GetEncoderCoreMLModelAsync(GgmlType type, CancellationToken cancellationToken = default)
public async Task<Stream> GetEncoderCoreMLModelAsync(GgmlType type, CancellationToken cancellationToken = default)
{
var modelName = GetModelName(type);
var url = $"https://huggingface.co/sandrohanea/whisper.net/resolve/v3/coreml/{modelName}-encoder.zip";

var request = new HttpRequestMessage(HttpMethod.Get, url);
var response = await httpClient.Value.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
response.EnsureSuccessStatusCode();

#if NETSTANDARD
return await response.Content.ReadAsStreamAsync();
return await httpClient.GetStreamAsync(url);
#else
return await response.Content.ReadAsStreamAsync(cancellationToken);
return await httpClient.GetStreamAsync(url, cancellationToken);
#endif
}

/// <summary>
/// Extracts the given zip stream to the given path.
/// </summary>
/// <param name="zipStream">The zip stream to be extracted.</param>
/// <param name="path">The path.</param>
/// <remarks>
/// In order to work, you'll need to provide the same path as the ggml model.
/// </remarks>
/// <returns></returns>
public static async Task ExtractToPath(this Task<Stream> zipStream, string path)
{
using var zipArchive = new ZipArchive(await zipStream, ZipArchiveMode.Read);
zipArchive.ExtractToDirectory(path);
}

private static string GetModelName(GgmlType type)
{
return type switch
Expand Down
23 changes: 23 additions & 0 deletions Whisper.net/Ggml/ZipStreamExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
// Licensed under the MIT license: https://opensource.org/licenses/MIT

using System.IO.Compression;

namespace Whisper.net.Ggml;

public static class ZipStreamExtensions
{
/// <summary>
/// Extracts the given zip stream to the given path.
/// </summary>
/// <param name="zipStream">The zip stream to be extracted.</param>
/// <param name="path">The path.</param>
/// <remarks>
/// In order to work, you'll need to provide the same path as the ggml model.
/// </remarks>
/// <returns></returns>
public static async Task ExtractToPath(this Task<Stream> zipStream, string path)
{
using var zipArchive = new ZipArchive(await zipStream, ZipArchiveMode.Read);
zipArchive.ExtractToDirectory(path);
}
}
5 changes: 5 additions & 0 deletions Whisper.net/Internals/Native/INativeWhisper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,9 @@ internal interface INativeWhisper : IDisposable
[UnmanagedFunctionPointer(CallingConvention.Cdecl, CharSet = CharSet.Ansi)]
public delegate IntPtr whisper_print_system_info();

[UnmanagedFunctionPointer(CallingConvention.Cdecl, CharSet = CharSet.Ansi)]
public delegate float whisper_full_get_segment_no_speech_prob_from_state(IntPtr state, int index);

whisper_init_from_file_with_params_no_state Whisper_Init_From_File_With_Params_No_State { get; }
whisper_init_from_buffer_with_params_no_state Whisper_Init_From_Buffer_With_Params_No_State { get; }
whisper_free Whisper_Free { get; }
Expand All @@ -103,4 +106,6 @@ internal interface INativeWhisper : IDisposable
whisper_full_get_token_data_from_state Whisper_Full_Get_Token_Data_From_State { get; }
whisper_full_get_token_text_from_state Whisper_Full_Get_Token_Text_From_State { get; }
whisper_print_system_info WhisperPrintSystemInfo { get; }

whisper_full_get_segment_no_speech_prob_from_state Whisper_Full_Get_Segment_No_Speech_Prob_From_State { get; }
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
// Licensed under the MIT license: https://opensource.org/licenses/MIT
#if NET6_0_OR_GREATER
#if NET8_0_OR_GREATER
using System.Runtime.InteropServices;
using static Whisper.net.Internals.Native.INativeCuda;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,9 @@ internal class DllImportsNativeLibWhisper : INativeWhisper
[DllImport(NativeConstants.LibWhisperLibraryName, CallingConvention = CallingConvention.Cdecl, CharSet = CharSet.Ansi)]
public static extern IntPtr whisper_print_system_info();

[DllImport(NativeConstants.LibWhisperLibraryName, CallingConvention = CallingConvention.Cdecl, CharSet = CharSet.Ansi)]
public static extern float whisper_full_get_segment_no_speech_prob_from_state(IntPtr state, int index);

public INativeWhisper.whisper_init_from_file_with_params_no_state Whisper_Init_From_File_With_Params_No_State => whisper_init_from_file_with_params_no_state;

public INativeWhisper.whisper_init_from_buffer_with_params_no_state Whisper_Init_From_Buffer_With_Params_No_State => whisper_init_from_buffer_with_params_no_state;
Expand Down Expand Up @@ -130,6 +133,8 @@ internal class DllImportsNativeLibWhisper : INativeWhisper

public INativeWhisper.whisper_print_system_info WhisperPrintSystemInfo => whisper_print_system_info;

public INativeWhisper.whisper_full_get_segment_no_speech_prob_from_state Whisper_Full_Get_Segment_No_Speech_Prob_From_State => whisper_full_get_segment_no_speech_prob_from_state;

public void Dispose()
{

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,9 @@ internal class DllImportsNativeWhisper : INativeWhisper
[DllImport(NativeConstants.WhisperLibraryName, CallingConvention = CallingConvention.Cdecl, CharSet = CharSet.Ansi)]
public static extern IntPtr whisper_print_system_info();

[DllImport(NativeConstants.WhisperLibraryName, CallingConvention = CallingConvention.Cdecl, CharSet = CharSet.Ansi)]
public static extern float whisper_full_get_segment_no_speech_prob_from_state(IntPtr state, int index);

public INativeWhisper.whisper_init_from_file_with_params_no_state Whisper_Init_From_File_With_Params_No_State => whisper_init_from_file_with_params_no_state;

public INativeWhisper.whisper_init_from_buffer_with_params_no_state Whisper_Init_From_Buffer_With_Params_No_State => whisper_init_from_buffer_with_params_no_state;
Expand Down Expand Up @@ -130,6 +133,8 @@ internal class DllImportsNativeWhisper : INativeWhisper

public INativeWhisper.whisper_print_system_info WhisperPrintSystemInfo => whisper_print_system_info;

public INativeWhisper.whisper_full_get_segment_no_speech_prob_from_state Whisper_Full_Get_Segment_No_Speech_Prob_From_State => whisper_full_get_segment_no_speech_prob_from_state;

public void Dispose()
{

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,9 @@ internal partial class LibraryImportInternalWhisper : INativeWhisper
[LibraryImport(NativeConstants.InternalLibraryName, StringMarshalling = StringMarshalling.Utf8)]
public static partial IntPtr whisper_print_system_info();

[LibraryImport(NativeConstants.InternalLibraryName, StringMarshalling = StringMarshalling.Utf8)]
public static partial float whisper_full_get_segment_no_speech_prob_from_state(IntPtr state, int index);

public INativeWhisper.whisper_init_from_file_with_params_no_state Whisper_Init_From_File_With_Params_No_State => whisper_init_from_file_with_params_no_state;

public INativeWhisper.whisper_init_from_buffer_with_params_no_state Whisper_Init_From_Buffer_With_Params_No_State => whisper_init_from_buffer_with_params_no_state;
Expand Down Expand Up @@ -130,6 +133,8 @@ internal partial class LibraryImportInternalWhisper : INativeWhisper

public INativeWhisper.whisper_print_system_info WhisperPrintSystemInfo => whisper_print_system_info;

public INativeWhisper.whisper_full_get_segment_no_speech_prob_from_state Whisper_Full_Get_Segment_No_Speech_Prob_From_State => whisper_full_get_segment_no_speech_prob_from_state;

public void Dispose()
{

Expand Down
Loading

0 comments on commit d406461

Please sign in to comment.