mirror of
https://github.com/unclshura/ABStemPlayer.git
synced 2026-08-07 00:43:38 +00:00
Add project files.
This commit is contained in:
commit
ea45bef297
31
.copilot/copilot-instructions.md
Normal file
31
.copilot/copilot-instructions.md
Normal file
@ -0,0 +1,31 @@
|
||||
You are c# programmer. I'm senior c# programmer with 30+ years of experience.
|
||||
Do not be overconfident about your answers - they are 70% incorrect.
|
||||
Do not say "final solution". Do not start every reply with my name.
|
||||
Do not use emoji or non-ascii symbols. Do not explain "why it work".
|
||||
|
||||
I'm developing ffmpeg-based audio player for music students.
|
||||
|
||||
Features:
|
||||
|
||||
* Play/Stop/Pause/FF/FB
|
||||
* Source track separation to "drums", "bass", "other", "vocals", "guitar", "piano"
|
||||
* Audio mixer for stems with on/off switch, gain and pane controls
|
||||
* Change playback speed without changing pitch
|
||||
* A-B loop repeat with multiple segments
|
||||
* Wave form visualisation on the playback position indicator
|
||||
|
||||
Rules:
|
||||
|
||||
* Very latest everything: .NET 10, C# language features, all nuget packages
|
||||
* Use Microsoft DI for resolving implementations.
|
||||
* Absolutely no per-frame allocations. Use `AudioBufferPool` to "borrow" buffers.
|
||||
|
||||
Architecture:
|
||||
|
||||
* AudioCore - all data processing algorithms. Namespaces:
|
||||
* AudioCore.Models - data models
|
||||
* AudioCore.Interfaces - all public interfaces
|
||||
* AudioCore.Impl - implementations
|
||||
* AudioCore_Tests - unit tests using MSTest
|
||||
* ABStemPlayer - Avalonia 12 UI. Namespace: ABStemPlayer
|
||||
|
||||
63
.gitattributes
vendored
Normal file
63
.gitattributes
vendored
Normal file
@ -0,0 +1,63 @@
|
||||
###############################################################################
|
||||
# Set default behavior to automatically normalize line endings.
|
||||
###############################################################################
|
||||
* text=auto
|
||||
|
||||
###############################################################################
|
||||
# Set default behavior for command prompt diff.
|
||||
#
|
||||
# This is need for earlier builds of msysgit that does not have it on by
|
||||
# default for csharp files.
|
||||
# Note: This is only used by command line
|
||||
###############################################################################
|
||||
#*.cs diff=csharp
|
||||
|
||||
###############################################################################
|
||||
# Set the merge driver for project and solution files
|
||||
#
|
||||
# Merging from the command prompt will add diff markers to the files if there
|
||||
# are conflicts (Merging from VS is not affected by the settings below, in VS
|
||||
# the diff markers are never inserted). Diff markers may cause the following
|
||||
# file extensions to fail to load in VS. An alternative would be to treat
|
||||
# these files as binary and thus will always conflict and require user
|
||||
# intervention with every merge. To do so, just uncomment the entries below
|
||||
###############################################################################
|
||||
#*.sln merge=binary
|
||||
#*.csproj merge=binary
|
||||
#*.vbproj merge=binary
|
||||
#*.vcxproj merge=binary
|
||||
#*.vcproj merge=binary
|
||||
#*.dbproj merge=binary
|
||||
#*.fsproj merge=binary
|
||||
#*.lsproj merge=binary
|
||||
#*.wixproj merge=binary
|
||||
#*.modelproj merge=binary
|
||||
#*.sqlproj merge=binary
|
||||
#*.wwaproj merge=binary
|
||||
|
||||
###############################################################################
|
||||
# behavior for image files
|
||||
#
|
||||
# image files are treated as binary by default.
|
||||
###############################################################################
|
||||
#*.jpg binary
|
||||
#*.png binary
|
||||
#*.gif binary
|
||||
|
||||
###############################################################################
|
||||
# diff behavior for common document formats
|
||||
#
|
||||
# Convert binary document formats to text before diffing them. This feature
|
||||
# is only available from the command line. Turn it on by uncommenting the
|
||||
# entries below.
|
||||
###############################################################################
|
||||
#*.doc diff=astextplain
|
||||
#*.DOC diff=astextplain
|
||||
#*.docx diff=astextplain
|
||||
#*.DOCX diff=astextplain
|
||||
#*.dot diff=astextplain
|
||||
#*.DOT diff=astextplain
|
||||
#*.pdf diff=astextplain
|
||||
#*.PDF diff=astextplain
|
||||
#*.rtf diff=astextplain
|
||||
#*.RTF diff=astextplain
|
||||
366
.gitignore
vendored
Normal file
366
.gitignore
vendored
Normal file
@ -0,0 +1,366 @@
|
||||
## Ignore Visual Studio temporary files, build results, and
|
||||
## files generated by popular Visual Studio add-ons.
|
||||
##
|
||||
## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore
|
||||
|
||||
# User-specific files
|
||||
*.rsuser
|
||||
*.suo
|
||||
*.user
|
||||
*.userosscache
|
||||
*.sln.docstates
|
||||
|
||||
# User-specific files (MonoDevelop/Xamarin Studio)
|
||||
*.userprefs
|
||||
|
||||
# Mono auto generated files
|
||||
mono_crash.*
|
||||
|
||||
# Build results
|
||||
[Dd]ebug/
|
||||
[Dd]ebugPublic/
|
||||
[Rr]elease/
|
||||
[Rr]eleases/
|
||||
x64/
|
||||
x86/
|
||||
[Ww][Ii][Nn]32/
|
||||
[Aa][Rr][Mm]/
|
||||
[Aa][Rr][Mm]64/
|
||||
bld/
|
||||
[Bb]in/
|
||||
[Oo]bj/
|
||||
[Oo]ut/
|
||||
[Ll]og/
|
||||
[Ll]ogs/
|
||||
|
||||
# Visual Studio 2015/2017 cache/options directory
|
||||
.vs/
|
||||
# Uncomment if you have tasks that create the project's static files in wwwroot
|
||||
#wwwroot/
|
||||
|
||||
# Visual Studio 2017 auto generated files
|
||||
Generated\ Files/
|
||||
|
||||
# MSTest test Results
|
||||
[Tt]est[Rr]esult*/
|
||||
[Bb]uild[Ll]og.*
|
||||
|
||||
# NUnit
|
||||
*.VisualState.xml
|
||||
TestResult.xml
|
||||
nunit-*.xml
|
||||
|
||||
# Build Results of an ATL Project
|
||||
[Dd]ebugPS/
|
||||
[Rr]eleasePS/
|
||||
dlldata.c
|
||||
|
||||
# Benchmark Results
|
||||
BenchmarkDotNet.Artifacts/
|
||||
|
||||
# .NET Core
|
||||
project.lock.json
|
||||
project.fragment.lock.json
|
||||
artifacts/
|
||||
|
||||
# ASP.NET Scaffolding
|
||||
ScaffoldingReadMe.txt
|
||||
|
||||
# StyleCop
|
||||
StyleCopReport.xml
|
||||
|
||||
# Files built by Visual Studio
|
||||
*_i.c
|
||||
*_p.c
|
||||
*_h.h
|
||||
*.ilk
|
||||
*.meta
|
||||
*.obj
|
||||
*.iobj
|
||||
*.pch
|
||||
*.pdb
|
||||
*.ipdb
|
||||
*.pgc
|
||||
*.pgd
|
||||
*.rsp
|
||||
*.sbr
|
||||
*.tlb
|
||||
*.tli
|
||||
*.tlh
|
||||
*.tmp
|
||||
*.tmp_proj
|
||||
*_wpftmp.csproj
|
||||
*.log
|
||||
*.vspscc
|
||||
*.vssscc
|
||||
.builds
|
||||
*.pidb
|
||||
*.svclog
|
||||
*.scc
|
||||
|
||||
# Chutzpah Test files
|
||||
_Chutzpah*
|
||||
|
||||
# Visual C++ cache files
|
||||
ipch/
|
||||
*.aps
|
||||
*.ncb
|
||||
*.opendb
|
||||
*.opensdf
|
||||
*.sdf
|
||||
*.cachefile
|
||||
*.VC.db
|
||||
*.VC.VC.opendb
|
||||
|
||||
# Visual Studio profiler
|
||||
*.psess
|
||||
*.vsp
|
||||
*.vspx
|
||||
*.sap
|
||||
|
||||
# Visual Studio Trace Files
|
||||
*.e2e
|
||||
|
||||
# TFS 2012 Local Workspace
|
||||
$tf/
|
||||
|
||||
# Guidance Automation Toolkit
|
||||
*.gpState
|
||||
|
||||
# ReSharper is a .NET coding add-in
|
||||
_ReSharper*/
|
||||
*.[Rr]e[Ss]harper
|
||||
*.DotSettings.user
|
||||
|
||||
# TeamCity is a build add-in
|
||||
_TeamCity*
|
||||
|
||||
# DotCover is a Code Coverage Tool
|
||||
*.dotCover
|
||||
|
||||
# AxoCover is a Code Coverage Tool
|
||||
.axoCover/*
|
||||
!.axoCover/settings.json
|
||||
|
||||
# Coverlet is a free, cross platform Code Coverage Tool
|
||||
coverage*.json
|
||||
coverage*.xml
|
||||
coverage*.info
|
||||
|
||||
# Visual Studio code coverage results
|
||||
*.coverage
|
||||
*.coveragexml
|
||||
|
||||
# NCrunch
|
||||
_NCrunch_*
|
||||
.*crunch*.local.xml
|
||||
nCrunchTemp_*
|
||||
|
||||
# MightyMoose
|
||||
*.mm.*
|
||||
AutoTest.Net/
|
||||
|
||||
# Web workbench (sass)
|
||||
.sass-cache/
|
||||
|
||||
# Installshield output folder
|
||||
[Ee]xpress/
|
||||
|
||||
# DocProject is a documentation generator add-in
|
||||
DocProject/buildhelp/
|
||||
DocProject/Help/*.HxT
|
||||
DocProject/Help/*.HxC
|
||||
DocProject/Help/*.hhc
|
||||
DocProject/Help/*.hhk
|
||||
DocProject/Help/*.hhp
|
||||
DocProject/Help/Html2
|
||||
DocProject/Help/html
|
||||
|
||||
# Click-Once directory
|
||||
publish/
|
||||
|
||||
# Publish Web Output
|
||||
*.[Pp]ublish.xml
|
||||
*.azurePubxml
|
||||
# Note: Comment the next line if you want to checkin your web deploy settings,
|
||||
# but database connection strings (with potential passwords) will be unencrypted
|
||||
*.pubxml
|
||||
*.publishproj
|
||||
|
||||
# Microsoft Azure Web App publish settings. Comment the next line if you want to
|
||||
# checkin your Azure Web App publish settings, but sensitive information contained
|
||||
# in these scripts will be unencrypted
|
||||
PublishScripts/
|
||||
|
||||
# NuGet Packages
|
||||
*.nupkg
|
||||
# NuGet Symbol Packages
|
||||
*.snupkg
|
||||
# The packages folder can be ignored because of Package Restore
|
||||
**/[Pp]ackages/*
|
||||
# except build/, which is used as an MSBuild target.
|
||||
!**/[Pp]ackages/build/
|
||||
# Uncomment if necessary however generally it will be regenerated when needed
|
||||
#!**/[Pp]ackages/repositories.config
|
||||
# NuGet v3's project.json files produces more ignorable files
|
||||
*.nuget.props
|
||||
*.nuget.targets
|
||||
|
||||
# Microsoft Azure Build Output
|
||||
csx/
|
||||
*.build.csdef
|
||||
|
||||
# Microsoft Azure Emulator
|
||||
ecf/
|
||||
rcf/
|
||||
|
||||
# Windows Store app package directories and files
|
||||
AppPackages/
|
||||
BundleArtifacts/
|
||||
Package.StoreAssociation.xml
|
||||
_pkginfo.txt
|
||||
*.appx
|
||||
*.appxbundle
|
||||
*.appxupload
|
||||
|
||||
# Visual Studio cache files
|
||||
# files ending in .cache can be ignored
|
||||
*.[Cc]ache
|
||||
# but keep track of directories ending in .cache
|
||||
!?*.[Cc]ache/
|
||||
|
||||
# Others
|
||||
ClientBin/
|
||||
~$*
|
||||
*~
|
||||
*.dbmdl
|
||||
*.dbproj.schemaview
|
||||
*.jfm
|
||||
*.pfx
|
||||
*.publishsettings
|
||||
orleans.codegen.cs
|
||||
|
||||
# Including strong name files can present a security risk
|
||||
# (https://github.com/github/gitignore/pull/2483#issue-259490424)
|
||||
#*.snk
|
||||
|
||||
# Since there are multiple workflows, uncomment next line to ignore bower_components
|
||||
# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)
|
||||
#bower_components/
|
||||
|
||||
# RIA/Silverlight projects
|
||||
Generated_Code/
|
||||
|
||||
# Backup & report files from converting an old project file
|
||||
# to a newer Visual Studio version. Backup files are not needed,
|
||||
# because we have git ;-)
|
||||
_UpgradeReport_Files/
|
||||
Backup*/
|
||||
UpgradeLog*.XML
|
||||
UpgradeLog*.htm
|
||||
ServiceFabricBackup/
|
||||
*.rptproj.bak
|
||||
|
||||
# SQL Server files
|
||||
*.mdf
|
||||
*.ldf
|
||||
*.ndf
|
||||
|
||||
# Business Intelligence projects
|
||||
*.rdl.data
|
||||
*.bim.layout
|
||||
*.bim_*.settings
|
||||
*.rptproj.rsuser
|
||||
*- [Bb]ackup.rdl
|
||||
*- [Bb]ackup ([0-9]).rdl
|
||||
*- [Bb]ackup ([0-9][0-9]).rdl
|
||||
|
||||
# Microsoft Fakes
|
||||
FakesAssemblies/
|
||||
|
||||
# GhostDoc plugin setting file
|
||||
*.GhostDoc.xml
|
||||
|
||||
# Node.js Tools for Visual Studio
|
||||
.ntvs_analysis.dat
|
||||
node_modules/
|
||||
|
||||
# Visual Studio 6 build log
|
||||
*.plg
|
||||
|
||||
# Visual Studio 6 workspace options file
|
||||
*.opt
|
||||
|
||||
# Visual Studio 6 auto-generated workspace file (contains which files were open etc.)
|
||||
*.vbw
|
||||
|
||||
# Visual Studio LightSwitch build output
|
||||
**/*.HTMLClient/GeneratedArtifacts
|
||||
**/*.DesktopClient/GeneratedArtifacts
|
||||
**/*.DesktopClient/ModelManifest.xml
|
||||
**/*.Server/GeneratedArtifacts
|
||||
**/*.Server/ModelManifest.xml
|
||||
_Pvt_Extensions
|
||||
|
||||
# Paket dependency manager
|
||||
.paket/paket.exe
|
||||
paket-files/
|
||||
|
||||
# FAKE - F# Make
|
||||
.fake/
|
||||
|
||||
# CodeRush personal settings
|
||||
.cr/personal
|
||||
|
||||
# Python Tools for Visual Studio (PTVS)
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
# Cake - Uncomment if you are using it
|
||||
# tools/**
|
||||
# !tools/packages.config
|
||||
|
||||
# Tabs Studio
|
||||
*.tss
|
||||
|
||||
# Telerik's JustMock configuration file
|
||||
*.jmconfig
|
||||
|
||||
# BizTalk build output
|
||||
*.btp.cs
|
||||
*.btm.cs
|
||||
*.odx.cs
|
||||
*.xsd.cs
|
||||
|
||||
# OpenCover UI analysis results
|
||||
OpenCover/
|
||||
|
||||
# Azure Stream Analytics local run output
|
||||
ASALocalRun/
|
||||
|
||||
# MSBuild Binary and Structured Log
|
||||
*.binlog
|
||||
|
||||
# NVidia Nsight GPU debugger configuration file
|
||||
*.nvuser
|
||||
|
||||
# MFractors (Xamarin productivity tool) working folder
|
||||
.mfractor/
|
||||
|
||||
# Local History for Visual Studio
|
||||
.localhistory/
|
||||
|
||||
# BeatPulse healthcheck temp database
|
||||
healthchecksdb
|
||||
|
||||
# Backup folder for Package Reference Convert tool in Visual Studio 2017
|
||||
MigrationBackup/
|
||||
|
||||
# Ionide (cross platform F# VS Code tools) working folder
|
||||
.ionide/
|
||||
|
||||
# Fody - auto-generated XML schema
|
||||
FodyWeavers.xsd
|
||||
|
||||
*.patch
|
||||
*.onnx
|
||||
11
ABStemPlayer.slnx
Normal file
11
ABStemPlayer.slnx
Normal file
@ -0,0 +1,11 @@
|
||||
<Solution>
|
||||
<Folder Name="/Solution Items/">
|
||||
<File Path=".copilot/copilot-instructions.md" />
|
||||
<File Path="Directory.Build.props" />
|
||||
<File Path="Directory.Packages.props" />
|
||||
<File Path="LICENSE.txt" />
|
||||
</Folder>
|
||||
<Project Path="ABStemPlayer/ABStemPlayer.csproj" Id="74fea350-33d3-4b4d-8f07-5c5be997046c" />
|
||||
<Project Path="AudioCore/AudioCore.csproj" />
|
||||
<Project Path="AudioCore_Tests/AudioCore_Tests.csproj" Id="aa371ed7-89c5-4742-8280-748e2f68926f" />
|
||||
</Solution>
|
||||
31
ABStemPlayer/ABStemPlayer.csproj
Normal file
31
ABStemPlayer/ABStemPlayer.csproj
Normal file
@ -0,0 +1,31 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<OutputType>WinExe</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ApplicationManifest>app.manifest</ApplicationManifest>
|
||||
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Folder Include="Models\" />
|
||||
<AvaloniaResource Include="Assets\**" />
|
||||
<Folder Include="Services\" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Avalonia" />
|
||||
<PackageReference Include="Avalonia.Desktop" />
|
||||
<PackageReference Include="Avalonia.Themes.Fluent" />
|
||||
<PackageReference Include="Avalonia.Fonts.Inter" />
|
||||
<PackageReference Include="AvaloniaUI.DiagnosticsSupport">
|
||||
<IncludeAssets Condition="'$(Configuration)' != 'Debug'">None</IncludeAssets>
|
||||
<PrivateAssets Condition="'$(Configuration)' != 'Debug'">All</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="CommunityToolkit.Mvvm" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\AudioCore\AudioCore.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
10
ABStemPlayer/App.axaml
Normal file
10
ABStemPlayer/App.axaml
Normal file
@ -0,0 +1,10 @@
|
||||
<Application xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
x:Class="ABStemPlayer.App"
|
||||
RequestedThemeVariant="Default">
|
||||
<!-- "Default" ThemeVariant follows system theme variant. "Dark" or "Light" are other available options. -->
|
||||
|
||||
<Application.Styles>
|
||||
<FluentTheme />
|
||||
</Application.Styles>
|
||||
</Application>
|
||||
38
ABStemPlayer/App.axaml.cs
Normal file
38
ABStemPlayer/App.axaml.cs
Normal file
@ -0,0 +1,38 @@
|
||||
using ABStemPlayer.Views;
|
||||
using Avalonia.Controls.ApplicationLifetimes;
|
||||
using Avalonia.Markup.Xaml;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace ABStemPlayer;
|
||||
|
||||
public partial class App : Application
|
||||
{
|
||||
private readonly ServiceProvider _provider = null!;
|
||||
|
||||
public App() { }
|
||||
|
||||
public App(ServiceProvider provider)
|
||||
{
|
||||
_provider = provider;
|
||||
}
|
||||
|
||||
public override void Initialize()
|
||||
{
|
||||
AvaloniaXamlLoader.Load(this);
|
||||
}
|
||||
|
||||
public override void OnFrameworkInitializationCompleted()
|
||||
{
|
||||
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
|
||||
{
|
||||
var vm = _provider.GetRequiredService<MainWindowViewModel>();
|
||||
|
||||
desktop.MainWindow = new MainWindow
|
||||
{
|
||||
DataContext = vm
|
||||
};
|
||||
}
|
||||
|
||||
base.OnFrameworkInitializationCompleted();
|
||||
}
|
||||
}
|
||||
BIN
ABStemPlayer/Assets/avalonia-logo.ico
Normal file
BIN
ABStemPlayer/Assets/avalonia-logo.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 172 KiB |
15
ABStemPlayer/Converters/BoolInvertConverter.cs
Normal file
15
ABStemPlayer/Converters/BoolInvertConverter.cs
Normal file
@ -0,0 +1,15 @@
|
||||
using System.Globalization;
|
||||
using Avalonia.Data.Converters;
|
||||
|
||||
namespace ABStemPlayer.Converters;
|
||||
|
||||
public sealed class BoolInvertConverter : IValueConverter
|
||||
{
|
||||
public static BoolInvertConverter Instance { get; } = new();
|
||||
|
||||
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||
=> value is bool b ? !b : value;
|
||||
|
||||
public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||
=> value is bool b ? !b : value;
|
||||
}
|
||||
30
ABStemPlayer/Converters/StemTypeToNameConverter.cs
Normal file
30
ABStemPlayer/Converters/StemTypeToNameConverter.cs
Normal file
@ -0,0 +1,30 @@
|
||||
using Avalonia.Data.Converters;
|
||||
using System.Globalization;
|
||||
|
||||
namespace ABStemPlayer.Converters
|
||||
{
|
||||
public sealed class StemTypeToNameConverter : IValueConverter
|
||||
{
|
||||
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||
{
|
||||
if (value is StemType type)
|
||||
{
|
||||
return type switch
|
||||
{
|
||||
StemType.Drums => "Drums",
|
||||
StemType.Bass => "Bass",
|
||||
StemType.Vocals => "Vocals",
|
||||
StemType.Guitar => "Guitar",
|
||||
StemType.Piano => "Piano",
|
||||
StemType.Other => "Other",
|
||||
_ => type.ToString()
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||
=> throw new NotSupportedException();
|
||||
}
|
||||
}
|
||||
21
ABStemPlayer/Converters/TimeSpanToStringConverter.cs
Normal file
21
ABStemPlayer/Converters/TimeSpanToStringConverter.cs
Normal file
@ -0,0 +1,21 @@
|
||||
using Avalonia.Data.Converters;
|
||||
using System.Globalization;
|
||||
|
||||
namespace ABStemPlayer.Converters
|
||||
{
|
||||
public sealed class TimeSpanToStringConverter : IValueConverter
|
||||
{
|
||||
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||
{
|
||||
if (value is TimeSpan time)
|
||||
{
|
||||
return time.ToString("mm\\:ss");
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||
=> throw new NotSupportedException();
|
||||
}
|
||||
}
|
||||
8
ABStemPlayer/GlobalUsings.cs
Normal file
8
ABStemPlayer/GlobalUsings.cs
Normal file
@ -0,0 +1,8 @@
|
||||
global using AudioCore;
|
||||
global using AudioCore.Interfaces;
|
||||
global using AudioCore.Models;
|
||||
global using ABStemPlayer.ViewModels;
|
||||
global using Avalonia;
|
||||
global using CommunityToolkit.Mvvm.ComponentModel;
|
||||
|
||||
|
||||
43
ABStemPlayer/Program.cs
Normal file
43
ABStemPlayer/Program.cs
Normal file
@ -0,0 +1,43 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace ABStemPlayer;
|
||||
|
||||
internal sealed class Program
|
||||
{
|
||||
// Initialization code. Don't use any Avalonia, third-party APIs or any
|
||||
// SynchronizationContext-reliant code before AppMain is called: things aren't initialized
|
||||
// yet and stuff might break.
|
||||
[STAThread]
|
||||
public static void Main(string[] args) => BuildAvaloniaApp()
|
||||
.StartWithClassicDesktopLifetime(args);
|
||||
|
||||
// Avalonia configuration, don't remove; also used by visual designer.
|
||||
public static AppBuilder BuildAvaloniaApp()
|
||||
{
|
||||
var services = ConfigureServices();
|
||||
var provider = services.BuildServiceProvider();
|
||||
|
||||
return AppBuilder.Configure<App>(() => new App(provider))
|
||||
.UsePlatformDetect()
|
||||
#if DEBUG
|
||||
.WithDeveloperTools()
|
||||
#endif
|
||||
.WithInterFont()
|
||||
.LogToTrace();
|
||||
}
|
||||
|
||||
private static ServiceCollection ConfigureServices()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
|
||||
services.AddAudioCore();
|
||||
|
||||
// View models
|
||||
services.AddSingleton<PlaybackViewModel>();
|
||||
|
||||
services.AddSingleton<MixerViewModel>();
|
||||
|
||||
services.AddSingleton<MainWindowViewModel>();
|
||||
return services;
|
||||
}
|
||||
}
|
||||
19
ABStemPlayer/ViewModels/MainWindowViewModel.cs
Normal file
19
ABStemPlayer/ViewModels/MainWindowViewModel.cs
Normal file
@ -0,0 +1,19 @@
|
||||
using System.Collections.ObjectModel;
|
||||
|
||||
namespace ABStemPlayer.ViewModels;
|
||||
|
||||
public sealed class MainWindowViewModel
|
||||
{
|
||||
public PlaybackViewModel Playback { get; }
|
||||
public MixerViewModel Mixer { get; }
|
||||
public ObservableCollection<WaveformBandViewModel> Bands => Playback.Bands;
|
||||
|
||||
public MainWindowViewModel(
|
||||
PlaybackViewModel playback,
|
||||
MixerViewModel mixer)
|
||||
{
|
||||
Playback = playback;
|
||||
Mixer = mixer;
|
||||
Playback.Mixer = mixer;
|
||||
}
|
||||
}
|
||||
17
ABStemPlayer/ViewModels/MixerViewModel.cs
Normal file
17
ABStemPlayer/ViewModels/MixerViewModel.cs
Normal file
@ -0,0 +1,17 @@
|
||||
using System.Collections.ObjectModel;
|
||||
|
||||
namespace ABStemPlayer.ViewModels;
|
||||
|
||||
public sealed class MixerViewModel
|
||||
{
|
||||
public ObservableCollection<StemChannelViewModel> Stems { get; } = new();
|
||||
|
||||
public MixerViewModel()
|
||||
{
|
||||
foreach (var type in Enum.GetValues<StemType>())
|
||||
{
|
||||
Stems.Add(new StemChannelViewModel(type));
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
409
ABStemPlayer/ViewModels/PlaybackViewModel.cs
Normal file
409
ABStemPlayer/ViewModels/PlaybackViewModel.cs
Normal file
@ -0,0 +1,409 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Windows.Input;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Controls.ApplicationLifetimes;
|
||||
using Avalonia.Platform.Storage;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
|
||||
namespace ABStemPlayer.ViewModels;
|
||||
|
||||
public sealed partial class PlaybackViewModel : ObservableObject
|
||||
{
|
||||
private readonly IStemPlaybackEngine _engine;
|
||||
private readonly IStemSeparator _separator;
|
||||
private readonly IStemDecoderFactory _decoderFactory;
|
||||
private readonly IStemWaveformService _waveformService;
|
||||
|
||||
// -----------------------------
|
||||
// Conversion mode properties
|
||||
// -----------------------------
|
||||
|
||||
[ObservableProperty] private bool _isConverting;
|
||||
[ObservableProperty] private double _progress;
|
||||
|
||||
private CancellationTokenSource? _conversionCts;
|
||||
|
||||
public ICommand CancelConversionCommand { get; }
|
||||
|
||||
// -----------------------------
|
||||
// Existing commands
|
||||
// -----------------------------
|
||||
|
||||
public ICommand OpenFileCommand { get; }
|
||||
public ICommand PlayCommand { get; }
|
||||
public ICommand PauseCommand { get; }
|
||||
public ICommand StopCommand { get; }
|
||||
public ICommand RewindCommand { get; }
|
||||
public ICommand FastForwardCommand { get; }
|
||||
|
||||
public ICommand SetPointACommand { get; }
|
||||
public ICommand SetPointBCommand { get; }
|
||||
|
||||
// -----------------------------
|
||||
// Playback properties
|
||||
// -----------------------------
|
||||
|
||||
[ObservableProperty] private bool _loopEnabled;
|
||||
[ObservableProperty] private float _playbackSpeed = 1f;
|
||||
[ObservableProperty] private TimeSpan _currentTime;
|
||||
[ObservableProperty] private TimeSpan _totalTime;
|
||||
[ObservableProperty] private MixerViewModel _mixer = null!;
|
||||
|
||||
public ObservableCollection<SegmentViewModel> Segments { get; } = new();
|
||||
public ObservableCollection<WaveformBandViewModel> Bands { get; } = new();
|
||||
|
||||
|
||||
private TimeSpan? _loopA;
|
||||
private TimeSpan? _loopB;
|
||||
|
||||
// -----------------------------
|
||||
// Constructor
|
||||
// -----------------------------
|
||||
|
||||
public PlaybackViewModel(
|
||||
IStemPlaybackEngine engine,
|
||||
IStemSeparator separator,
|
||||
IStemDecoderFactory decoderFactory,
|
||||
IStemWaveformService waveformService
|
||||
)
|
||||
{
|
||||
_engine = engine;
|
||||
_separator = separator;
|
||||
_decoderFactory = decoderFactory;
|
||||
_waveformService = waveformService;
|
||||
|
||||
CancelConversionCommand = new RelayCommand(_ => CancelConversion());
|
||||
|
||||
OpenFileCommand = new AsyncRelayCommand(OpenFileAsync);
|
||||
PlayCommand = new AsyncRelayCommand(() => _engine.PlayAsync());
|
||||
PauseCommand = new AsyncRelayCommand(() => _engine.PauseAsync());
|
||||
StopCommand = new AsyncRelayCommand(async () => { await _engine.StopAsync(); await _engine.SeekAsync(TimeSpan.Zero); });
|
||||
|
||||
RewindCommand = new AsyncRelayCommand(() => _engine.SeekAsync(CurrentTime - TimeSpan.FromSeconds(5)));
|
||||
FastForwardCommand = new AsyncRelayCommand(() => _engine.SeekAsync(CurrentTime + TimeSpan.FromSeconds(5)));
|
||||
|
||||
SetPointACommand = new RelayCommand(_ =>
|
||||
{
|
||||
_loopA = CurrentTime;
|
||||
UpdateLoop();
|
||||
});
|
||||
|
||||
SetPointBCommand = new RelayCommand(_ =>
|
||||
{
|
||||
_loopB = CurrentTime;
|
||||
UpdateLoop();
|
||||
});
|
||||
|
||||
|
||||
}
|
||||
|
||||
partial void OnCurrentTimeChanged(TimeSpan value)
|
||||
{
|
||||
foreach (var band in Bands)
|
||||
{
|
||||
band.UpdatePlaybackPosition(CurrentTime, TotalTime);
|
||||
}
|
||||
}
|
||||
|
||||
partial void OnTotalTimeChanged(TimeSpan value)
|
||||
{
|
||||
foreach (var band in Bands)
|
||||
{
|
||||
band.UpdatePlaybackPosition(CurrentTime, TotalTime);
|
||||
}
|
||||
}
|
||||
// -----------------------------
|
||||
// File open + stem conversion
|
||||
//------------------------------
|
||||
|
||||
private async Task OpenFileAsync()
|
||||
{
|
||||
var topLevel = TopLevel.GetTopLevel(
|
||||
(App.Current?.ApplicationLifetime as IClassicDesktopStyleApplicationLifetime)?.MainWindow
|
||||
);
|
||||
|
||||
if (topLevel is null)
|
||||
return;
|
||||
|
||||
var files = await topLevel.StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions
|
||||
{
|
||||
AllowMultiple = false,
|
||||
FileTypeFilter =
|
||||
[
|
||||
new FilePickerFileType("Audio")
|
||||
{
|
||||
Patterns = new[] { "*.mp3", "*.flac", "*.wav" }
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
if (files.Count == 0)
|
||||
return;
|
||||
|
||||
var file = files[0];
|
||||
|
||||
await _engine.StopAsync();
|
||||
|
||||
var session = await SplitStems(file);
|
||||
if (session == null)
|
||||
return;
|
||||
|
||||
TotalTime = session.StemSet.Stems.FirstOrDefault()?.Duration ?? TimeSpan.Zero;
|
||||
CurrentTime = TimeSpan.Zero;
|
||||
|
||||
await UpdateWaveForms(session);
|
||||
|
||||
Bands.Clear();
|
||||
foreach ( var item in session.StemSet.Stems)
|
||||
{
|
||||
Bands.Add(new WaveformBandViewModel(item));
|
||||
}
|
||||
|
||||
await _engine.LoadSessionAsync(session, new PlaybackProgressReporter(this));
|
||||
}
|
||||
|
||||
private class PlaybackProgressReporter : IProgressReporter<TimeSpan>
|
||||
{
|
||||
private readonly PlaybackViewModel _vm;
|
||||
public PlaybackProgressReporter(PlaybackViewModel vm)
|
||||
{
|
||||
_vm = vm;
|
||||
}
|
||||
public Task ReportProgress(TimeSpan progress, CancellationToken ct)
|
||||
{
|
||||
Avalonia.Threading.Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
_vm.CurrentTime = progress;
|
||||
});
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task UpdateWaveForms(PlaybackSession session)
|
||||
{
|
||||
IsConverting = true;
|
||||
Progress = 0;
|
||||
|
||||
_conversionCts = new CancellationTokenSource();
|
||||
var ct = _conversionCts.Token;
|
||||
|
||||
var stems = session.StemSet.Stems;
|
||||
var total = stems.Count;
|
||||
|
||||
var reporter = new VmProgressReporter(this);
|
||||
|
||||
// Thread‑safe counter
|
||||
var completed = 0;
|
||||
|
||||
try
|
||||
{
|
||||
await Task.Run(async () =>
|
||||
{
|
||||
// Create parallel tasks
|
||||
var tasks = stems.Select(async stem =>
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
var decoder = _decoderFactory.Create(stem);
|
||||
|
||||
var waveform = await _waveformService.ComputeWaveformAsync(stem, decoder, 200);
|
||||
|
||||
stem.Waveform = waveform;
|
||||
|
||||
// Update progress safely
|
||||
var done = Interlocked.Increment(ref completed);
|
||||
var p = (double)done / total;
|
||||
|
||||
await reporter.ReportProgress(p, ct);
|
||||
}).ToList();
|
||||
|
||||
// Run all tasks in parallel
|
||||
await Task.WhenAll(tasks);
|
||||
}, ct);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
Avalonia.Threading.Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
Progress = 0;
|
||||
IsConverting = false;
|
||||
});
|
||||
return;
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
Avalonia.Threading.Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
Progress = 0;
|
||||
IsConverting = false;
|
||||
});
|
||||
throw;
|
||||
}
|
||||
|
||||
IsConverting = false;
|
||||
}
|
||||
|
||||
|
||||
|
||||
// -----------------------------
|
||||
// Progress reporter
|
||||
// -----------------------------
|
||||
|
||||
private sealed class VmProgressReporter : IProgressReporter<double>
|
||||
{
|
||||
private readonly PlaybackViewModel _vm;
|
||||
|
||||
public VmProgressReporter(PlaybackViewModel vm)
|
||||
{
|
||||
_vm = vm;
|
||||
}
|
||||
|
||||
public Task ReportProgress(double progress, CancellationToken ct)
|
||||
{
|
||||
Avalonia.Threading.Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
_vm.Progress = progress * 100.0;
|
||||
});
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// -----------------------------
|
||||
// Stem splitting
|
||||
// -----------------------------
|
||||
|
||||
private async Task<PlaybackSession?> SplitStems(IStorageFile file)
|
||||
{
|
||||
// Enter conversion mode
|
||||
IsConverting = true;
|
||||
Progress = 0;
|
||||
|
||||
_conversionCts = new CancellationTokenSource();
|
||||
var ct = _conversionCts.Token;
|
||||
|
||||
var outDir = Path.Combine(Path.GetDirectoryName(file.Path.LocalPath)!, "ABStemPlayer");
|
||||
|
||||
StemSet? stemSet = null;
|
||||
|
||||
try
|
||||
{
|
||||
// Run separation on background thread
|
||||
stemSet = await Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
return await _separator.SeparateAsync(
|
||||
new StemSeparationRequest
|
||||
{
|
||||
SourceFilePath = file.Path.LocalPath,
|
||||
OutputDirectory = outDir
|
||||
},
|
||||
new VmProgressReporter(this),
|
||||
ct
|
||||
);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
Avalonia.Threading.Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
Progress = 0;
|
||||
IsConverting = false;
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}, ct);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Cancelled by user
|
||||
Avalonia.Threading.Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
Progress = 0;
|
||||
IsConverting = false;
|
||||
});
|
||||
}
|
||||
|
||||
// Exit conversion mode
|
||||
IsConverting = false;
|
||||
Progress = 0;
|
||||
|
||||
if ( stemSet == null)
|
||||
return null;
|
||||
|
||||
// Build session
|
||||
return new PlaybackSession
|
||||
{
|
||||
StemSet = stemSet,
|
||||
Mixer = new MixerSettings
|
||||
{
|
||||
Stems = stemSet.Stems.Select(GetMixerSettings).ToList()
|
||||
},
|
||||
Loop = new LoopRegion
|
||||
{
|
||||
Start = TimeSpan.Zero,
|
||||
End = TimeSpan.Zero
|
||||
},
|
||||
Speed = new PlaybackSpeedSettings
|
||||
{
|
||||
Speed = PlaybackSpeed
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private StemMixSettings GetMixerSettings(StemTrack stem)
|
||||
{
|
||||
var found = Mixer.Stems.FirstOrDefault(s => s.Type == stem.Type);
|
||||
if ( found != null )
|
||||
return new StemMixSettings
|
||||
{
|
||||
GainDb = found.GainDb,
|
||||
Enabled = found.Enabled,
|
||||
Pan = found.Pan
|
||||
};
|
||||
|
||||
found = new StemChannelViewModel(stem.Type);
|
||||
Mixer.Stems.Add(found);
|
||||
return new StemMixSettings
|
||||
{
|
||||
GainDb = found.GainDb,
|
||||
Enabled = found.Enabled,
|
||||
Pan = found.Pan
|
||||
};
|
||||
}
|
||||
|
||||
// -----------------------------
|
||||
// Cancel conversion
|
||||
// -----------------------------
|
||||
|
||||
private void CancelConversion()
|
||||
{
|
||||
_conversionCts?.Cancel();
|
||||
}
|
||||
|
||||
// -----------------------------
|
||||
// Loop logic
|
||||
// -----------------------------
|
||||
|
||||
private void UpdateLoop()
|
||||
{
|
||||
if (_loopA.HasValue && _loopB.HasValue && LoopEnabled)
|
||||
{
|
||||
var a = _loopA.Value;
|
||||
var b = _loopB.Value;
|
||||
|
||||
if (b > a)
|
||||
_engine.SetLoop(a, b);
|
||||
}
|
||||
else
|
||||
{
|
||||
_engine.ClearLoop();
|
||||
}
|
||||
}
|
||||
|
||||
public void UpdateSpeed()
|
||||
{
|
||||
_engine.SetSpeed(PlaybackSpeed);
|
||||
}
|
||||
}
|
||||
19
ABStemPlayer/ViewModels/RelayCommand.cs
Normal file
19
ABStemPlayer/ViewModels/RelayCommand.cs
Normal file
@ -0,0 +1,19 @@
|
||||
using System.Windows.Input;
|
||||
|
||||
namespace ABStemPlayer.ViewModels;
|
||||
|
||||
public sealed class RelayCommand : ICommand
|
||||
{
|
||||
private readonly Action<object?> _execute;
|
||||
private readonly Func<object?, bool>? _canExecute;
|
||||
|
||||
public RelayCommand(Action<object?> execute, Func<object?, bool>? canExecute = null)
|
||||
{
|
||||
_execute = execute;
|
||||
_canExecute = canExecute;
|
||||
}
|
||||
|
||||
public bool CanExecute(object? parameter) => _canExecute?.Invoke(parameter) ?? true;
|
||||
public void Execute(object? parameter) => _execute(parameter);
|
||||
public event EventHandler? CanExecuteChanged;
|
||||
}
|
||||
19
ABStemPlayer/ViewModels/SegmentViewModel.cs
Normal file
19
ABStemPlayer/ViewModels/SegmentViewModel.cs
Normal file
@ -0,0 +1,19 @@
|
||||
namespace ABStemPlayer.ViewModels;
|
||||
|
||||
public sealed class SegmentViewModel
|
||||
{
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
public TimeSpan Start { get; set; }
|
||||
public TimeSpan End { get; set; }
|
||||
|
||||
public bool Active { get; set; }
|
||||
|
||||
public string StartFormatted => Start.ToString("mm\\:ss");
|
||||
public string EndFormatted => End.ToString("mm\\:ss");
|
||||
|
||||
// waveform rendering helpers
|
||||
public double StartX { get; set; }
|
||||
public double Width { get; set; }
|
||||
public double SegmentHeight { get; set; } = 80;
|
||||
}
|
||||
15
ABStemPlayer/ViewModels/StemChannelViewModel.cs
Normal file
15
ABStemPlayer/ViewModels/StemChannelViewModel.cs
Normal file
@ -0,0 +1,15 @@
|
||||
namespace ABStemPlayer.ViewModels;
|
||||
|
||||
public partial class StemChannelViewModel : ObservableObject
|
||||
{
|
||||
public StemType Type { get; }
|
||||
|
||||
[ObservableProperty] private bool _enabled = true;
|
||||
[ObservableProperty] private float _gainDb = 0f;
|
||||
[ObservableProperty] private float _pan = 0f;
|
||||
|
||||
public StemChannelViewModel(StemType type)
|
||||
{
|
||||
Type = type;
|
||||
}
|
||||
}
|
||||
5
ABStemPlayer/ViewModels/ViewModelBase.cs
Normal file
5
ABStemPlayer/ViewModels/ViewModelBase.cs
Normal file
@ -0,0 +1,5 @@
|
||||
namespace ABStemPlayer.ViewModels;
|
||||
|
||||
public abstract class ViewModelBase : ObservableObject
|
||||
{
|
||||
}
|
||||
91
ABStemPlayer/ViewModels/WaveformBandViewModel.cs
Normal file
91
ABStemPlayer/ViewModels/WaveformBandViewModel.cs
Normal file
@ -0,0 +1,91 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using Avalonia.Media;
|
||||
|
||||
namespace ABStemPlayer.ViewModels;
|
||||
|
||||
public partial class WaveformBandViewModel : ObservableObject
|
||||
{
|
||||
public sealed class WaveformBar
|
||||
{
|
||||
public double X { get; set; } // Canvas.Left
|
||||
public double Y { get; set; } // Canvas.Top
|
||||
public double Width { get; set; } // bar width
|
||||
public double Height { get; set; } // bar height
|
||||
|
||||
public override string ToString() => Height.ToString();
|
||||
}
|
||||
|
||||
|
||||
private readonly StemTrack _stem;
|
||||
public string BandName => _stem.Name;
|
||||
|
||||
[ObservableProperty] private double _canvasWidth;
|
||||
[ObservableProperty] private double _canvasHeight;
|
||||
[ObservableProperty] private Geometry? _waveformGeometry;
|
||||
|
||||
public ObservableCollection<WaveformBar> WaveformBars { get; } = new();
|
||||
|
||||
[ObservableProperty] private double _playbackX;
|
||||
|
||||
public ObservableCollection<SegmentViewModel> Segments { get; } = new();
|
||||
|
||||
public TimeSpan Duration { get; set; }
|
||||
public string DurationFormatted => Duration.ToString("mm\\:ss");
|
||||
|
||||
public WaveformBandViewModel(StemTrack stem)
|
||||
{
|
||||
_stem = stem;
|
||||
}
|
||||
|
||||
public void UpdatePlaybackPosition(TimeSpan current, TimeSpan total)
|
||||
{
|
||||
if (total <= TimeSpan.Zero || CanvasWidth <= 0)
|
||||
{
|
||||
PlaybackX = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
double ratio = current.TotalSeconds / total.TotalSeconds;
|
||||
PlaybackX = ratio * CanvasWidth;
|
||||
}
|
||||
|
||||
public void UpdateBarsForCanvasSize(double canvasWidth, double canvasHeight)
|
||||
{
|
||||
CanvasWidth = canvasWidth;
|
||||
CanvasHeight = canvasHeight;
|
||||
var waveform = _stem.Waveform;
|
||||
|
||||
if (waveform == null || waveform.Length == 0)
|
||||
{
|
||||
WaveformGeometry = null;
|
||||
return;
|
||||
}
|
||||
|
||||
var geo = new StreamGeometry();
|
||||
|
||||
using (var ctx = geo.Open())
|
||||
{
|
||||
double half = canvasHeight / 2.0;
|
||||
double spacing = 1.0;
|
||||
double barWidth = (canvasWidth - (waveform.Length - 1) * spacing) / waveform.Length;
|
||||
|
||||
double x = 0;
|
||||
|
||||
for (int i = 0; i < waveform.Length; i++)
|
||||
{
|
||||
double amp = Math.Abs(waveform[i]);
|
||||
double h = amp * half;
|
||||
|
||||
// draw vertical bar
|
||||
ctx.BeginFigure(new Point(x, half - h), false);
|
||||
ctx.LineTo(new Point(x, half + h));
|
||||
|
||||
x += barWidth + spacing;
|
||||
}
|
||||
}
|
||||
|
||||
WaveformGeometry = geo;
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
87
ABStemPlayer/Views/MainWindow.axaml
Normal file
87
ABStemPlayer/Views/MainWindow.axaml
Normal file
@ -0,0 +1,87 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Window xmlns="https://github.com/avaloniaui"
|
||||
xmlns:vm="using:ABStemPlayer.ViewModels"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
xmlns:local="clr-namespace:ABStemPlayer.Views"
|
||||
xmlns:conv="using:ABStemPlayer.Converters"
|
||||
mc:Ignorable="d"
|
||||
x:Class="ABStemPlayer.Views.MainWindow"
|
||||
x:DataType="vm:MainWindowViewModel"
|
||||
Width="1280"
|
||||
Height="720"
|
||||
Icon="/Assets/avalonia-logo.ico"
|
||||
Title="AB Stem Player">
|
||||
|
||||
<Window.Resources>
|
||||
<conv:TimeSpanToStringConverter x:Key="TimeSpanToStringConverter" />
|
||||
</Window.Resources>
|
||||
|
||||
<Grid RowDefinitions="Auto,*,Auto"
|
||||
ColumnDefinitions="*,Auto">
|
||||
|
||||
<!-- Playback + transport + speed + AB loop -->
|
||||
<local:PlaybackControls Grid.Row="2"
|
||||
Grid.Column="0"
|
||||
Grid.ColumnSpan="2"
|
||||
Margin="8"
|
||||
DataContext="{Binding Playback}" />
|
||||
|
||||
<!-- Mixer console -->
|
||||
<local:MixerControl Grid.Row="1"
|
||||
Grid.Column="1"
|
||||
Margin="8"
|
||||
DataContext="{Binding Mixer}" />
|
||||
|
||||
<!-- Waveform area (per band) -->
|
||||
<Grid Grid.Row="1"
|
||||
Grid.Column="0"
|
||||
Margin="8">
|
||||
|
||||
<ScrollViewer Grid.Row="1"
|
||||
Grid.Column="0"
|
||||
Margin="8"
|
||||
VerticalScrollBarVisibility="Auto">
|
||||
|
||||
<ItemsControl ItemsSource="{Binding Bands}"
|
||||
Margin="0,2">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
<StackPanel Orientation="Vertical"/>
|
||||
</ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<local:WaveformBandView Margin="0,2"/>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
|
||||
</ScrollViewer>
|
||||
</Grid>
|
||||
|
||||
<!-- Status / transport time etc -->
|
||||
<DockPanel Grid.Row="0"
|
||||
Grid.ColumnSpan="2"
|
||||
Background="#202020"
|
||||
>
|
||||
<TextBlock Text="{Binding Playback.CurrentTime, Converter={StaticResource TimeSpanToStringConverter}}"
|
||||
Foreground="#e0e0e0"
|
||||
DockPanel.Dock="Left"
|
||||
Margin="8,0" />
|
||||
|
||||
<TextBlock Text="{Binding Playback.TotalTime, Converter={StaticResource TimeSpanToStringConverter}}"
|
||||
Foreground="#c0c0c0"
|
||||
DockPanel.Dock="Left"
|
||||
Margin="8,0" />
|
||||
|
||||
<!--TextBlock Text="{Binding Playback.CurrentSegmentDescription}"
|
||||
Foreground="#c0c0c0"
|
||||
DockPanel.Dock="Right"
|
||||
Margin="8,0" /-->
|
||||
</DockPanel>
|
||||
|
||||
</Grid>
|
||||
</Window>
|
||||
11
ABStemPlayer/Views/MainWindow.axaml.cs
Normal file
11
ABStemPlayer/Views/MainWindow.axaml.cs
Normal file
@ -0,0 +1,11 @@
|
||||
using Avalonia.Controls;
|
||||
|
||||
namespace ABStemPlayer.Views;
|
||||
|
||||
public partial class MainWindow : Window
|
||||
{
|
||||
public MainWindow()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
}
|
||||
83
ABStemPlayer/Views/MixerControl.axaml
Normal file
83
ABStemPlayer/Views/MixerControl.axaml
Normal file
@ -0,0 +1,83 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:ABStemPlayer.ViewModels"
|
||||
x:DataType="vm:MixerViewModel"
|
||||
xmlns:conv="using:ABStemPlayer.Converters"
|
||||
x:Class="ABStemPlayer.Views.MixerControl">
|
||||
|
||||
<UserControl.Resources>
|
||||
<conv:StemTypeToNameConverter x:Key="StemTypeToNameConverter"/>
|
||||
</UserControl.Resources>
|
||||
|
||||
<Border Padding="8"
|
||||
Background="#202020"
|
||||
CornerRadius="4">
|
||||
|
||||
<!-- Dynamic stems -->
|
||||
<ItemsControl ItemsSource="{Binding Stems}">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
<StackPanel Orientation="Horizontal"
|
||||
Spacing="16"/>
|
||||
</ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:StemChannelViewModel">
|
||||
|
||||
<!-- One mixer column -->
|
||||
<Grid Width="80"
|
||||
RowDefinitions="Auto,*,Auto,Auto,Auto">
|
||||
|
||||
<!-- Stem name -->
|
||||
<TextBlock Grid.Row="0"
|
||||
Text="{Binding Type, Converter={StaticResource StemTypeToNameConverter}}"
|
||||
HorizontalAlignment="Center"
|
||||
Margin="0,0,0,4"
|
||||
Foreground="#f0f0f0"/>
|
||||
|
||||
<!-- Gain slider -->
|
||||
<Slider Grid.Row="1"
|
||||
Orientation="Vertical"
|
||||
Minimum="-24"
|
||||
Maximum="12"
|
||||
Value="{Binding GainDb}"
|
||||
VerticalAlignment="Stretch"
|
||||
MinHeight="100"/>
|
||||
|
||||
<!-- Gain readout -->
|
||||
<TextBlock Grid.Row="2"
|
||||
Text="{Binding GainDb, StringFormat='{}{0:F1} dB'}"
|
||||
HorizontalAlignment="Center"
|
||||
Margin="0,4,0,4"
|
||||
Foreground="#c0c0c0"/>
|
||||
|
||||
<!-- Pan slider -->
|
||||
<Slider Grid.Row="3"
|
||||
Orientation="Horizontal"
|
||||
Minimum="-1"
|
||||
Maximum="1"
|
||||
Value="{Binding Pan}"
|
||||
Margin="0,4,0,0"/>
|
||||
|
||||
<!-- Pan + enabled -->
|
||||
<StackPanel Grid.Row="4"
|
||||
Orientation="Vertical"
|
||||
HorizontalAlignment="Center">
|
||||
<TextBlock Text="Pan"
|
||||
HorizontalAlignment="Center"
|
||||
Foreground="#c0c0c0"
|
||||
Margin="0,2,0,2"/>
|
||||
<ToggleSwitch IsChecked="{Binding Enabled}"
|
||||
Content="On"
|
||||
HorizontalAlignment="Center"
|
||||
Margin="0,4,0,0"/>
|
||||
</StackPanel>
|
||||
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
|
||||
</Border>
|
||||
</UserControl>
|
||||
11
ABStemPlayer/Views/MixerControl.axaml.cs
Normal file
11
ABStemPlayer/Views/MixerControl.axaml.cs
Normal file
@ -0,0 +1,11 @@
|
||||
using Avalonia.Controls;
|
||||
|
||||
namespace ABStemPlayer.Views;
|
||||
|
||||
public partial class MixerControl : UserControl
|
||||
{
|
||||
public MixerControl()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
}
|
||||
256
ABStemPlayer/Views/PlaybackControls.axaml
Normal file
256
ABStemPlayer/Views/PlaybackControls.axaml
Normal file
@ -0,0 +1,256 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:ABStemPlayer.ViewModels"
|
||||
xmlns:conv="using:ABStemPlayer.Converters"
|
||||
x:DataType="vm:PlaybackViewModel"
|
||||
x:Class="ABStemPlayer.Views.PlaybackControls">
|
||||
<UserControl.Resources>
|
||||
<conv:BoolInvertConverter x:Key="BoolInvert" />
|
||||
</UserControl.Resources>
|
||||
|
||||
<Border Padding="8"
|
||||
Background="#101010"
|
||||
CornerRadius="4">
|
||||
|
||||
<Grid>
|
||||
|
||||
<!-- NORMAL UI -->
|
||||
<Grid x:Name="NormalUI"
|
||||
ColumnDefinitions="Auto,*,Auto"
|
||||
RowDefinitions="Auto,Auto"
|
||||
IsVisible="{Binding IsConverting, Converter={StaticResource BoolInvert}}" >
|
||||
|
||||
<!-- Transport buttons -->
|
||||
<Grid Grid.Row="0"
|
||||
Grid.Column="0">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<!-- File Open -->
|
||||
<Button Command="{Binding OpenFileCommand}"
|
||||
Width="40"
|
||||
Height="40"
|
||||
Grid.Column="0"
|
||||
Margin="0,0,6,0">
|
||||
<Path Fill="#e0e0e0"
|
||||
Data="M 6 10 H 18 L 22 14 H 34 V 30 H 6 Z"/>
|
||||
</Button>
|
||||
|
||||
<!-- Rewind -->
|
||||
<Button Command="{Binding RewindCommand}"
|
||||
Width="40"
|
||||
Height="40"
|
||||
Grid.Column="1"
|
||||
Margin="0,0,6,0">
|
||||
<Path Fill="#e0e0e0"
|
||||
Data="M 30 8 L 10 20 L 30 32 Z M 18 8 L -2 20 L 18 32 Z"/>
|
||||
</Button>
|
||||
|
||||
<!-- Play -->
|
||||
<Button Command="{Binding PlayCommand}"
|
||||
Width="40"
|
||||
Height="40"
|
||||
Grid.Column="2"
|
||||
Margin="0,0,6,0">
|
||||
<Path Fill="#00ff00"
|
||||
Data="M 10 8 L 32 20 L 10 32 Z"/>
|
||||
</Button>
|
||||
|
||||
<!-- Pause -->
|
||||
<Button Command="{Binding PauseCommand}"
|
||||
Width="40"
|
||||
Height="40"
|
||||
Grid.Column="3"
|
||||
Margin="0,0,6,0">
|
||||
<Path Fill="#e0e0e0"
|
||||
Data="M 8 8 H 16 V 32 H 8 Z
|
||||
M 24 8 H 32 V 32 H 24 Z"/>
|
||||
</Button>
|
||||
|
||||
<!-- Stop -->
|
||||
<Button Command="{Binding StopCommand}"
|
||||
Width="40"
|
||||
Height="40"
|
||||
Grid.Column="4"
|
||||
Margin="0,0,6,0">
|
||||
<Path Fill="#e0e0e0"
|
||||
Data="M 8 8 H 32 V 32 H 8 Z"/>
|
||||
</Button>
|
||||
|
||||
<!-- Fast Forward -->
|
||||
<Button Command="{Binding FastForwardCommand}"
|
||||
Width="40"
|
||||
Height="40"
|
||||
Grid.Column="5">
|
||||
<Path Fill="#e0e0e0"
|
||||
Data="M 10 8 L 30 20 L 10 32 Z M 22 8 L 42 20 L 22 32 Z"/>
|
||||
</Button>
|
||||
</Grid>
|
||||
|
||||
<!-- Speed slider -->
|
||||
<Grid Grid.Row="0"
|
||||
Grid.Column="1"
|
||||
HorizontalAlignment="Center">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<TextBlock Text="Speed"
|
||||
Foreground="#f0f0f0"
|
||||
VerticalAlignment="Center"
|
||||
Grid.Column="0"
|
||||
Margin="20,10,8,0"/>
|
||||
|
||||
<Slider Minimum="0.25"
|
||||
Maximum="2.0"
|
||||
Value="{Binding PlaybackSpeed}"
|
||||
Width="260"
|
||||
Grid.Column="1"
|
||||
Margin="0,10,8,0"/>
|
||||
|
||||
<TextBlock Text="{Binding PlaybackSpeed, StringFormat='x{0:F2}'}"
|
||||
Foreground="#c0c0c0"
|
||||
VerticalAlignment="Center"
|
||||
Grid.Column="2"/>
|
||||
</Grid>
|
||||
|
||||
<!-- AB loop controls -->
|
||||
<Grid Grid.Row="0"
|
||||
Grid.Column="2"
|
||||
HorizontalAlignment="Right">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<ToggleSwitch IsChecked="{Binding LoopEnabled}"
|
||||
Content="Loop"
|
||||
VerticalAlignment="Center"
|
||||
Grid.Column="0"
|
||||
Margin="0,0,6,0"/>
|
||||
|
||||
<Button Command="{Binding SetPointACommand}"
|
||||
Width="60"
|
||||
Height="32"
|
||||
Grid.Column="1"
|
||||
Margin="0,0,6,0">
|
||||
<Grid>
|
||||
<Path Fill="#0080ff"
|
||||
Data="M 30 6 L 46 18 L 38 18 L 38 26 L 22 26 L 22 18 L 14 18 Z"/>
|
||||
<Path Stroke="#e0e0e0"
|
||||
StrokeThickness="2"
|
||||
Fill="Transparent"
|
||||
Data="M 20 5 L 34 35 H 28 L 25 25 H 15 L 12 35 H 6 Z M 20 13 L 17 21 H 23 Z"/>
|
||||
</Grid>
|
||||
</Button>
|
||||
|
||||
<Button Command="{Binding SetPointBCommand}"
|
||||
Width="60"
|
||||
Height="32"
|
||||
Grid.Column="2">
|
||||
<Grid>
|
||||
<Path Fill="#0080ff"
|
||||
Data="M 30 26 L 46 14 L 38 14 L 38 6 L 22 6 L 22 14 L 14 14 Z"/>
|
||||
<Path Stroke="#e0e0e0"
|
||||
StrokeThickness="2"
|
||||
Fill="Transparent"
|
||||
Data="
|
||||
M 10 2.44 L 18.4 2.44
|
||||
A 6 6 0 0 1 24.4 8.44
|
||||
A 6 6 0 0 1 18.4 14.44
|
||||
L 22 14.44
|
||||
A 5.4 5.4 0 0 1 27.4 19.84
|
||||
A 5.4 5.4 0 0 1 22 25.24
|
||||
L 10 25.24 Z
|
||||
|
||||
M 19 7.24 L 19 10.44
|
||||
L 25 10.44
|
||||
A 1.2 1.2 0 0 0 26.2 9.24
|
||||
A 1.2 1.2 0 0 0 25 7.24 Z
|
||||
|
||||
M 19 14.44 L 19 19.04
|
||||
L 25 19.04
|
||||
A 1.8 1.8 0 0 0 26.8 17.24
|
||||
A 1.8 1.8 0 0 0 25 14.44 Z
|
||||
"/>
|
||||
</Grid>
|
||||
</Button>
|
||||
</Grid>
|
||||
|
||||
<!-- Segment list -->
|
||||
<ItemsControl Grid.Row="1"
|
||||
Grid.ColumnSpan="3"
|
||||
Margin="0,8,0,0"
|
||||
ItemsSource="{Binding Segments}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<Border Margin="2"
|
||||
Padding="4"
|
||||
Background="#202020"
|
||||
CornerRadius="2">
|
||||
<Grid ColumnDefinitions="Auto,Auto,Auto,Auto,Auto">
|
||||
|
||||
<TextBlock Text="{Binding Name}"
|
||||
Foreground="#f0f0f0"
|
||||
Grid.Column="0"
|
||||
Margin="0,0,8,0"/>
|
||||
|
||||
<TextBlock Text="{Binding StartFormatted}"
|
||||
Foreground="#c0c0c0"
|
||||
Grid.Column="1"
|
||||
Margin="0,0,8,0"/>
|
||||
|
||||
<TextBlock Text="-"
|
||||
Foreground="#c0c0c0"
|
||||
Grid.Column="2"
|
||||
Margin="0,0,8,0"/>
|
||||
|
||||
<TextBlock Text="{Binding EndFormatted}"
|
||||
Foreground="#c0c0c0"
|
||||
Grid.Column="3"
|
||||
Margin="0,0,8,0"/>
|
||||
|
||||
<ToggleSwitch IsChecked="{Binding Active}"
|
||||
Content="Active"
|
||||
Grid.Column="4"
|
||||
Margin="8,0,0,0"/>
|
||||
</Grid>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
|
||||
</Grid>
|
||||
|
||||
<!-- CONVERSION MODE -->
|
||||
<StackPanel x:Name="ConversionUI"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
Spacing="12"
|
||||
IsVisible="{Binding IsConverting}">
|
||||
|
||||
<ProgressBar Minimum="0"
|
||||
Maximum="100"
|
||||
Value="{Binding Progress}"
|
||||
Width="300"
|
||||
Height="24"/>
|
||||
|
||||
<Button Content="Cancel"
|
||||
Command="{Binding CancelConversionCommand}"
|
||||
Width="120"
|
||||
Height="36"/>
|
||||
</StackPanel>
|
||||
|
||||
</Grid>
|
||||
</Border>
|
||||
</UserControl>
|
||||
11
ABStemPlayer/Views/PlaybackControls.axaml.cs
Normal file
11
ABStemPlayer/Views/PlaybackControls.axaml.cs
Normal file
@ -0,0 +1,11 @@
|
||||
using Avalonia.Controls;
|
||||
|
||||
namespace ABStemPlayer.Views;
|
||||
|
||||
public partial class PlaybackControls : UserControl
|
||||
{
|
||||
public PlaybackControls()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
}
|
||||
45
ABStemPlayer/Views/WaveformBandView.axaml
Normal file
45
ABStemPlayer/Views/WaveformBandView.axaml
Normal file
@ -0,0 +1,45 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:ABStemPlayer.ViewModels"
|
||||
x:DataType="vm:WaveformBandViewModel"
|
||||
x:Class="ABStemPlayer.Views.WaveformBandView">
|
||||
|
||||
<Border Background="#181818"
|
||||
CornerRadius="2"
|
||||
Padding="4">
|
||||
|
||||
<Grid RowDefinitions="Auto,*">
|
||||
|
||||
<!-- Header -->
|
||||
<StackPanel Orientation="Horizontal"
|
||||
Margin="0,0,0,4"
|
||||
Spacing="8">
|
||||
<TextBlock Text="{Binding BandName}"
|
||||
Foreground="#f0f0f0" />
|
||||
<TextBlock Text="{Binding DurationFormatted}"
|
||||
Foreground="#c0c0c0" />
|
||||
</StackPanel>
|
||||
|
||||
<!-- Waveform -->
|
||||
<Canvas x:Name="WaveformCanvas"
|
||||
Grid.Row="1"
|
||||
Background="#101010"
|
||||
Height="80"
|
||||
SizeChanged="WaveformCanvas_OnSizeChanged">
|
||||
|
||||
<!-- Waveform geometry -->
|
||||
<Path Stroke="#40a0ff"
|
||||
StrokeThickness="1"
|
||||
Data="{Binding WaveformGeometry}" />
|
||||
|
||||
<!-- Playback cursor -->
|
||||
<Rectangle Fill="#ff0000"
|
||||
Width="2"
|
||||
Height="{Binding CanvasHeight}"
|
||||
Canvas.Left="{Binding PlaybackX}" />
|
||||
|
||||
</Canvas>
|
||||
</Grid>
|
||||
</Border>
|
||||
</UserControl>
|
||||
20
ABStemPlayer/Views/WaveformBandView.axaml.cs
Normal file
20
ABStemPlayer/Views/WaveformBandView.axaml.cs
Normal file
@ -0,0 +1,20 @@
|
||||
using Avalonia.Controls;
|
||||
|
||||
namespace ABStemPlayer.Views;
|
||||
|
||||
public partial class WaveformBandView : UserControl
|
||||
{
|
||||
public WaveformBandView()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
|
||||
private void WaveformCanvas_OnSizeChanged(object? sender, SizeChangedEventArgs e)
|
||||
{
|
||||
if (DataContext is WaveformBandViewModel vm)
|
||||
{
|
||||
vm.UpdateBarsForCanvasSize(e.NewSize.Width, e.NewSize.Height);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
18
ABStemPlayer/app.manifest
Normal file
18
ABStemPlayer/app.manifest
Normal file
@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
|
||||
<!-- This manifest is used on Windows only.
|
||||
Don't remove it as it might cause problems with window transparency and embedded controls.
|
||||
For more details visit https://learn.microsoft.com/en-us/windows/win32/sbscs/application-manifests -->
|
||||
<assemblyIdentity version="1.0.0.0" name="ABStemPlayer.Desktop"/>
|
||||
|
||||
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
|
||||
<application>
|
||||
<!-- A list of the Windows versions that this application has been tested on
|
||||
and is designed to work with. Uncomment the appropriate elements
|
||||
and Windows will automatically select the most compatible environment. -->
|
||||
|
||||
<!-- Windows 10 -->
|
||||
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
|
||||
</application>
|
||||
</compatibility>
|
||||
</assembly>
|
||||
62
AudioCore/AudioCore.csproj
Normal file
62
AudioCore/AudioCore.csproj
Normal file
@ -0,0 +1,62 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<IsPackable>true</IsPackable>
|
||||
<Authors>Alexander Shabarshov</Authors>
|
||||
<Description>
|
||||
Audio engine core for ABStemPlayer. Audio pipeline.
|
||||
</Description>
|
||||
<PackageProjectUrl>https://github.com/unclshura/ABStemPlayer</PackageProjectUrl>
|
||||
<PackageTags>audio, player, ab, guitar, drums, piano, stem, mixer, c#</PackageTags>
|
||||
<PackageReadmeFile>README.md</PackageReadmeFile>
|
||||
</PropertyGroup>
|
||||
|
||||
<!-- DEBUG CONFIGURATION -->
|
||||
<PropertyGroup Condition="'$(Configuration)' == 'Debug'">
|
||||
<Optimize>false</Optimize>
|
||||
<SelfContained>false</SelfContained>
|
||||
<PublishSingleFile>false</PublishSingleFile>
|
||||
<PublishTrimmed>false</PublishTrimmed>
|
||||
<IncludeNativeLibrariesForSelfExtract>false</IncludeNativeLibrariesForSelfExtract>
|
||||
<PublishReadyToRun>false</PublishReadyToRun>
|
||||
</PropertyGroup>
|
||||
|
||||
<!-- RELEASE CONFIGURATION -->
|
||||
<PropertyGroup Condition="'$(Configuration)' == 'Release'">
|
||||
<Optimize>true</Optimize>
|
||||
<EnableAVX2>true</EnableAVX2>
|
||||
<DebugType>none</DebugType>
|
||||
<DebugSymbols>false</DebugSymbols>
|
||||
<IncludeNativeLibrariesForSelfExtract>true</IncludeNativeLibrariesForSelfExtract>
|
||||
<PublishReadyToRun>true</PublishReadyToRun>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<Version>1.0.0</Version>
|
||||
<InformationalVersion>$(Version).$(BuildNumber)+$(SourceRevisionId)</InformationalVersion>
|
||||
<AssemblyVersion>$(Version)</AssemblyVersion>
|
||||
<FileVersion>$(Version).$(BuildNumber)</FileVersion>
|
||||
</PropertyGroup>
|
||||
|
||||
<Target Name="RemoveUnwantedFiles" AfterTargets="Publish" Condition="'$(Configuration)' == 'Release'">
|
||||
<ItemGroup>
|
||||
<FilesToDelete Include="$(PublishDir)**\*.pdb" />
|
||||
<FilesToDelete Include="$(PublishDir)**\*.lib" />
|
||||
</ItemGroup>
|
||||
|
||||
<Delete Files="@(FilesToDelete)" />
|
||||
</Target>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
|
||||
<PackageReference Include="Microsoft.ML.OnnxRuntime.DirectML" />
|
||||
<PackageReference Include="NAudio" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Content Include="Data/*.*">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</Content>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
30
AudioCore/AudioCoreExtensions.cs
Normal file
30
AudioCore/AudioCoreExtensions.cs
Normal file
@ -0,0 +1,30 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using AudioCore.Impl;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace AudioCore;
|
||||
|
||||
public static class AudioCoreExtensions
|
||||
{
|
||||
[ExcludeFromCodeCoverage]
|
||||
public static ServiceCollection AddAudioCore(this ServiceCollection services)
|
||||
{
|
||||
services.AddSingleton<AudioBufferPool>();
|
||||
services.AddSingleton<ByteBufferPool>();
|
||||
services.AddSingleton<IStemDecoderFactory, StemDecoderFactory>();
|
||||
services.AddSingleton<IAudioReaderFactory, FfmpegAudioReaderFactory>();
|
||||
services.AddSingleton<IStemWaveformService, StemWaveformService>();
|
||||
|
||||
services.AddTransient<IStemDecoder, StemDecoder>();
|
||||
services.AddTransient<IAudioMixer, AudioMixer>();
|
||||
services.AddTransient<ITimeStretchEngine, RubberBandTimeStretchEngine>();
|
||||
services.AddTransient<IStemSeparator, Htdemucs6sSeparator>();
|
||||
|
||||
services.AddSingleton<IAudioOutputDevice>(sp =>
|
||||
new WasapiOutputDevice(sp.GetRequiredService<ByteBufferPool>(), channels: 2));
|
||||
|
||||
services.AddTransient<IStemPlaybackEngine, StemPlaybackEngine>();
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
4
AudioCore/GlobalUsings.cs
Normal file
4
AudioCore/GlobalUsings.cs
Normal file
@ -0,0 +1,4 @@
|
||||
global using AudioCore.Interfaces;
|
||||
global using AudioCore.Models;
|
||||
|
||||
|
||||
27
AudioCore/Impl/AudioBuffer.cs
Normal file
27
AudioCore/Impl/AudioBuffer.cs
Normal file
@ -0,0 +1,27 @@
|
||||
namespace AudioCore.Impl;
|
||||
|
||||
public class AudioBuffer<T> : IDisposable
|
||||
{
|
||||
private readonly GenericBufferPool<T> _owner;
|
||||
private bool _disposed;
|
||||
|
||||
public T[] Samples { get; }
|
||||
public int Length { get; set; } // number of valid samples
|
||||
|
||||
internal AudioBuffer(T[] samples, int capacity, GenericBufferPool<T> owner)
|
||||
{
|
||||
Samples = samples;
|
||||
Length = capacity;
|
||||
_owner = owner;
|
||||
}
|
||||
|
||||
public Span<T> Span => _disposed ? default : Samples.AsSpan(0, Length);
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed)
|
||||
return;
|
||||
_owner.Return(Samples);
|
||||
_disposed = true;
|
||||
}
|
||||
}
|
||||
67
AudioCore/Impl/AudioMixer.cs
Normal file
67
AudioCore/Impl/AudioMixer.cs
Normal file
@ -0,0 +1,67 @@
|
||||
namespace AudioCore.Impl;
|
||||
|
||||
public sealed class AudioMixer : IAudioMixer
|
||||
{
|
||||
private readonly AudioBufferPool _pool;
|
||||
|
||||
public AudioMixer(AudioBufferPool pool)
|
||||
{
|
||||
_pool = pool;
|
||||
}
|
||||
|
||||
public MixedAudioBlock Mix(
|
||||
IReadOnlyList<AudioBlock> stemBlocks,
|
||||
MixerSettings settings)
|
||||
{
|
||||
if (stemBlocks.Count == 0)
|
||||
throw new ArgumentException("No stems provided");
|
||||
|
||||
// All blocks must have same sample rate and frame count
|
||||
var first = stemBlocks[0];
|
||||
var frames = first.Buffer.Length / first.Channels;
|
||||
var sampleRate = first.SampleRate;
|
||||
var position = first.Position;
|
||||
|
||||
const int outputChannels = 2;
|
||||
|
||||
// Rent output buffer
|
||||
var outBuf = _pool.Rent(frames * outputChannels);
|
||||
outBuf.Length = frames * outputChannels;
|
||||
|
||||
Span<float> outSpan = outBuf.Span;
|
||||
outSpan.Clear();
|
||||
|
||||
for (var stem = 0; stem < stemBlocks.Count; stem++)
|
||||
{
|
||||
var s = settings.Stems[stem];
|
||||
if (!s.Enabled)
|
||||
continue;
|
||||
|
||||
var block = stemBlocks[stem];
|
||||
Span<float> inSpan = block.Buffer.Span;
|
||||
|
||||
var inChannels = block.Channels;
|
||||
|
||||
var gain = DbToLinear(s.GainDb);
|
||||
var pan = s.Pan;
|
||||
|
||||
var leftGain = gain * (pan <= 0 ? 1f : 1f - pan);
|
||||
var rightGain = gain * (pan >= 0 ? 1f : 1f + pan);
|
||||
|
||||
for (var i = 0; i < frames; i++)
|
||||
{
|
||||
var l = inChannels > 1 ? inSpan[i * inChannels + 0] : inSpan[i];
|
||||
var r = inChannels > 1 ? inSpan[i * inChannels + 1] : inSpan[i];
|
||||
|
||||
outSpan[i * 2 + 0] += l * leftGain;
|
||||
outSpan[i * 2 + 1] += r * rightGain;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return new MixedAudioBlock(outBuf, frames, outputChannels, sampleRate, position);
|
||||
}
|
||||
|
||||
private static float DbToLinear(float db)
|
||||
=> db <= -80f ? 0f : MathF.Pow(10f, db / 20f);
|
||||
}
|
||||
27
AudioCore/Impl/FfmpegAudioReader.cs
Normal file
27
AudioCore/Impl/FfmpegAudioReader.cs
Normal file
@ -0,0 +1,27 @@
|
||||
namespace AudioCore.Impl;
|
||||
|
||||
public sealed class FfmpegAudioReader : IAudioReader
|
||||
{
|
||||
private readonly FfmpegPipe _pipe;
|
||||
|
||||
public int SampleRate => _pipe.SampleRate;
|
||||
public int Channels => _pipe.Channels;
|
||||
public long TotalSamples => _pipe.TotalSamples;
|
||||
|
||||
public FfmpegAudioReader(string path)
|
||||
{
|
||||
_pipe = new FfmpegPipe(path);
|
||||
}
|
||||
|
||||
public int Read(float[] buffer, int offset, int count)
|
||||
=> _pipe.Read(buffer, offset, count);
|
||||
|
||||
public void Seek(long sampleIndex)
|
||||
=> _pipe.Seek(sampleIndex);
|
||||
|
||||
public void Reset()
|
||||
=> Seek(0);
|
||||
|
||||
public void Dispose()
|
||||
=> _pipe.Dispose();
|
||||
}
|
||||
9
AudioCore/Impl/FfmpegAudioReaderFactory.cs
Normal file
9
AudioCore/Impl/FfmpegAudioReaderFactory.cs
Normal file
@ -0,0 +1,9 @@
|
||||
namespace AudioCore.Impl;
|
||||
|
||||
public sealed class FfmpegAudioReaderFactory : IAudioReaderFactory
|
||||
{
|
||||
public IAudioReader Create(string filePath)
|
||||
{
|
||||
return new FfmpegAudioReader(filePath);
|
||||
}
|
||||
}
|
||||
112
AudioCore/Impl/FfmpegPipe.cs
Normal file
112
AudioCore/Impl/FfmpegPipe.cs
Normal file
@ -0,0 +1,112 @@
|
||||
using System.Diagnostics;
|
||||
|
||||
namespace AudioCore.Impl;
|
||||
|
||||
public sealed class FfmpegPipe : IDisposable
|
||||
{
|
||||
private readonly string _path;
|
||||
private Process? _proc;
|
||||
private Stream? _stdout;
|
||||
|
||||
public int SampleRate { get; }
|
||||
public int Channels { get; }
|
||||
public long TotalSamples { get; }
|
||||
|
||||
public FfmpegPipe(string path, int sampleRate = 44100, int channels = 2)
|
||||
{
|
||||
_path = path;
|
||||
SampleRate = sampleRate;
|
||||
Channels = channels;
|
||||
|
||||
// Optional: probe duration
|
||||
TotalSamples = ProbeTotalSamples(path, sampleRate);
|
||||
|
||||
StartProcess(0);
|
||||
}
|
||||
|
||||
private void StartProcess(long startSample)
|
||||
{
|
||||
var startSeconds = (double)startSample / SampleRate;
|
||||
|
||||
var psi = new ProcessStartInfo
|
||||
{
|
||||
FileName = "ffmpeg",
|
||||
Arguments =
|
||||
$"-hide_banner -loglevel error " +
|
||||
$"-nostdin " + // prevent console attach
|
||||
$"-ss {startSeconds.ToString(System.Globalization.CultureInfo.InvariantCulture)} " +
|
||||
$"-i \"{_path}\" " +
|
||||
$"-f f32le -ac {Channels} -ar {SampleRate} pipe:1",
|
||||
|
||||
UseShellExecute = false,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
RedirectStandardInput = true, // prevents console window
|
||||
CreateNoWindow = true,
|
||||
WindowStyle = ProcessWindowStyle.Hidden,
|
||||
ErrorDialog = false
|
||||
};
|
||||
|
||||
_proc = Process.Start(psi);
|
||||
_stdout = _proc!.StandardOutput.BaseStream;
|
||||
}
|
||||
|
||||
public int Read(float[] buffer, int offset, int count)
|
||||
{
|
||||
var bytesNeeded = count * sizeof(float);
|
||||
var tmp = new byte[bytesNeeded];
|
||||
|
||||
var readBytes = _stdout!.Read(tmp, 0, bytesNeeded);
|
||||
if (readBytes <= 0)
|
||||
return 0;
|
||||
|
||||
Buffer.BlockCopy(tmp, 0, buffer, offset * sizeof(float), readBytes);
|
||||
|
||||
return readBytes / sizeof(float);
|
||||
}
|
||||
|
||||
public void Seek(long sampleIndex)
|
||||
{
|
||||
DisposeProcessOnly();
|
||||
StartProcess(sampleIndex);
|
||||
}
|
||||
|
||||
public void Reset() => Seek(0);
|
||||
|
||||
private static long ProbeTotalSamples(string path, int sampleRate)
|
||||
{
|
||||
var psi = new ProcessStartInfo
|
||||
{
|
||||
FileName = "ffprobe",
|
||||
Arguments = $"-v error -show_entries format=duration -of csv=p=0 \"{path}\"",
|
||||
RedirectStandardOutput = true,
|
||||
CreateNoWindow = true,
|
||||
WindowStyle = ProcessWindowStyle.Hidden,
|
||||
UseShellExecute = false
|
||||
};
|
||||
|
||||
using var p = Process.Start(psi);
|
||||
var s = p!.StandardOutput.ReadToEnd();
|
||||
p.WaitForExit();
|
||||
|
||||
if (double.TryParse(s, System.Globalization.NumberStyles.Float,
|
||||
System.Globalization.CultureInfo.InvariantCulture, out var seconds))
|
||||
{
|
||||
return (long)(seconds * sampleRate);
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
private void DisposeProcessOnly()
|
||||
{
|
||||
try { _stdout?.Dispose(); } catch { }
|
||||
try { if (_proc != null && !_proc.HasExited) _proc.Kill(); } catch { }
|
||||
try { _proc?.Dispose(); } catch { }
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
DisposeProcessOnly();
|
||||
}
|
||||
}
|
||||
22
AudioCore/Impl/GenericBufferPool.cs
Normal file
22
AudioCore/Impl/GenericBufferPool.cs
Normal file
@ -0,0 +1,22 @@
|
||||
using System.Buffers;
|
||||
|
||||
namespace AudioCore.Impl;
|
||||
|
||||
public class GenericBufferPool<T>
|
||||
{
|
||||
private readonly ArrayPool<T> _pool = ArrayPool<T>.Shared;
|
||||
|
||||
public AudioBuffer<T> Rent(int sampleCount)
|
||||
{
|
||||
var array = _pool.Rent(sampleCount);
|
||||
return new AudioBuffer<T>(array, sampleCount, this);
|
||||
}
|
||||
|
||||
internal void Return(T[] array)
|
||||
{
|
||||
_pool.Return(array);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class AudioBufferPool : GenericBufferPool<float> { }
|
||||
public sealed class ByteBufferPool : GenericBufferPool<byte> { }
|
||||
303
AudioCore/Impl/Htdemucs6sSeparator.cs
Normal file
303
AudioCore/Impl/Htdemucs6sSeparator.cs
Normal file
@ -0,0 +1,303 @@
|
||||
using System.Diagnostics;
|
||||
using Microsoft.ML.OnnxRuntime;
|
||||
using NAudio.Wave;
|
||||
|
||||
namespace AudioCore.Impl;
|
||||
|
||||
public sealed class Htdemucs6sSeparator : IStemSeparator
|
||||
{
|
||||
private const int _sampleRate = 44100;
|
||||
private const int _channels = 2;
|
||||
private const double _segmentSeconds = 7.8;
|
||||
private const int _segmentSamples = (int)(_sampleRate * _segmentSeconds); // 343,980
|
||||
private const int _overlap = _segmentSamples / 4; // 85,995
|
||||
private const int _stride = _segmentSamples - _overlap; // 257,985
|
||||
|
||||
private static readonly string[] _stemNames = Enum.GetNames<StemType>();
|
||||
|
||||
public async Task<StemSet> SeparateAsync(
|
||||
StemSeparationRequest request,
|
||||
IProgressReporter<double> progress,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
Directory.CreateDirectory(request.OutputDirectory);
|
||||
|
||||
var existingStems = CheckExistingStems(request);
|
||||
if (existingStems != null)
|
||||
return existingStems;
|
||||
|
||||
// 1. Load audio
|
||||
var mix = LoadStereoFloatWave(request.SourceFilePath, out var sr);
|
||||
if (sr != _sampleRate)
|
||||
throw new InvalidOperationException($"Input must be {_sampleRate} Hz");
|
||||
|
||||
var totalSamples = mix.GetLength(1);
|
||||
|
||||
// 2. Prepare ONNX session
|
||||
var opts = new SessionOptions();
|
||||
opts.AppendExecutionProvider_CPU();
|
||||
opts.GraphOptimizationLevel = GraphOptimizationLevel.ORT_ENABLE_ALL;
|
||||
|
||||
var modelPath = Path.Combine(AppContext.BaseDirectory, "Data", "htdemucs_6s.onnx");
|
||||
using var session = new InferenceSession(modelPath, opts);
|
||||
|
||||
// 3. Prepare buffers
|
||||
var outStems = new float[_stemNames.Length, _channels, totalSamples];
|
||||
var weight = new float[totalSamples];
|
||||
var window = MakeWindow(_segmentSamples, _overlap);
|
||||
|
||||
var nChunks = Math.Max(1, (totalSamples + _stride - 1) / _stride);
|
||||
|
||||
// 4. Sliding window inference
|
||||
for (var i = 0; i < nChunks; i++)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
var start = i * _stride;
|
||||
var end = Math.Min(start + _segmentSamples, totalSamples);
|
||||
var clen = end - start;
|
||||
|
||||
// Extract chunk into [2, N]
|
||||
var chunk = new float[_channels, _segmentSamples];
|
||||
for (var ch = 0; ch < _channels; ch++)
|
||||
{
|
||||
Array.Copy(mix, ch * totalSamples + start, chunk, ch * _segmentSamples, clen);
|
||||
}
|
||||
|
||||
// Build flat input buffer (1,2,N)
|
||||
var inputData = new float[_channels * _segmentSamples];
|
||||
for (var ch = 0; ch < _channels; ch++)
|
||||
{
|
||||
var baseIndex = ch * _segmentSamples;
|
||||
for (var s = 0; s < _segmentSamples; s++)
|
||||
inputData[baseIndex + s] = chunk[ch, s];
|
||||
}
|
||||
|
||||
// Create OrtValue for input
|
||||
using var inputOrtValue = OrtValue.CreateTensorValueFromMemory(
|
||||
inputData,
|
||||
new long[] { 1, _channels, _segmentSamples }
|
||||
);
|
||||
|
||||
// Prepare output buffer (CPU)
|
||||
var outputData = new float[_stemNames.Length * _channels * _segmentSamples];
|
||||
|
||||
// Create OrtValue for output
|
||||
using var outputOrtValue = OrtValue.CreateTensorValueFromMemory(
|
||||
outputData,
|
||||
new long[] { 1, _stemNames.Length, _channels, _segmentSamples }
|
||||
);
|
||||
|
||||
// Bind using IOBinding
|
||||
using var io = session.CreateIoBinding();
|
||||
io.BindInput("mix", inputOrtValue);
|
||||
io.BindOutput("stems", outputOrtValue);
|
||||
|
||||
// Execute on GPU → output goes directly to CPU buffer
|
||||
session.RunWithBinding(new RunOptions(), io);
|
||||
|
||||
// Now outputData contains (1,6,2,N)
|
||||
var buf = outputData.AsSpan();
|
||||
|
||||
// Extract output tensor shape (1, 6, 2, N)
|
||||
var stemCnt = _stemNames.Length; // 6
|
||||
var chCnt = _channels; // 2
|
||||
var length = _segmentSamples; // 343980
|
||||
|
||||
// Compute strides for flattened buffer
|
||||
var stemStride = chCnt * length; // 2 * N
|
||||
var channelStride = length; // N
|
||||
|
||||
// Overlap-add
|
||||
for (var stem = 0; stem < stemCnt; stem++)
|
||||
{
|
||||
for (var ch = 0; ch < chCnt; ch++)
|
||||
{
|
||||
var baseIndex = stem * stemStride + ch * channelStride;
|
||||
|
||||
for (var s = 0; s < clen; s++)
|
||||
{
|
||||
var w = window[s];
|
||||
var v = buf[baseIndex + s];
|
||||
|
||||
outStems[stem, ch, start + s] += v * w;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (var s = 0; s < clen; s++)
|
||||
weight[start + s] += window[s];
|
||||
|
||||
await progress.ReportProgress((double)(i + 1) / nChunks, ct);
|
||||
}
|
||||
|
||||
// 5. Normalize by weight
|
||||
for (var stem = 0; stem < _stemNames.Length; stem++)
|
||||
{
|
||||
for (var ch = 0; ch < _channels; ch++)
|
||||
{
|
||||
for (var s = 0; s < totalSamples; s++)
|
||||
{
|
||||
var w = weight[s];
|
||||
if (w > 1e-8f)
|
||||
outStems[stem, ch, s] /= w;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Write stems
|
||||
var result = new List<StemTrack>();
|
||||
for (var i = 0; i < _stemNames.Length; i++)
|
||||
{
|
||||
var name = $"{Path.GetFileNameWithoutExtension(request.SourceFilePath)}_{_stemNames[i]}.flac";
|
||||
var path = Path.Combine(request.OutputDirectory, name);
|
||||
WriteFlac(path, outStems, i, totalSamples);
|
||||
|
||||
result.Add(new StemTrack
|
||||
{
|
||||
Type = Enum.Parse<StemType>(_stemNames[i]),
|
||||
Name = name,
|
||||
FilePath = path,
|
||||
SampleRate = _sampleRate,
|
||||
Channels = _channels,
|
||||
Duration = TimeSpan.FromSeconds((double)totalSamples / _sampleRate)
|
||||
});
|
||||
}
|
||||
|
||||
return new StemSet
|
||||
{
|
||||
OriginalFilePath = request.SourceFilePath,
|
||||
Stems = result
|
||||
};
|
||||
}
|
||||
|
||||
private StemSet? CheckExistingStems(StemSeparationRequest request)
|
||||
{
|
||||
var filesToCheck = Enum.GetNames(typeof(StemType))
|
||||
.Select(stemType => (stemType, Path.Combine(request.OutputDirectory, $"{Path.GetFileNameWithoutExtension(request.SourceFilePath)}_{stemType}.flac")))
|
||||
.ToList();
|
||||
|
||||
var stems = new List<StemTrack>();
|
||||
var set = new StemSet
|
||||
{
|
||||
OriginalFilePath = request.SourceFilePath,
|
||||
Stems = stems
|
||||
};
|
||||
|
||||
foreach (var f in filesToCheck.Where(f => File.Exists(f.Item2)))
|
||||
{
|
||||
using var reader = new AudioFileReader(f.Item2);
|
||||
|
||||
var stem =new StemTrack
|
||||
{
|
||||
Type = Enum.Parse<StemType>(f.Item1),
|
||||
Name = Path.GetFileName(f.Item2),
|
||||
FilePath = f.Item2,
|
||||
SampleRate = reader.WaveFormat.SampleRate,
|
||||
Channels = reader.WaveFormat.Channels,
|
||||
Duration = reader.TotalTime
|
||||
};
|
||||
|
||||
stems.Add(stem);
|
||||
}
|
||||
|
||||
return stems.Count > 0 ? set : null;
|
||||
}
|
||||
|
||||
// ------------------------------
|
||||
// Helpers
|
||||
// ------------------------------
|
||||
|
||||
private static float[] MakeWindow(int n, int overlap)
|
||||
{
|
||||
var w = new float[n];
|
||||
for (var i = 0; i < n; i++) w[i] = 1f;
|
||||
|
||||
for (var i = 0; i < overlap; i++)
|
||||
{
|
||||
var fade = (float)i / overlap;
|
||||
w[i] = fade;
|
||||
w[n - 1 - i] = fade;
|
||||
}
|
||||
return w;
|
||||
}
|
||||
|
||||
private static float[,] LoadStereoFloatWave(string path, out int sampleRate)
|
||||
{
|
||||
using var reader = new AudioFileReader(path);
|
||||
sampleRate = reader.WaveFormat.SampleRate;
|
||||
|
||||
var samples = new List<float>();
|
||||
var buffer = new float[reader.WaveFormat.SampleRate * 4];
|
||||
int read;
|
||||
while ((read = reader.Read(buffer, 0, buffer.Length)) > 0)
|
||||
samples.AddRange(buffer.AsSpan(0, read));
|
||||
|
||||
var total = samples.Count / 2;
|
||||
var result = new float[2, total];
|
||||
|
||||
for (var i = 0; i < total; i++)
|
||||
{
|
||||
result[0, i] = samples[2 * i];
|
||||
result[1, i] = samples[2 * i + 1];
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
//private static void WriteWave(string path, float[,,] stems, int stemIndex, int totalSamples)
|
||||
//{
|
||||
// var format = WaveFormat.CreateIeeeFloatWaveFormat(_sampleRate, _channels);
|
||||
|
||||
// using var writer = new WaveFileWriter(path, format);
|
||||
|
||||
// for (int i = 0; i < totalSamples; i++)
|
||||
// {
|
||||
// writer.WriteSample(stems[stemIndex, 0, i]);
|
||||
// writer.WriteSample(stems[stemIndex, 1, i]);
|
||||
// }
|
||||
//}
|
||||
|
||||
private static void WriteFlac(string path, float[,,] stems, int stemIndex, int totalSamples)
|
||||
{
|
||||
var psi = new ProcessStartInfo
|
||||
{
|
||||
FileName = "ffmpeg",
|
||||
Arguments =
|
||||
"-y " +
|
||||
"-f f32le " + // raw float32 little-endian
|
||||
"-ar 44100 " + // sample rate
|
||||
"-ac 2 " + // channels
|
||||
"-i pipe:0 " + // read from stdin
|
||||
"-compression_level 12 " + // max FLAC compression
|
||||
$"\"{path}\"",
|
||||
RedirectStandardInput = true,
|
||||
RedirectStandardError = true,
|
||||
RedirectStandardOutput = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
WindowStyle = ProcessWindowStyle.Hidden,
|
||||
};
|
||||
|
||||
using var ff = Process.Start(psi) ?? throw new InvalidOperationException("Failed to start FFmpeg process");
|
||||
using var stdin = ff.StandardInput.BaseStream;
|
||||
|
||||
// Write raw float32 PCM directly to FFmpeg
|
||||
var buffer = new byte[sizeof(float) * 2]; // stereo frame
|
||||
|
||||
for (var i = 0; i < totalSamples; i++)
|
||||
{
|
||||
BitConverter.TryWriteBytes(buffer.AsSpan(0, 4), stems[stemIndex, 0, i]);
|
||||
BitConverter.TryWriteBytes(buffer.AsSpan(4, 4), stems[stemIndex, 1, i]);
|
||||
stdin.Write(buffer, 0, buffer.Length);
|
||||
}
|
||||
|
||||
stdin.Flush();
|
||||
stdin.Close();
|
||||
|
||||
ff.WaitForExit();
|
||||
}
|
||||
|
||||
|
||||
|
||||
}
|
||||
232
AudioCore/Impl/RubberBandTimeStretchEngine.cs
Normal file
232
AudioCore/Impl/RubberBandTimeStretchEngine.cs
Normal file
@ -0,0 +1,232 @@
|
||||
using System.Diagnostics;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace AudioCore.Impl;
|
||||
|
||||
public sealed class RubberBandTimeStretchEngine : ITimeStretchEngine, IDisposable
|
||||
{
|
||||
private readonly AudioBufferPool _pool;
|
||||
private readonly int _sampleRate;
|
||||
private readonly int _channels;
|
||||
|
||||
private Process? _ff;
|
||||
private Stream? _stdin;
|
||||
private Stream? _stdout;
|
||||
|
||||
private float _speed = 1.0f;
|
||||
|
||||
private readonly byte[] _ring;
|
||||
private int _ringWrite;
|
||||
private int _ringRead;
|
||||
private readonly object _ringLock = new();
|
||||
|
||||
private Thread? _readerThread;
|
||||
private bool _readerRunning;
|
||||
|
||||
public RubberBandTimeStretchEngine(AudioBufferPool pool, int sampleRate = 44100, int channels = 2)
|
||||
{
|
||||
_pool = pool;
|
||||
_sampleRate = sampleRate;
|
||||
_channels = channels;
|
||||
|
||||
// Ring buffer: e.g. 1 second of audio
|
||||
var bytesPerSecond = sampleRate * channels * sizeof(float);
|
||||
_ring = new byte[bytesPerSecond];
|
||||
|
||||
StartProcess();
|
||||
}
|
||||
|
||||
public void Configure(PlaybackSpeedSettings settings)
|
||||
{
|
||||
if (Math.Abs(settings.Speed - _speed) < 0.0001f)
|
||||
return;
|
||||
|
||||
_speed = settings.Speed;
|
||||
RestartProcess();
|
||||
}
|
||||
|
||||
public TimeStretchedAudioBlock Process(MixedAudioBlock input)
|
||||
{
|
||||
var expectedFloats = input.Frames * _channels;
|
||||
var expectedBytes = expectedFloats * sizeof(float);
|
||||
|
||||
if (Math.Abs(_speed - 1.0f) < 0.01f)
|
||||
{
|
||||
var buf = _pool.Rent(expectedFloats);
|
||||
Array.Copy(input.Buffer.Samples, buf.Samples, input.Buffer.Length);
|
||||
|
||||
return new TimeStretchedAudioBlock(buf, input.Frames, _channels, _sampleRate);
|
||||
}
|
||||
|
||||
var span = input.Buffer.Span;
|
||||
var bytes = MemoryMarshal.AsBytes(span);
|
||||
|
||||
_stdin!.Write(bytes);
|
||||
_stdin.Flush();
|
||||
|
||||
var available = WaitForOutput();
|
||||
if (available <= 0)
|
||||
return default;
|
||||
|
||||
var outBuf = _pool.Rent(expectedFloats);
|
||||
var outBytes = MemoryMarshal.AsBytes(outBuf.Span);
|
||||
|
||||
var readBytes = DrainRing(outBytes, expectedBytes);
|
||||
|
||||
if (readBytes <= 0)
|
||||
{
|
||||
outBuf.Dispose();
|
||||
return default;
|
||||
}
|
||||
|
||||
var frames = readBytes / (_channels * sizeof(float));
|
||||
outBuf.Length = frames * _channels;
|
||||
|
||||
return new TimeStretchedAudioBlock(outBuf, frames, _channels, _sampleRate);
|
||||
}
|
||||
|
||||
private int WaitForOutput(int timeoutMs = 5000)
|
||||
{
|
||||
var sw = Stopwatch.StartNew();
|
||||
|
||||
while (sw.ElapsedMilliseconds < timeoutMs)
|
||||
{
|
||||
lock (_ringLock)
|
||||
{
|
||||
var available = (_ringWrite >= _ringRead)
|
||||
? _ringWrite - _ringRead
|
||||
: _ring.Length - _ringRead + _ringWrite;
|
||||
|
||||
if (available > 0)
|
||||
return available;
|
||||
}
|
||||
|
||||
Thread.Sleep(2);
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
|
||||
|
||||
private void StartProcess()
|
||||
{
|
||||
var psi = new ProcessStartInfo
|
||||
{
|
||||
FileName = "ffmpeg",
|
||||
Arguments =
|
||||
$"-hide_banner -loglevel error " +
|
||||
$"-f f32le -ar {_sampleRate} -ac {_channels} -i pipe:0 " +
|
||||
$"-af \"rubberband=tempo={_speed}\" " +
|
||||
$"-f f32le -ar {_sampleRate} -ac {_channels} pipe:1",
|
||||
RedirectStandardInput = true,
|
||||
RedirectStandardOutput = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
WindowStyle = ProcessWindowStyle.Hidden,
|
||||
};
|
||||
|
||||
_ff = System.Diagnostics.Process.Start(psi);
|
||||
_stdin = _ff!.StandardInput.BaseStream;
|
||||
_stdout = _ff!.StandardOutput.BaseStream;
|
||||
|
||||
_readerRunning = true;
|
||||
_readerThread = new Thread(ReaderLoop) { IsBackground = true };
|
||||
_readerThread.Start();
|
||||
}
|
||||
|
||||
|
||||
private void RestartProcess()
|
||||
{
|
||||
DisposeProcess();
|
||||
ResetRing();
|
||||
StartProcess();
|
||||
}
|
||||
|
||||
private void ReaderLoop()
|
||||
{
|
||||
var buf = new byte[4096];
|
||||
|
||||
try
|
||||
{
|
||||
while (_readerRunning)
|
||||
{
|
||||
var read = _stdout!.Read(buf, 0, buf.Length);
|
||||
|
||||
if (read <= 0)
|
||||
break;
|
||||
|
||||
lock (_ringLock)
|
||||
{
|
||||
var first = Math.Min(read, _ring.Length - _ringWrite);
|
||||
Buffer.BlockCopy(buf, 0, _ring, _ringWrite, first);
|
||||
_ringWrite = (_ringWrite + first) % _ring.Length;
|
||||
|
||||
var remaining = read - first;
|
||||
if (remaining > 0)
|
||||
{
|
||||
Buffer.BlockCopy(buf, first, _ring, _ringWrite, remaining);
|
||||
_ringWrite = (_ringWrite + remaining) % _ring.Length;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch { /* swallow for now */ }
|
||||
}
|
||||
|
||||
private int DrainRing(Span<byte> dest, int maxBytes)
|
||||
{
|
||||
lock (_ringLock)
|
||||
{
|
||||
var available = (_ringWrite >= _ringRead)
|
||||
? _ringWrite - _ringRead
|
||||
: _ring.Length - _ringRead + _ringWrite;
|
||||
|
||||
if (available <= 0)
|
||||
return 0;
|
||||
|
||||
var toRead = Math.Min(available, Math.Min(maxBytes, dest.Length));
|
||||
|
||||
var first = Math.Min(toRead, _ring.Length - _ringRead);
|
||||
new Span<byte>(_ring, _ringRead, first).CopyTo(dest.Slice(0, first));
|
||||
_ringRead = (_ringRead + first) % _ring.Length;
|
||||
|
||||
var remaining = toRead - first;
|
||||
if (remaining > 0)
|
||||
{
|
||||
new Span<byte>(_ring, _ringRead, remaining)
|
||||
.CopyTo(dest.Slice(first, remaining));
|
||||
_ringRead = (_ringRead + remaining) % _ring.Length;
|
||||
}
|
||||
|
||||
return toRead;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private void ResetRing()
|
||||
{
|
||||
lock (_ringLock)
|
||||
{
|
||||
_ringWrite = 0;
|
||||
_ringRead = 0;
|
||||
}
|
||||
}
|
||||
|
||||
private void DisposeProcess()
|
||||
{
|
||||
_readerRunning = false;
|
||||
try { _stdout?.Close(); } catch { }
|
||||
try { _stdin?.Close(); } catch { }
|
||||
try { _ff?.Kill(); } catch { }
|
||||
try { _ff?.Dispose(); } catch { }
|
||||
|
||||
if (_readerThread != null)
|
||||
{
|
||||
try { _readerThread.Join(500); } catch { }
|
||||
_readerThread = null;
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose() => DisposeProcess();
|
||||
}
|
||||
61
AudioCore/Impl/StemDecoder.cs
Normal file
61
AudioCore/Impl/StemDecoder.cs
Normal file
@ -0,0 +1,61 @@
|
||||
namespace AudioCore.Impl;
|
||||
|
||||
public sealed class StemDecoder : IStemDecoder
|
||||
{
|
||||
private readonly IAudioReader _reader;
|
||||
private readonly AudioBufferPool _pool;
|
||||
private readonly int _blockSize;
|
||||
private long _currentSample;
|
||||
|
||||
public StemTrack Stem { get; }
|
||||
|
||||
public StemDecoder(
|
||||
IAudioReader reader,
|
||||
AudioBufferPool pool,
|
||||
StemTrack stem,
|
||||
int blockSize = 4096)
|
||||
{
|
||||
_reader = reader;
|
||||
_pool = pool;
|
||||
_blockSize = blockSize;
|
||||
Stem = stem;
|
||||
|
||||
Stem.Channels = reader.Channels;
|
||||
Stem.SampleRate = reader.SampleRate;
|
||||
Stem.Duration = TimeSpan.FromSeconds((double)reader.TotalSamples / reader.SampleRate);
|
||||
}
|
||||
|
||||
public bool TryDecodeNextBlock(out AudioBlock block)
|
||||
{
|
||||
var channels = _reader.Channels;
|
||||
var floatsNeeded = _blockSize * channels;
|
||||
|
||||
var buf = _pool.Rent(floatsNeeded);
|
||||
var readFloats = _reader.Read(buf.Samples, 0, floatsNeeded);
|
||||
|
||||
if (readFloats <= 0)
|
||||
{
|
||||
buf.Dispose();
|
||||
block = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
buf.Length = readFloats;
|
||||
|
||||
var pos = _currentSample;
|
||||
_currentSample += readFloats / channels;
|
||||
|
||||
block = new AudioBlock(buf, _reader.SampleRate, channels, pos);
|
||||
return true;
|
||||
}
|
||||
|
||||
public void Seek(long samplePosition)
|
||||
{
|
||||
_reader.Seek(samplePosition);
|
||||
_currentSample = samplePosition;
|
||||
}
|
||||
|
||||
public void Reset() => Seek(0);
|
||||
|
||||
public void Dispose() => _reader.Dispose();
|
||||
}
|
||||
24
AudioCore/Impl/StemDecoderFactory.cs
Normal file
24
AudioCore/Impl/StemDecoderFactory.cs
Normal file
@ -0,0 +1,24 @@
|
||||
namespace AudioCore.Impl;
|
||||
|
||||
public sealed class StemDecoderFactory : IStemDecoderFactory
|
||||
{
|
||||
private readonly IAudioReaderFactory _readerFactory;
|
||||
private readonly AudioBufferPool _pool;
|
||||
private readonly int _blockSize;
|
||||
|
||||
public StemDecoderFactory(
|
||||
IAudioReaderFactory readerFactory,
|
||||
AudioBufferPool pool,
|
||||
int blockSize = 4096)
|
||||
{
|
||||
_readerFactory = readerFactory;
|
||||
_pool = pool;
|
||||
_blockSize = blockSize;
|
||||
}
|
||||
|
||||
public IStemDecoder Create(StemTrack stem)
|
||||
{
|
||||
var reader = _readerFactory.Create(stem.FilePath);
|
||||
return new StemDecoder(reader, _pool, stem, _blockSize);
|
||||
}
|
||||
}
|
||||
430
AudioCore/Impl/StemPlaybackEngine.cs
Normal file
430
AudioCore/Impl/StemPlaybackEngine.cs
Normal file
@ -0,0 +1,430 @@
|
||||
namespace AudioCore.Impl;
|
||||
|
||||
public sealed class StemPlaybackEngine : IStemPlaybackEngine, IDisposable
|
||||
{
|
||||
private readonly IStemDecoderFactory _stemDecoderFactory;
|
||||
private readonly IAudioOutputDevice _outputDevice;
|
||||
private readonly IAudioMixer _audioMixer;
|
||||
private readonly ITimeStretchEngine _timeStretchEngine;
|
||||
|
||||
private readonly Lock _stateLock = new();
|
||||
|
||||
private PlaybackSession? _session;
|
||||
private IStemDecoder[] _decoders = Array.Empty<IStemDecoder>();
|
||||
|
||||
private StemMixSettings[] _stemMixSettings = Array.Empty<StemMixSettings>();
|
||||
private MixerSettings? _mixerSettings;
|
||||
|
||||
private PlaybackSpeedSettings _speedSettings = new();
|
||||
private LoopRegion _loopRegion = new();
|
||||
|
||||
private long _currentFramePosition;
|
||||
private long _loopStartFrames;
|
||||
private long _loopEndFrames;
|
||||
|
||||
private bool _isPlaying;
|
||||
private CancellationTokenSource? _renderCts;
|
||||
private Task? _renderTask;
|
||||
private IProgressReporter<TimeSpan>? _progressReporter;
|
||||
|
||||
public StemPlaybackEngine(
|
||||
IStemDecoderFactory stemDecoderFactory,
|
||||
IAudioOutputDevice outputDevice,
|
||||
IAudioMixer audioMixer,
|
||||
ITimeStretchEngine timeStretchEngine)
|
||||
{
|
||||
_stemDecoderFactory = stemDecoderFactory;
|
||||
_outputDevice = outputDevice;
|
||||
_audioMixer = audioMixer;
|
||||
_timeStretchEngine = timeStretchEngine;
|
||||
}
|
||||
|
||||
public PlaybackSession? CurrentSession
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_stateLock)
|
||||
{
|
||||
return _session;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async Task LoadSessionAsync(PlaybackSession session, IProgressReporter<TimeSpan > progress)
|
||||
{
|
||||
await StopAsync().ConfigureAwait(false);
|
||||
|
||||
lock (_stateLock)
|
||||
{
|
||||
_session = session;
|
||||
_progressReporter = progress;
|
||||
|
||||
_decoders = session.StemSet.Stems
|
||||
.Select(stem => _stemDecoderFactory.Create(stem))
|
||||
.ToArray();
|
||||
|
||||
_stemMixSettings = session.Mixer.Stems.ToArray();
|
||||
_mixerSettings = new MixerSettings
|
||||
{
|
||||
Stems = _stemMixSettings
|
||||
};
|
||||
|
||||
_speedSettings = new PlaybackSpeedSettings
|
||||
{
|
||||
Speed = session.Speed.Speed
|
||||
};
|
||||
_timeStretchEngine.Configure(_speedSettings);
|
||||
|
||||
_loopRegion = session.Loop;
|
||||
if (_loopRegion.IsEnabled)
|
||||
{
|
||||
_loopStartFrames = TimeToFrames(_loopRegion.Start);
|
||||
_loopEndFrames = TimeToFrames(_loopRegion.End);
|
||||
}
|
||||
else
|
||||
{
|
||||
_loopStartFrames = 0;
|
||||
_loopEndFrames = 0;
|
||||
}
|
||||
|
||||
_currentFramePosition = 0;
|
||||
|
||||
foreach (var decoder in _decoders)
|
||||
{
|
||||
decoder.Reset();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public Task PlayAsync()
|
||||
{
|
||||
lock (_stateLock)
|
||||
{
|
||||
if (_isPlaying)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
if (_renderTask is null || _renderTask.IsCompleted)
|
||||
{
|
||||
_renderCts?.Dispose();
|
||||
_renderCts = new CancellationTokenSource();
|
||||
_renderTask = Task.Run(() => RenderLoopAsync(_renderCts.Token));
|
||||
}
|
||||
|
||||
_isPlaying = true;
|
||||
_outputDevice.Start();
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task PauseAsync()
|
||||
{
|
||||
lock (_stateLock)
|
||||
{
|
||||
if (!_isPlaying)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
_isPlaying = false;
|
||||
_outputDevice.Stop();
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public async Task StopAsync()
|
||||
{
|
||||
CancellationTokenSource? ctsToCancel;
|
||||
IStemDecoder[] decodersToDispose;
|
||||
Task? renderTask;
|
||||
|
||||
lock (_stateLock)
|
||||
{
|
||||
if (!_isPlaying && _renderTask is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_isPlaying = false;
|
||||
_currentFramePosition = 0;
|
||||
|
||||
ctsToCancel = _renderCts;
|
||||
_renderCts = null;
|
||||
|
||||
_outputDevice.Stop();
|
||||
|
||||
decodersToDispose = _decoders;
|
||||
_decoders = Array.Empty<IStemDecoder>();
|
||||
|
||||
renderTask = _renderTask;
|
||||
_renderTask = null;
|
||||
}
|
||||
|
||||
if (ctsToCancel is not null)
|
||||
{
|
||||
ctsToCancel.Cancel();
|
||||
}
|
||||
|
||||
// Do NOT wait on renderTask if we are already inside it.
|
||||
// Just let it observe cancellation and exit.
|
||||
if (renderTask is not null && !ReferenceEquals(renderTask, Task.CurrentId))
|
||||
{
|
||||
try
|
||||
{
|
||||
await renderTask.ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var decoder in decodersToDispose)
|
||||
{
|
||||
decoder.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
public Task SeekAsync(TimeSpan position)
|
||||
{
|
||||
var frameIndex = TimeToFrames(position);
|
||||
|
||||
lock (_stateLock)
|
||||
{
|
||||
if (_session is null || _decoders.Length == 0)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
_currentFramePosition = frameIndex;
|
||||
|
||||
foreach (var decoder in _decoders)
|
||||
{
|
||||
decoder.Seek(frameIndex);
|
||||
}
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public void SetLoop(TimeSpan start, TimeSpan end)
|
||||
{
|
||||
lock (_stateLock)
|
||||
{
|
||||
_loopRegion = new LoopRegion
|
||||
{
|
||||
IsEnabled = true,
|
||||
Start = start,
|
||||
End = end
|
||||
};
|
||||
|
||||
_loopStartFrames = TimeToFrames(start);
|
||||
_loopEndFrames = TimeToFrames(end);
|
||||
}
|
||||
}
|
||||
|
||||
public void ClearLoop()
|
||||
{
|
||||
lock (_stateLock)
|
||||
{
|
||||
_loopRegion = new LoopRegion
|
||||
{
|
||||
IsEnabled = false,
|
||||
Start = TimeSpan.Zero,
|
||||
End = TimeSpan.Zero
|
||||
};
|
||||
|
||||
_loopStartFrames = 0;
|
||||
_loopEndFrames = 0;
|
||||
}
|
||||
}
|
||||
|
||||
public void SetSpeed(double speedFactor)
|
||||
{
|
||||
lock (_stateLock)
|
||||
{
|
||||
_speedSettings.Speed = (float)speedFactor;
|
||||
_timeStretchEngine.Configure(_speedSettings);
|
||||
}
|
||||
}
|
||||
|
||||
public void SetStemEnabled(int stemIndex, bool enabled)
|
||||
{
|
||||
lock (_stateLock)
|
||||
{
|
||||
if (stemIndex < 0 || stemIndex >= _stemMixSettings.Length)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var current = _stemMixSettings[stemIndex];
|
||||
_stemMixSettings[stemIndex] = new StemMixSettings
|
||||
{
|
||||
Enabled = enabled,
|
||||
GainDb = current.GainDb,
|
||||
Pan = current.Pan
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public void SetStemGain(int stemIndex, float gainDb)
|
||||
{
|
||||
lock (_stateLock)
|
||||
{
|
||||
if (stemIndex < 0 || stemIndex >= _stemMixSettings.Length)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var current = _stemMixSettings[stemIndex];
|
||||
_stemMixSettings[stemIndex] = new StemMixSettings
|
||||
{
|
||||
Enabled = current.Enabled,
|
||||
GainDb = gainDb,
|
||||
Pan = current.Pan
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public void SetStemPan(int stemIndex, float pan)
|
||||
{
|
||||
lock (_stateLock)
|
||||
{
|
||||
if (stemIndex < 0 || stemIndex >= _stemMixSettings.Length)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var current = _stemMixSettings[stemIndex];
|
||||
_stemMixSettings[stemIndex] = new StemMixSettings
|
||||
{
|
||||
Enabled = current.Enabled,
|
||||
GainDb = current.GainDb,
|
||||
Pan = pan
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RenderLoopAsync(CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
while (!ct.IsCancellationRequested)
|
||||
{
|
||||
bool playing;
|
||||
IStemDecoder[] decodersSnapshot;
|
||||
MixerSettings? mixerSettingsSnapshot;
|
||||
long loopStart;
|
||||
long loopEnd;
|
||||
bool loopEnabled;
|
||||
|
||||
lock (_stateLock)
|
||||
{
|
||||
playing = _isPlaying;
|
||||
decodersSnapshot = _decoders;
|
||||
mixerSettingsSnapshot = _mixerSettings;
|
||||
loopStart = _loopStartFrames;
|
||||
loopEnd = _loopEndFrames;
|
||||
loopEnabled = _loopRegion.IsEnabled;
|
||||
}
|
||||
|
||||
if (!playing || decodersSnapshot.Length == 0 || mixerSettingsSnapshot is null)
|
||||
{
|
||||
await Task.Delay(5, ct).ConfigureAwait(false);
|
||||
continue;
|
||||
}
|
||||
|
||||
List<AudioBlock> stemBlocks = new();
|
||||
|
||||
bool eofDetected = false;
|
||||
|
||||
// Decode once, no double scanning
|
||||
var totalFrames = decodersSnapshot[0].Stem.Duration.TotalSeconds * _outputDevice.SampleRate;
|
||||
|
||||
foreach (var decoder in decodersSnapshot)
|
||||
{
|
||||
if (!decoder.TryDecodeNextBlock(out var block))
|
||||
{
|
||||
eofDetected = true;
|
||||
foreach (var b in stemBlocks)
|
||||
b.Dispose();
|
||||
break;
|
||||
}
|
||||
|
||||
stemBlocks.Add(block);
|
||||
}
|
||||
|
||||
if (eofDetected || stemBlocks.Count == 0)
|
||||
{
|
||||
// Report EOF progress
|
||||
await _progressReporter!.ReportProgress(TimeSpan.FromSeconds(1.0));
|
||||
|
||||
lock (_stateLock)
|
||||
{
|
||||
_isPlaying = false;
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
// Report progress (0..1)
|
||||
var progress = TimeSpan.FromSeconds((double)_currentFramePosition / _outputDevice.SampleRate);
|
||||
await _progressReporter!.ReportProgress(progress);
|
||||
|
||||
using var mixed = _audioMixer.Mix(stemBlocks, mixerSettingsSnapshot);
|
||||
|
||||
foreach (var block in stemBlocks)
|
||||
block.Dispose();
|
||||
|
||||
var nextPosition = mixed.SamplePosition + mixed.Frames;
|
||||
|
||||
// Loop region handling
|
||||
if (loopEnabled && loopEnd > loopStart && nextPosition >= loopEnd)
|
||||
{
|
||||
foreach (var decoder in decodersSnapshot)
|
||||
decoder.Seek(loopStart);
|
||||
|
||||
lock (_stateLock)
|
||||
{
|
||||
_currentFramePosition = loopStart;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
using var stretched = _timeStretchEngine.Process(mixed);
|
||||
|
||||
_outputDevice.Write(stretched.Buffer.Span);
|
||||
|
||||
lock (_stateLock)
|
||||
{
|
||||
_currentFramePosition = nextPosition;
|
||||
}
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_outputDevice.Stop();
|
||||
}
|
||||
}
|
||||
|
||||
private long TimeToFrames(TimeSpan time)
|
||||
{
|
||||
return (long)(time.TotalSeconds * _outputDevice.SampleRate);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_renderCts?.Cancel();
|
||||
_renderCts?.Dispose();
|
||||
|
||||
foreach (var decoder in _decoders)
|
||||
{
|
||||
decoder.Dispose();
|
||||
}
|
||||
|
||||
_decoders = Array.Empty<IStemDecoder>();
|
||||
}
|
||||
}
|
||||
99
AudioCore/Impl/StemWaveformService.cs
Normal file
99
AudioCore/Impl/StemWaveformService.cs
Normal file
@ -0,0 +1,99 @@
|
||||
namespace AudioCore.Impl;
|
||||
|
||||
public sealed class StemWaveformService : IStemWaveformService
|
||||
{
|
||||
private readonly AudioBufferPool _bufferPool;
|
||||
|
||||
public StemWaveformService(AudioBufferPool bufferPool)
|
||||
{
|
||||
_bufferPool = bufferPool;
|
||||
}
|
||||
|
||||
public async Task<float[]> ComputeWaveformAsync(StemTrack stem, IStemDecoder decoder, int segments = 200)
|
||||
{
|
||||
if (segments <= 0)
|
||||
{
|
||||
return Array.Empty<float>();
|
||||
}
|
||||
|
||||
var value = await TryReadingFromCache(stem, segments);
|
||||
if (value != null)
|
||||
return value;
|
||||
|
||||
var totalFrames = (long)(decoder.Stem.Duration.TotalSeconds * decoder.Stem.SampleRate);
|
||||
var framesPerSegment = Math.Max(1,totalFrames / segments);
|
||||
|
||||
var result = new float[segments];
|
||||
|
||||
decoder.Reset();
|
||||
|
||||
for (var i = 0; i < segments; i++)
|
||||
{
|
||||
var segmentStart = framesPerSegment * i;
|
||||
decoder.Seek(segmentStart);
|
||||
|
||||
var sum = 0f;
|
||||
var count = 0;
|
||||
|
||||
// Decode only one block per segment
|
||||
if (decoder.TryDecodeNextBlock(out var block))
|
||||
{
|
||||
try
|
||||
{
|
||||
var span = block.Span;
|
||||
var channels = decoder.Stem.Channels;
|
||||
|
||||
for (var s = 0; s < span.Length; s++)
|
||||
{
|
||||
var v = span[s];
|
||||
sum += Math.Abs(v);
|
||||
}
|
||||
|
||||
count = span.Length;
|
||||
}
|
||||
finally
|
||||
{
|
||||
block.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
result[i] = count > 0 ? sum / count : 0f;
|
||||
|
||||
await Task.Yield();
|
||||
}
|
||||
|
||||
await SaveToCache(stem, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
private async Task SaveToCache(StemTrack stem, float[] result)
|
||||
{
|
||||
var cahcheFile = GetCahcheFileName(stem);
|
||||
if (cahcheFile == null)
|
||||
return;
|
||||
|
||||
var bytes = new byte[result.Length * sizeof(float)];
|
||||
Buffer.BlockCopy(result, 0, bytes, 0, bytes.Length);
|
||||
await File.WriteAllBytesAsync(cahcheFile, bytes);
|
||||
}
|
||||
|
||||
private static async Task<float[]?> TryReadingFromCache(StemTrack stem, int segments)
|
||||
{
|
||||
var cahcheFile = GetCahcheFileName(stem);
|
||||
if (!File.Exists(cahcheFile))
|
||||
return null;
|
||||
|
||||
var cachedData = await File.ReadAllBytesAsync(cahcheFile);
|
||||
if (cachedData.Length == segments * sizeof(float))
|
||||
{
|
||||
var res = new float[segments];
|
||||
Buffer.BlockCopy(cachedData, 0, res, 0, cachedData.Length);
|
||||
return res;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string? GetCahcheFileName(StemTrack stem) =>
|
||||
string.IsNullOrWhiteSpace(stem.FilePath) ? null : Path.Combine(Path.GetDirectoryName(stem.FilePath)!, $"{Path.GetFileNameWithoutExtension(stem.FilePath)}.waveform");
|
||||
}
|
||||
115
AudioCore/Impl/WasapiOutputDevice.cs
Normal file
115
AudioCore/Impl/WasapiOutputDevice.cs
Normal file
@ -0,0 +1,115 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Runtime.InteropServices;
|
||||
using NAudio.CoreAudioApi;
|
||||
using NAudio.Wave;
|
||||
|
||||
namespace AudioCore.Impl;
|
||||
|
||||
public sealed class WasapiOutputDevice : IAudioOutputDevice, IDisposable
|
||||
{
|
||||
private readonly IWavePlayer _out;
|
||||
private readonly BufferedWaveProvider _buffer;
|
||||
private readonly WaveFormat _mixFormat = null!;
|
||||
private readonly ByteBufferPool _pool;
|
||||
|
||||
public int SampleRate { get; }
|
||||
public int Channels { get; }
|
||||
/// <summary>
|
||||
/// For tests only, exposes the underlying buffer to allow direct writes.
|
||||
/// </summary>
|
||||
public BufferedWaveProvider Buffer => _buffer;
|
||||
|
||||
/// <summary>
|
||||
/// For tests only
|
||||
/// </summary>
|
||||
public WasapiOutputDevice(ByteBufferPool pool, IWavePlayer output, int sampleRate = 44100, int channels = 2)
|
||||
{
|
||||
_pool = pool;
|
||||
_mixFormat = WaveFormat.CreateIeeeFloatWaveFormat(sampleRate, channels);
|
||||
SampleRate = sampleRate;
|
||||
Channels = channels;
|
||||
|
||||
_buffer = new BufferedWaveProvider(_mixFormat)
|
||||
{
|
||||
DiscardOnBufferOverflow = true
|
||||
};
|
||||
|
||||
_out = output;
|
||||
_out.Init(_buffer);
|
||||
}
|
||||
|
||||
[ExcludeFromCodeCoverage]
|
||||
public WasapiOutputDevice(ByteBufferPool pool, int channels = 2)
|
||||
{
|
||||
Channels = channels;
|
||||
_pool = pool;
|
||||
|
||||
// Get default audio device
|
||||
var device = new MMDeviceEnumerator()
|
||||
.GetDefaultAudioEndpoint(DataFlow.Render, Role.Multimedia);
|
||||
|
||||
// Use the system mix format (required for shared mode)
|
||||
_mixFormat = device.AudioClient.MixFormat;
|
||||
SampleRate = _mixFormat.SampleRate;
|
||||
|
||||
// Create buffer in mix format
|
||||
_buffer = new BufferedWaveProvider(_mixFormat)
|
||||
{
|
||||
BufferLength = _mixFormat.AverageBytesPerSecond * 4, // 4 seconds
|
||||
DiscardOnBufferOverflow = false
|
||||
};
|
||||
|
||||
// Event-driven WASAPI mode (true = event mode)
|
||||
_out = new WasapiOut(device, AudioClientShareMode.Shared, true, 10);
|
||||
_out.Init(_buffer);
|
||||
}
|
||||
|
||||
public void Start() => _out.Play();
|
||||
public void Stop() => _out.Stop();
|
||||
|
||||
public void Write(ReadOnlySpan<float> samples)
|
||||
{
|
||||
// Convert float -> PCM16 or float passthrough depending on mix format
|
||||
byte[] bytes;
|
||||
|
||||
if (_mixFormat.Encoding == WaveFormatEncoding.IeeeFloat)
|
||||
{
|
||||
// Device supports float32 directly
|
||||
bytes = MemoryMarshal.AsBytes(samples).ToArray();
|
||||
Send(bytes);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Convert float -> PCM16
|
||||
int count = samples.Length;
|
||||
using var buffer = _pool.Rent(count * 2);
|
||||
|
||||
int bi = 0;
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
short pcm = (short)(Math.Clamp(samples[i], -1f, 1f) * short.MaxValue);
|
||||
buffer.Samples[bi++] = (byte)(pcm & 0xFF);
|
||||
buffer.Samples[bi++] = (byte)((pcm >> 8) & 0xFF);
|
||||
}
|
||||
Send(buffer.Samples);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private void Send(byte[] bytes)
|
||||
{
|
||||
// Wait until buffer has enough free space
|
||||
while (_buffer.BufferedBytes + bytes.Length > _buffer.BufferLength)
|
||||
{
|
||||
// Sleep a tiny amount to let WASAPI consume data
|
||||
Thread.Sleep(2);
|
||||
}
|
||||
|
||||
_buffer.AddSamples(bytes, 0, bytes.Length);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_out.Dispose();
|
||||
}
|
||||
}
|
||||
8
AudioCore/Interfaces/IAudioMixer.cs
Normal file
8
AudioCore/Interfaces/IAudioMixer.cs
Normal file
@ -0,0 +1,8 @@
|
||||
namespace AudioCore.Interfaces;
|
||||
|
||||
public interface IAudioMixer
|
||||
{
|
||||
MixedAudioBlock Mix(
|
||||
IReadOnlyList<AudioBlock> stemBlocks,
|
||||
MixerSettings settings);
|
||||
}
|
||||
13
AudioCore/Interfaces/IAudioOutputDevice.cs
Normal file
13
AudioCore/Interfaces/IAudioOutputDevice.cs
Normal file
@ -0,0 +1,13 @@
|
||||
namespace AudioCore.Interfaces;
|
||||
|
||||
public interface IAudioOutputDevice
|
||||
{
|
||||
int SampleRate { get; }
|
||||
int Channels { get; }
|
||||
|
||||
void Start();
|
||||
void Stop();
|
||||
|
||||
// Push interleaved float32 PCM
|
||||
void Write(ReadOnlySpan<float> samples);
|
||||
}
|
||||
17
AudioCore/Interfaces/IAudioReader.cs
Normal file
17
AudioCore/Interfaces/IAudioReader.cs
Normal file
@ -0,0 +1,17 @@
|
||||
namespace AudioCore.Interfaces;
|
||||
|
||||
public interface IAudioReader : IDisposable
|
||||
{
|
||||
int SampleRate { get; }
|
||||
int Channels { get; }
|
||||
long TotalSamples { get; }
|
||||
|
||||
// Read PCM float samples into the provided buffer.
|
||||
// Returns number of samples actually read.
|
||||
int Read(float[] buffer, int offset, int count);
|
||||
|
||||
// Seek to absolute sample index.
|
||||
void Seek(long sampleIndex);
|
||||
|
||||
void Reset();
|
||||
}
|
||||
6
AudioCore/Interfaces/IAudioReaderFactory.cs
Normal file
6
AudioCore/Interfaces/IAudioReaderFactory.cs
Normal file
@ -0,0 +1,6 @@
|
||||
namespace AudioCore.Interfaces;
|
||||
|
||||
public interface IAudioReaderFactory
|
||||
{
|
||||
IAudioReader Create(string filePath);
|
||||
}
|
||||
6
AudioCore/Interfaces/IProgressReporter.cs
Normal file
6
AudioCore/Interfaces/IProgressReporter.cs
Normal file
@ -0,0 +1,6 @@
|
||||
namespace AudioCore.Interfaces;
|
||||
|
||||
public interface IProgressReporter<T>
|
||||
{
|
||||
Task ReportProgress(T progress, CancellationToken ct = default);
|
||||
}
|
||||
12
AudioCore/Interfaces/IStemDecoder.cs
Normal file
12
AudioCore/Interfaces/IStemDecoder.cs
Normal file
@ -0,0 +1,12 @@
|
||||
namespace AudioCore.Interfaces;
|
||||
|
||||
public interface IStemDecoder : IDisposable
|
||||
{
|
||||
StemTrack Stem { get; }
|
||||
|
||||
bool TryDecodeNextBlock(out AudioBlock block);
|
||||
|
||||
void Seek(long samplePosition);
|
||||
|
||||
void Reset();
|
||||
}
|
||||
6
AudioCore/Interfaces/IStemDecoderFactory.cs
Normal file
6
AudioCore/Interfaces/IStemDecoderFactory.cs
Normal file
@ -0,0 +1,6 @@
|
||||
namespace AudioCore.Interfaces;
|
||||
|
||||
public interface IStemDecoderFactory
|
||||
{
|
||||
IStemDecoder Create(StemTrack stem);
|
||||
}
|
||||
26
AudioCore/Interfaces/IStemPlaybackEngine.cs
Normal file
26
AudioCore/Interfaces/IStemPlaybackEngine.cs
Normal file
@ -0,0 +1,26 @@
|
||||
namespace AudioCore.Interfaces;
|
||||
|
||||
public interface IStemPlaybackEngine
|
||||
{
|
||||
PlaybackSession? CurrentSession { get; }
|
||||
|
||||
Task LoadSessionAsync(PlaybackSession session, IProgressReporter<TimeSpan> progressReporter);
|
||||
|
||||
// Transport
|
||||
Task PlayAsync();
|
||||
Task PauseAsync();
|
||||
Task StopAsync();
|
||||
Task SeekAsync(TimeSpan position);
|
||||
|
||||
// Loop
|
||||
void SetLoop(TimeSpan start, TimeSpan end);
|
||||
void ClearLoop();
|
||||
|
||||
// Speed
|
||||
void SetSpeed(double speedFactor);
|
||||
|
||||
// Mixer
|
||||
void SetStemEnabled(int stemNo, bool enabled);
|
||||
void SetStemGain(int stemNo, float gainDb);
|
||||
void SetStemPan(int stemNo, float pan);
|
||||
}
|
||||
9
AudioCore/Interfaces/IStemSeparator.cs
Normal file
9
AudioCore/Interfaces/IStemSeparator.cs
Normal file
@ -0,0 +1,9 @@
|
||||
namespace AudioCore.Interfaces;
|
||||
|
||||
public interface IStemSeparator
|
||||
{
|
||||
Task<StemSet> SeparateAsync(
|
||||
StemSeparationRequest request,
|
||||
IProgressReporter<double> progress,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
17
AudioCore/Interfaces/IStemWaveformService.cs
Normal file
17
AudioCore/Interfaces/IStemWaveformService.cs
Normal file
@ -0,0 +1,17 @@
|
||||
namespace AudioCore.Interfaces;
|
||||
|
||||
public interface IStemWaveformService
|
||||
{
|
||||
/// <summary>
|
||||
/// Computes N averaged amplitude values for the given stem.
|
||||
/// </summary>
|
||||
/// <param name="decoder">Configured stem decoder.</param>
|
||||
/// <param name="segments">Number of averages to produce.</param>
|
||||
/// <returns>Float array of length N.</returns>
|
||||
Task<float[]> ComputeWaveformAsync(StemTrack stem, IStemDecoder decoder, int segments = 200);
|
||||
}
|
||||
|
||||
public interface IStemWaveformServiceFactory
|
||||
{
|
||||
IStemWaveformService Create(StemTrack stem);
|
||||
}
|
||||
15
AudioCore/Interfaces/ITimeStretchEngine.cs
Normal file
15
AudioCore/Interfaces/ITimeStretchEngine.cs
Normal file
@ -0,0 +1,15 @@
|
||||
namespace AudioCore.Interfaces;
|
||||
|
||||
public sealed class PlaybackSpeedSettings
|
||||
{
|
||||
public float Speed { get; set; } = 1.0f; // 0.5x, 1.0x, 1.5x, etc.
|
||||
}
|
||||
|
||||
|
||||
public interface ITimeStretchEngine
|
||||
{
|
||||
void Configure(PlaybackSpeedSettings settings);
|
||||
|
||||
// Streaming block processing
|
||||
TimeStretchedAudioBlock Process(MixedAudioBlock input);
|
||||
}
|
||||
25
AudioCore/Models/AudioBlock.cs
Normal file
25
AudioCore/Models/AudioBlock.cs
Normal file
@ -0,0 +1,25 @@
|
||||
using AudioCore.Impl;
|
||||
|
||||
namespace AudioCore.Models;
|
||||
|
||||
public readonly struct AudioBlock : IDisposable
|
||||
{
|
||||
public AudioBuffer<float> Buffer { get; }
|
||||
public int SampleRate { get; }
|
||||
public int Channels { get; }
|
||||
public long Position { get; }
|
||||
public int Frames => Buffer.Length / Channels;
|
||||
|
||||
public AudioBlock(AudioBuffer<float> buffer, int sampleRate, int channels, long samplePosition)
|
||||
{
|
||||
Buffer = buffer;
|
||||
SampleRate = sampleRate;
|
||||
Channels = channels;
|
||||
Position = samplePosition;
|
||||
}
|
||||
|
||||
public Span<float> Span => Buffer.Span;
|
||||
public int Length => Buffer.Length;
|
||||
|
||||
public void Dispose() => Buffer.Dispose();
|
||||
}
|
||||
8
AudioCore/Models/LoopRegion.cs
Normal file
8
AudioCore/Models/LoopRegion.cs
Normal file
@ -0,0 +1,8 @@
|
||||
namespace AudioCore.Models;
|
||||
|
||||
public sealed class LoopRegion
|
||||
{
|
||||
public bool IsEnabled { get; init; }
|
||||
public TimeSpan Start { get; init; }
|
||||
public TimeSpan End { get; init; }
|
||||
}
|
||||
23
AudioCore/Models/MixedAudioBlock.cs
Normal file
23
AudioCore/Models/MixedAudioBlock.cs
Normal file
@ -0,0 +1,23 @@
|
||||
using AudioCore.Impl;
|
||||
|
||||
namespace AudioCore.Models;
|
||||
|
||||
public readonly struct MixedAudioBlock : IDisposable
|
||||
{
|
||||
public AudioBuffer<float> Buffer { get; }
|
||||
public int Frames { get; }
|
||||
public int Channels { get; }
|
||||
public int SampleRate { get; }
|
||||
public long SamplePosition { get; }
|
||||
|
||||
public MixedAudioBlock(AudioBuffer<float> buffer, int frames, int channels, int sampleRate, long samplePosition)
|
||||
{
|
||||
Buffer = buffer;
|
||||
Frames = frames;
|
||||
Channels = channels;
|
||||
SampleRate = sampleRate;
|
||||
SamplePosition = samplePosition;
|
||||
}
|
||||
|
||||
public void Dispose() => Buffer.Dispose();
|
||||
}
|
||||
14
AudioCore/Models/MixerSettings.cs
Normal file
14
AudioCore/Models/MixerSettings.cs
Normal file
@ -0,0 +1,14 @@
|
||||
namespace AudioCore.Models;
|
||||
|
||||
public sealed class StemMixSettings
|
||||
{
|
||||
public bool Enabled { get; init; } = true;
|
||||
public float GainDb { get; init; } = 0f;
|
||||
public float Pan { get; init; } = 0f; // -1..+1
|
||||
}
|
||||
|
||||
public sealed class MixerSettings
|
||||
{
|
||||
public required IReadOnlyList<StemMixSettings> Stems { get; init; }
|
||||
}
|
||||
|
||||
9
AudioCore/Models/PlaybackSession.cs
Normal file
9
AudioCore/Models/PlaybackSession.cs
Normal file
@ -0,0 +1,9 @@
|
||||
namespace AudioCore.Models;
|
||||
|
||||
public sealed class PlaybackSession
|
||||
{
|
||||
public StemSet StemSet { get; init; } = default!;
|
||||
public MixerSettings Mixer { get; set; } = new() { Stems = [] };
|
||||
public LoopRegion Loop { get; set; } = new();
|
||||
public PlaybackSpeedSettings Speed { get; set; } = new();
|
||||
}
|
||||
7
AudioCore/Models/StemSeparationRequest.cs
Normal file
7
AudioCore/Models/StemSeparationRequest.cs
Normal file
@ -0,0 +1,7 @@
|
||||
namespace AudioCore.Models;
|
||||
|
||||
public class StemSeparationRequest
|
||||
{
|
||||
public string SourceFilePath { get; init; } = "";
|
||||
public string OutputDirectory { get; init; } = "";
|
||||
}
|
||||
7
AudioCore/Models/StemSet.cs
Normal file
7
AudioCore/Models/StemSet.cs
Normal file
@ -0,0 +1,7 @@
|
||||
namespace AudioCore.Models;
|
||||
|
||||
public sealed class StemSet
|
||||
{
|
||||
public string OriginalFilePath { get; init; } = string.Empty;
|
||||
public IReadOnlyList<StemTrack> Stems { get; init; } = Array.Empty<StemTrack>();
|
||||
}
|
||||
12
AudioCore/Models/StemTrack.cs
Normal file
12
AudioCore/Models/StemTrack.cs
Normal file
@ -0,0 +1,12 @@
|
||||
namespace AudioCore.Models;
|
||||
|
||||
public sealed class StemTrack
|
||||
{
|
||||
public StemType Type { get; init; }
|
||||
public string Name { get; init; } = string.Empty;
|
||||
public string FilePath { get; init; } = string.Empty;
|
||||
public TimeSpan Duration { get; set; }
|
||||
public int SampleRate { get; set; }
|
||||
public int Channels { get; set; }
|
||||
public float[] Waveform { get; set; } = [];
|
||||
}
|
||||
11
AudioCore/Models/StemType.cs
Normal file
11
AudioCore/Models/StemType.cs
Normal file
@ -0,0 +1,11 @@
|
||||
namespace AudioCore.Models;
|
||||
|
||||
public enum StemType
|
||||
{
|
||||
Drums,
|
||||
Bass,
|
||||
Other,
|
||||
Vocals,
|
||||
Guitar,
|
||||
Piano
|
||||
}
|
||||
21
AudioCore/Models/TimeStretchedAudioBlock.cs
Normal file
21
AudioCore/Models/TimeStretchedAudioBlock.cs
Normal file
@ -0,0 +1,21 @@
|
||||
using AudioCore.Impl;
|
||||
|
||||
namespace AudioCore.Models;
|
||||
|
||||
public readonly struct TimeStretchedAudioBlock : IDisposable
|
||||
{
|
||||
public AudioBuffer<float> Buffer { get; }
|
||||
public int Frames { get; }
|
||||
public int Channels { get; }
|
||||
public int SampleRate { get; }
|
||||
|
||||
public TimeStretchedAudioBlock(AudioBuffer<float> buffer, int frames, int channels, int sampleRate)
|
||||
{
|
||||
Buffer = buffer;
|
||||
Frames = frames;
|
||||
Channels = channels;
|
||||
SampleRate = sampleRate;
|
||||
}
|
||||
|
||||
public void Dispose() => Buffer.Dispose();
|
||||
}
|
||||
26
AudioCore_Tests/AudioCore_Tests.csproj
Normal file
26
AudioCore_Tests/AudioCore_Tests.csproj
Normal file
@ -0,0 +1,26 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.ML.OnnxRuntime.DirectML" />
|
||||
<PackageReference Include="MSTest" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Content Include="Data/*.*">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</Content>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\AudioCore\AudioCore.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Microsoft.VisualStudio.TestTools.UnitTesting" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
181
AudioCore_Tests/AudioMixer_Tests.cs
Normal file
181
AudioCore_Tests/AudioMixer_Tests.cs
Normal file
@ -0,0 +1,181 @@
|
||||
using AudioCore.Impl;
|
||||
using AudioCore.Interfaces;
|
||||
using AudioCore.Models;
|
||||
|
||||
namespace AudioCore_Tests;
|
||||
|
||||
[TestClass]
|
||||
public sealed class AudioMixer_Tests
|
||||
{
|
||||
private AudioBufferPool _pool = null!;
|
||||
private IAudioMixer _mixer = null!;
|
||||
|
||||
[TestInitialize]
|
||||
public void Init()
|
||||
{
|
||||
_pool = new AudioBufferPool();
|
||||
_mixer = new AudioMixer(_pool);
|
||||
}
|
||||
|
||||
private AudioBlock MakeBlock(float left, float right, int frames = 4, int sampleRate = 44100)
|
||||
{
|
||||
var buf = _pool.Rent(frames * 2);
|
||||
buf.Length = frames * 2;
|
||||
|
||||
for (var i = 0; i < frames; i++)
|
||||
{
|
||||
buf.Samples[i * 2 + 0] = left;
|
||||
buf.Samples[i * 2 + 1] = right;
|
||||
}
|
||||
|
||||
return new AudioBlock(buf, sampleRate, 2, 0);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Mixer_Mixes_Two_Stems()
|
||||
{
|
||||
var stem1 = MakeBlock(1f, 1f);
|
||||
var stem2 = MakeBlock(0.5f, 0.5f);
|
||||
|
||||
var settings = new MixerSettings
|
||||
{
|
||||
Stems = new[]
|
||||
{
|
||||
new StemMixSettings { Enabled = true, GainDb = 0, Pan = 0 },
|
||||
new StemMixSettings { Enabled = true, GainDb = 0, Pan = 0 }
|
||||
}
|
||||
};
|
||||
|
||||
var mixed = _mixer.Mix(new[] { stem1, stem2 }, settings);
|
||||
|
||||
var span = mixed.Buffer.Span;
|
||||
|
||||
for (var i = 0; i < mixed.Frames; i++)
|
||||
{
|
||||
Assert.AreEqual(1.5f, span[i * 2 + 0], 1e-6f);
|
||||
Assert.AreEqual(1.5f, span[i * 2 + 1], 1e-6f);
|
||||
}
|
||||
|
||||
mixed.Dispose();
|
||||
stem1.Dispose();
|
||||
stem2.Dispose();
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Mixer_Respects_Gain()
|
||||
{
|
||||
var stem = MakeBlock(1f, 1f);
|
||||
|
||||
var settings = new MixerSettings
|
||||
{
|
||||
Stems = new[]
|
||||
{
|
||||
new StemMixSettings { Enabled = true, GainDb = -6, Pan = 0 }
|
||||
}
|
||||
};
|
||||
|
||||
var mixed = _mixer.Mix(new[] { stem }, settings);
|
||||
|
||||
var expected = MathF.Pow(10f, -6f / 20f); // -6 dB
|
||||
|
||||
var span = mixed.Buffer.Span;
|
||||
|
||||
for (var i = 0; i < mixed.Frames; i++)
|
||||
{
|
||||
Assert.AreEqual(expected, span[i * 2 + 0], 1e-6f);
|
||||
Assert.AreEqual(expected, span[i * 2 + 1], 1e-6f);
|
||||
}
|
||||
|
||||
mixed.Dispose();
|
||||
stem.Dispose();
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Mixer_Respects_Pan()
|
||||
{
|
||||
var stem = MakeBlock(1f, 1f);
|
||||
|
||||
var settings = new MixerSettings
|
||||
{
|
||||
Stems = new[]
|
||||
{
|
||||
new StemMixSettings { Enabled = true, GainDb = 0, Pan = 1f } // full right
|
||||
}
|
||||
};
|
||||
|
||||
var mixed = _mixer.Mix(new[] { stem }, settings);
|
||||
var span = mixed.Buffer.Span;
|
||||
|
||||
for (var i = 0; i < mixed.Frames; i++)
|
||||
{
|
||||
Assert.AreEqual(0f, span[i * 2 + 0], 1e-6f); // left muted
|
||||
Assert.AreEqual(1f, span[i * 2 + 1], 1e-6f); // right full
|
||||
}
|
||||
|
||||
mixed.Dispose();
|
||||
stem.Dispose();
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Mixer_Disabled_Stem_Is_Ignored()
|
||||
{
|
||||
var stem1 = MakeBlock(1f, 1f);
|
||||
var stem2 = MakeBlock(1f, 1f);
|
||||
|
||||
var settings = new MixerSettings
|
||||
{
|
||||
Stems = new[]
|
||||
{
|
||||
new StemMixSettings { Enabled = true },
|
||||
new StemMixSettings { Enabled = false }
|
||||
}
|
||||
};
|
||||
|
||||
var mixed = _mixer.Mix(new[] { stem1, stem2 }, settings);
|
||||
var span = mixed.Buffer.Span;
|
||||
|
||||
for (var i = 0; i < mixed.Frames; i++)
|
||||
{
|
||||
Assert.AreEqual(1f, span[i * 2 + 0], 1e-6f);
|
||||
Assert.AreEqual(1f, span[i * 2 + 1], 1e-6f);
|
||||
}
|
||||
|
||||
mixed.Dispose();
|
||||
stem1.Dispose();
|
||||
stem2.Dispose();
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Mixer_Handles_Mono_Stem()
|
||||
{
|
||||
// mono block
|
||||
var frames = 4;
|
||||
var buf = _pool.Rent(frames);
|
||||
buf.Length = frames;
|
||||
|
||||
for (var i = 0; i < frames; i++)
|
||||
buf.Samples[i] = 2f;
|
||||
|
||||
var monoBlock = new AudioBlock(buf, 44100, 1, 0);
|
||||
|
||||
var settings = new MixerSettings
|
||||
{
|
||||
Stems = new[]
|
||||
{
|
||||
new StemMixSettings { Enabled = true }
|
||||
}
|
||||
};
|
||||
|
||||
var mixed = _mixer.Mix(new[] { monoBlock }, settings);
|
||||
var span = mixed.Buffer.Span;
|
||||
|
||||
for (var i = 0; i < mixed.Frames; i++)
|
||||
{
|
||||
Assert.AreEqual(2f, span[i * 2 + 0], 1e-6f);
|
||||
Assert.AreEqual(2f, span[i * 2 + 1], 1e-6f);
|
||||
}
|
||||
|
||||
mixed.Dispose();
|
||||
monoBlock.Dispose();
|
||||
}
|
||||
}
|
||||
BIN
AudioCore_Tests/Data/test_input.mp3
Normal file
BIN
AudioCore_Tests/Data/test_input.mp3
Normal file
Binary file not shown.
52
AudioCore_Tests/FakeAudioReader.cs
Normal file
52
AudioCore_Tests/FakeAudioReader.cs
Normal file
@ -0,0 +1,52 @@
|
||||
using AudioCore.Interfaces;
|
||||
|
||||
namespace AudioCore_Tests;
|
||||
|
||||
public sealed class FakeAudioReader : IAudioReader
|
||||
{
|
||||
private readonly float[] _data;
|
||||
private long _pos;
|
||||
private bool _disposed;
|
||||
|
||||
public int SampleRate { get; }
|
||||
public int Channels { get; }
|
||||
public long TotalSamples => _data.Length;
|
||||
|
||||
public FakeAudioReader(float[] data, int sampleRate = 48000, int channels = 2)
|
||||
{
|
||||
_data = data;
|
||||
SampleRate = sampleRate;
|
||||
Channels = channels;
|
||||
_pos = 0;
|
||||
}
|
||||
|
||||
public int Read(float[] buffer, int offset, int count)
|
||||
{
|
||||
if (_disposed)
|
||||
throw new ObjectDisposedException(nameof(FakeAudioReader));
|
||||
|
||||
var remaining = _data.Length - _pos;
|
||||
if (remaining <= 0)
|
||||
return 0;
|
||||
|
||||
var toRead = (int)Math.Min(count, remaining);
|
||||
Array.Copy(_data, _pos, buffer, offset, toRead);
|
||||
_pos += toRead;
|
||||
return toRead;
|
||||
}
|
||||
|
||||
public void Seek(long samplePosition)
|
||||
{
|
||||
if (_disposed)
|
||||
throw new ObjectDisposedException(nameof(FakeAudioReader));
|
||||
|
||||
_pos = Math.Clamp(samplePosition * Channels, 0, _data.Length);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_disposed = true;
|
||||
}
|
||||
|
||||
public void Reset() => _pos = 0;
|
||||
}
|
||||
47
AudioCore_Tests/FakeWasapiOut.cs
Normal file
47
AudioCore_Tests/FakeWasapiOut.cs
Normal file
@ -0,0 +1,47 @@
|
||||
using NAudio.Wave;
|
||||
|
||||
namespace AudioCore_Tests;
|
||||
|
||||
public sealed class FakeWasapiOut : IWavePlayer
|
||||
{
|
||||
public bool Played { get; private set; }
|
||||
public bool Stopped { get; private set; }
|
||||
public bool Disposed { get; private set; }
|
||||
public IWaveProvider? Provider { get; private set; }
|
||||
|
||||
public PlaybackState PlaybackState { get; private set; } = PlaybackState.Stopped;
|
||||
|
||||
public float Volume { get; set; } = 1.0f;
|
||||
|
||||
public WaveFormat? OutputWaveFormat => Provider?.WaveFormat;
|
||||
|
||||
public event EventHandler<StoppedEventArgs>? PlaybackStopped;
|
||||
|
||||
public void Init(IWaveProvider waveProvider)
|
||||
{
|
||||
Provider = waveProvider;
|
||||
}
|
||||
|
||||
public void Play()
|
||||
{
|
||||
Played = true;
|
||||
PlaybackState = PlaybackState.Playing;
|
||||
}
|
||||
|
||||
public void Stop()
|
||||
{
|
||||
Stopped = true;
|
||||
PlaybackState = PlaybackState.Stopped;
|
||||
PlaybackStopped?.Invoke(this, new StoppedEventArgs());
|
||||
}
|
||||
|
||||
public void Pause()
|
||||
{
|
||||
PlaybackState = PlaybackState.Paused;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Disposed = true;
|
||||
}
|
||||
}
|
||||
207
AudioCore_Tests/FfmpegAudioReader_Tests.cs
Normal file
207
AudioCore_Tests/FfmpegAudioReader_Tests.cs
Normal file
@ -0,0 +1,207 @@
|
||||
using System.Diagnostics;
|
||||
using AudioCore.Impl;
|
||||
|
||||
namespace AudioCore_Tests;
|
||||
|
||||
[TestClass]
|
||||
public sealed class FfmpegAudioReader_Tests
|
||||
{
|
||||
private string _inputPath = null!;
|
||||
|
||||
[TestInitialize]
|
||||
public void Init()
|
||||
{
|
||||
var baseDir = AppContext.BaseDirectory;
|
||||
_inputPath = Path.Combine(baseDir, "Data", "test_input.mp3");
|
||||
|
||||
Assert.IsTrue(File.Exists(_inputPath), "Test input audio missing");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Reader_Opens_And_Reports_Properties()
|
||||
{
|
||||
using var reader = new FfmpegAudioReader(_inputPath);
|
||||
|
||||
Assert.AreEqual(44100, reader.SampleRate);
|
||||
Assert.AreEqual(2, reader.Channels);
|
||||
Assert.IsGreaterThan(0, reader.TotalSamples);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Reader_Reads_Some_Samples()
|
||||
{
|
||||
using var reader = new FfmpegAudioReader(_inputPath);
|
||||
|
||||
var buf = new float[44100]; // 0.5 sec stereo = 22050 frames
|
||||
var read = reader.Read(buf, 0, buf.Length);
|
||||
|
||||
Assert.IsGreaterThan(0, read, "Reader returned no samples");
|
||||
Assert.IsLessThanOrEqualTo(buf.Length, read);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Reader_Seek_Works()
|
||||
{
|
||||
using var reader = new FfmpegAudioReader(_inputPath);
|
||||
|
||||
var buf1 = new float[44100];
|
||||
var buf2 = new float[44100];
|
||||
|
||||
// Read from start
|
||||
var r1 = reader.Read(buf1, 0, buf1.Length);
|
||||
Assert.IsGreaterThan(0, r1);
|
||||
|
||||
// Seek to 1 second
|
||||
reader.Seek(reader.SampleRate);
|
||||
|
||||
var r2 = reader.Read(buf2, 0, buf2.Length);
|
||||
Assert.IsGreaterThan(0, r2);
|
||||
|
||||
// Buffers should differ
|
||||
var identical = true;
|
||||
for (var i = 0; i < Math.Min(r1, r2); i++)
|
||||
{
|
||||
if (buf1[i] != buf2[i])
|
||||
{
|
||||
identical = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Assert.IsFalse(identical, "Seek did not change decoded samples");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Reader_Reset_Works()
|
||||
{
|
||||
using var reader = new FfmpegAudioReader(_inputPath);
|
||||
|
||||
var buf1 = new float[44100];
|
||||
var buf2 = new float[44100];
|
||||
|
||||
var r1 = reader.Read(buf1, 0, buf1.Length);
|
||||
Assert.IsGreaterThan(0, r1);
|
||||
|
||||
reader.Reset();
|
||||
|
||||
var r2 = reader.Read(buf2, 0, buf2.Length);
|
||||
Assert.IsGreaterThan(0, r2);
|
||||
|
||||
// After reset, buffers should match again
|
||||
var identical = true;
|
||||
for (var i = 0; i < Math.Min(r1, r2); i++)
|
||||
{
|
||||
if (buf1[i] != buf2[i])
|
||||
{
|
||||
identical = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Assert.IsTrue(identical, "Reset did not return to start of file");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Reader_Dispose_Does_Not_Throw()
|
||||
{
|
||||
var reader = new FfmpegAudioReader(_inputPath);
|
||||
reader.Dispose();
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Reader_Can_Read_Flac_File()
|
||||
{
|
||||
// Arrange
|
||||
var baseDir = AppContext.BaseDirectory;
|
||||
var flacPath = Path.Combine(baseDir, "Data", "test_input_converted.flac");
|
||||
|
||||
try
|
||||
{
|
||||
// Convert MP3 → FLAC using FFmpeg
|
||||
var psi = new ProcessStartInfo
|
||||
{
|
||||
FileName = "ffmpeg",
|
||||
Arguments = $"-y -i \"{_inputPath}\" -compression_level 12 \"{flacPath}\"",
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
WindowStyle = ProcessWindowStyle.Hidden,
|
||||
};
|
||||
|
||||
using (var p = Process.Start(psi))
|
||||
{
|
||||
p!.WaitForExit();
|
||||
Assert.AreEqual(0, p.ExitCode, "FFmpeg failed to convert MP3 to FLAC");
|
||||
}
|
||||
|
||||
Assert.IsTrue(File.Exists(flacPath), "FLAC file was not created");
|
||||
|
||||
// Act
|
||||
using var reader = new FfmpegAudioReader(flacPath);
|
||||
|
||||
// Assert basic properties
|
||||
Assert.AreEqual(44100, reader.SampleRate);
|
||||
Assert.AreEqual(2, reader.Channels);
|
||||
Assert.IsGreaterThan(0, reader.TotalSamples);
|
||||
|
||||
// Read some samples
|
||||
var buf = new float[44100];
|
||||
var read = reader.Read(buf, 0, buf.Length);
|
||||
|
||||
Assert.IsGreaterThan(0, read, "FLAC reader returned no samples");
|
||||
Assert.IsLessThanOrEqualTo(buf.Length, read);
|
||||
|
||||
// Seek test
|
||||
reader.Seek(reader.SampleRate); // 1 second
|
||||
var buf2 = new float[44100];
|
||||
var read2 = reader.Read(buf2, 0, buf2.Length);
|
||||
|
||||
Assert.IsGreaterThan(0, read2);
|
||||
|
||||
// Buffers should differ after seek
|
||||
var identical = true;
|
||||
for (var i = 0; i < Math.Min(read, read2); i++)
|
||||
{
|
||||
if (buf[i] != buf2[i])
|
||||
{
|
||||
identical = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Assert.IsFalse(identical, "Seek did not change decoded FLAC samples");
|
||||
|
||||
// Reset test
|
||||
reader.Reset();
|
||||
var buf3 = new float[44100];
|
||||
var read3 = reader.Read(buf3, 0, buf3.Length);
|
||||
|
||||
Assert.IsGreaterThan(0, read3);
|
||||
|
||||
// After reset, buf3 should match buf
|
||||
var matchAfterReset = true;
|
||||
for (var i = 0; i < Math.Min(read, read3); i++)
|
||||
{
|
||||
if (buf[i] != buf3[i])
|
||||
{
|
||||
matchAfterReset = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Assert.IsTrue(matchAfterReset, "Reset did not return to start of FLAC file");
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Cleanup even if test fails
|
||||
try
|
||||
{
|
||||
if (File.Exists(flacPath))
|
||||
File.Delete(flacPath);
|
||||
}
|
||||
catch { /* swallow */ }
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
81
AudioCore_Tests/Htdemucs6sSeparator_Tests.cs
Normal file
81
AudioCore_Tests/Htdemucs6sSeparator_Tests.cs
Normal file
@ -0,0 +1,81 @@
|
||||
using AudioCore.Interfaces;
|
||||
using AudioCore.Impl;
|
||||
using AudioCore.Models;
|
||||
|
||||
namespace AudioCore_Tests;
|
||||
|
||||
[TestClass]
|
||||
public sealed class Htdemucs6sSeparator_Tests
|
||||
{
|
||||
private string _inputPath = null!;
|
||||
private string _outputDir = null!;
|
||||
|
||||
[TestInitialize]
|
||||
public void Init()
|
||||
{
|
||||
var baseDir = AppContext.BaseDirectory;
|
||||
|
||||
var modelPath = Path.Combine(baseDir, "Data", "htdemucs_6s.onnx");
|
||||
_inputPath = Path.Combine(baseDir, "Data", "test_input.mp3");
|
||||
|
||||
Assert.IsTrue(File.Exists(modelPath), "Model file missing");
|
||||
Assert.IsTrue(File.Exists(_inputPath), "Test input audio missing");
|
||||
|
||||
_outputDir = Path.Combine(baseDir, "TestOutput", Guid.NewGuid().ToString());
|
||||
Directory.CreateDirectory(_outputDir);
|
||||
}
|
||||
|
||||
[TestCleanup]
|
||||
public void Cleanup()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(_outputDir) && Directory.Exists(_outputDir))
|
||||
Directory.Delete(_outputDir, recursive: true);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Swallow exceptions so cleanup never breaks the test runner
|
||||
}
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task Htdemucs6sSeparator_Separates_6_Stems()
|
||||
{
|
||||
var separator = new Htdemucs6sSeparator();
|
||||
|
||||
var request = new StemSeparationRequest
|
||||
{
|
||||
SourceFilePath = _inputPath,
|
||||
OutputDirectory = _outputDir
|
||||
};
|
||||
|
||||
var progress = new TestProgressReporter();
|
||||
|
||||
var stemSet = await separator.SeparateAsync(request, progress);
|
||||
|
||||
Assert.HasCount(6, stemSet.Stems, "Expected 6 stems");
|
||||
|
||||
foreach (var stem in stemSet.Stems)
|
||||
{
|
||||
Assert.IsTrue(File.Exists(stem.FilePath), $"Missing stem file: {stem.FilePath}");
|
||||
Assert.AreEqual(44100, stem.SampleRate);
|
||||
Assert.AreEqual(2, stem.Channels);
|
||||
Assert.IsGreaterThan(0.1, stem.Duration.TotalSeconds, "Stem duration too small");
|
||||
}
|
||||
|
||||
Assert.IsGreaterThan(0, progress.Calls, "Progress reporter was never called");
|
||||
}
|
||||
|
||||
private sealed class TestProgressReporter : IProgressReporter<double>
|
||||
{
|
||||
public int Calls { get; private set; }
|
||||
|
||||
public Task ReportProgress(double progress, CancellationToken ct = default)
|
||||
{
|
||||
Calls++;
|
||||
Assert.IsTrue(progress >= 0.0 && progress <= 1.0);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
}
|
||||
1
AudioCore_Tests/MSTestSettings.cs
Normal file
1
AudioCore_Tests/MSTestSettings.cs
Normal file
@ -0,0 +1 @@
|
||||
[assembly: Parallelize(Scope = ExecutionScope.MethodLevel)]
|
||||
131
AudioCore_Tests/Pipeline_Integration_Tests.cs
Normal file
131
AudioCore_Tests/Pipeline_Integration_Tests.cs
Normal file
@ -0,0 +1,131 @@
|
||||
using System.Diagnostics;
|
||||
using System.Runtime.InteropServices;
|
||||
using AudioCore.Impl;
|
||||
using AudioCore.Models;
|
||||
|
||||
namespace AudioCore_Tests;
|
||||
|
||||
[TestClass]
|
||||
public sealed class Pipeline_Integration_Tests
|
||||
{
|
||||
private string _inputPath = null!;
|
||||
private string _outputDir = null!;
|
||||
|
||||
[TestInitialize]
|
||||
public void Init()
|
||||
{
|
||||
var baseDir = AppContext.BaseDirectory;
|
||||
|
||||
_inputPath = Path.Combine(baseDir, "Data", "test_input.mp3");
|
||||
Assert.IsTrue(File.Exists(_inputPath), "Missing test input");
|
||||
|
||||
_outputDir = Path.Combine(baseDir, "TestOutput", Guid.NewGuid().ToString());
|
||||
Directory.CreateDirectory(_outputDir);
|
||||
}
|
||||
|
||||
[TestCleanup]
|
||||
public void Cleanup()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (Directory.Exists(_outputDir))
|
||||
Directory.Delete(_outputDir, true);
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void FullPipeline_Decoder_Mixer_Encoder_Works()
|
||||
{
|
||||
var pool = new AudioBufferPool();
|
||||
var readerFactory = new FfmpegAudioReaderFactory();
|
||||
var decoderFactory = new StemDecoderFactory(readerFactory, pool);
|
||||
var mixer = new AudioMixer(pool);
|
||||
|
||||
var stems = new[]
|
||||
{
|
||||
new StemTrack { FilePath = _inputPath, Name = "stem1" },
|
||||
new StemTrack { FilePath = _inputPath, Name = "stem2" }
|
||||
};
|
||||
|
||||
var decoders = stems
|
||||
.Select(s => decoderFactory.Create(s))
|
||||
.ToList();
|
||||
|
||||
var settings = new MixerSettings
|
||||
{
|
||||
Stems = new[]
|
||||
{
|
||||
new StemMixSettings { Enabled = true, GainDb = 0, Pan = 0 },
|
||||
new StemMixSettings { Enabled = true, GainDb = -3, Pan = 0.2f }
|
||||
}
|
||||
};
|
||||
|
||||
var outFlac = Path.Combine(_outputDir, "mixed.flac");
|
||||
|
||||
var psi = new ProcessStartInfo
|
||||
{
|
||||
FileName = "ffmpeg",
|
||||
Arguments =
|
||||
"-y -f f32le -ar 44100 -ac 2 -i pipe:0 " +
|
||||
"-compression_level 12 " +
|
||||
$"\"{outFlac}\"",
|
||||
RedirectStandardInput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
WindowStyle = ProcessWindowStyle.Hidden,
|
||||
};
|
||||
|
||||
using var ff = Process.Start(psi);
|
||||
var stdin = ff!.StandardInput.BaseStream;
|
||||
|
||||
var running = true;
|
||||
|
||||
while (true)
|
||||
{
|
||||
var blocks = new List<AudioBlock>(decoders.Count);
|
||||
|
||||
foreach (var d in decoders)
|
||||
{
|
||||
if (!d.TryDecodeNextBlock(out var block))
|
||||
{
|
||||
foreach (var b in blocks)
|
||||
b.Dispose();
|
||||
|
||||
running = false;
|
||||
break;
|
||||
}
|
||||
|
||||
blocks.Add(block);
|
||||
}
|
||||
|
||||
if (!running)
|
||||
break;
|
||||
|
||||
var mixed = mixer.Mix(blocks, settings);
|
||||
|
||||
var span = mixed.Buffer.Span;
|
||||
var bytes = MemoryMarshal.AsBytes(span);
|
||||
stdin.Write(bytes);
|
||||
|
||||
mixed.Dispose();
|
||||
foreach (var b in blocks)
|
||||
b.Dispose();
|
||||
}
|
||||
|
||||
stdin.Close();
|
||||
ff.WaitForExit();
|
||||
|
||||
Assert.AreEqual(0, ff.ExitCode, "FFmpeg failed");
|
||||
|
||||
Assert.IsTrue(File.Exists(outFlac), "FLAC file was not created");
|
||||
Assert.IsGreaterThan(1024, new FileInfo(outFlac).Length, "FLAC file too small");
|
||||
|
||||
using var verify = new FfmpegAudioReader(outFlac);
|
||||
var buf = new float[4096];
|
||||
var read = verify.Read(buf, 0, buf.Length);
|
||||
|
||||
Assert.IsGreaterThan(0, read, "FLAC output is not decodable");
|
||||
}
|
||||
}
|
||||
148
AudioCore_Tests/StemDecoder_Tests.cs
Normal file
148
AudioCore_Tests/StemDecoder_Tests.cs
Normal file
@ -0,0 +1,148 @@
|
||||
using AudioCore.Impl;
|
||||
using AudioCore.Models;
|
||||
|
||||
namespace AudioCore_Tests;
|
||||
|
||||
[TestClass]
|
||||
public sealed class StemDecoder_Tests
|
||||
{
|
||||
[TestMethod]
|
||||
public void TryDecodeNextBlock_ReturnsBlock()
|
||||
{
|
||||
var pool = new AudioBufferPool();
|
||||
var samples = Enumerable.Range(0, 48000).Select(i => (float)i).ToArray(); // 1 sec stereo
|
||||
var reader = new FakeAudioReader(samples, 48000, 2);
|
||||
var stem = new StemTrack{ Name = "test", FilePath = "file.wav" };
|
||||
|
||||
var decoder = new StemDecoder(reader, pool, stem, blockSize: 1024);
|
||||
|
||||
var ok = decoder.TryDecodeNextBlock(out var block);
|
||||
|
||||
Assert.IsTrue(ok);
|
||||
Assert.AreEqual(1024, block.Frames);
|
||||
Assert.AreEqual(0, block.Position);
|
||||
Assert.AreEqual(48000, block.SampleRate);
|
||||
Assert.AreEqual(2, block.Channels);
|
||||
|
||||
block.Dispose();
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void TryDecodeNextBlock_AdvancesPosition()
|
||||
{
|
||||
var pool = new AudioBufferPool();
|
||||
var samples = Enumerable.Range(0, 48000).Select(i => (float)i).ToArray();
|
||||
var reader = new FakeAudioReader(samples);
|
||||
var stem = new StemTrack{ Name = "test", FilePath = "file.wav" };
|
||||
|
||||
var decoder = new StemDecoder(reader, pool, stem, blockSize: 1000);
|
||||
|
||||
decoder.TryDecodeNextBlock(out var b1);
|
||||
decoder.TryDecodeNextBlock(out var b2);
|
||||
|
||||
Assert.AreEqual(0, b1.Position);
|
||||
Assert.AreEqual(1000, b2.Position);
|
||||
|
||||
b1.Dispose();
|
||||
b2.Dispose();
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Seek_MovesReaderAndDecoderPosition()
|
||||
{
|
||||
var pool = new AudioBufferPool();
|
||||
var samples = Enumerable.Range(0, 48000).Select(i => (float)i).ToArray();
|
||||
var reader = new FakeAudioReader(samples);
|
||||
var stem = new StemTrack{ Name = "test", FilePath = "file.wav" };
|
||||
|
||||
var decoder = new StemDecoder(reader, pool, stem, blockSize: 500);
|
||||
|
||||
decoder.Seek(2000); // sample position
|
||||
|
||||
decoder.TryDecodeNextBlock(out var block);
|
||||
|
||||
Assert.AreEqual(2000, block.Position);
|
||||
Assert.AreEqual(500, block.Frames);
|
||||
|
||||
block.Dispose();
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Reset_ReturnsToStart()
|
||||
{
|
||||
var pool = new AudioBufferPool();
|
||||
var samples = Enumerable.Range(0, 48000).Select(i => (float)i).ToArray();
|
||||
var reader = new FakeAudioReader(samples);
|
||||
var stem = new StemTrack{ Name = "test", FilePath = "file.wav" };
|
||||
|
||||
var decoder = new StemDecoder(reader, pool, stem, blockSize: 500);
|
||||
|
||||
decoder.TryDecodeNextBlock(out var b1);
|
||||
decoder.Reset();
|
||||
decoder.TryDecodeNextBlock(out var b2);
|
||||
|
||||
Assert.AreEqual(0, b2.Position);
|
||||
|
||||
b1.Dispose();
|
||||
b2.Dispose();
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void TryDecodeNextBlock_ReturnsFalseAtEnd()
|
||||
{
|
||||
var pool = new AudioBufferPool();
|
||||
var samples = new float[2000]; // small buffer
|
||||
var reader = new FakeAudioReader(samples, 48000, 2);
|
||||
var stem = new StemTrack { Name = "test", FilePath = "file.wav" };
|
||||
|
||||
var decoder = new StemDecoder(reader, pool, stem, blockSize: 1024);
|
||||
|
||||
// First block: should succeed
|
||||
Assert.IsTrue(decoder.TryDecodeNextBlock(out var b1));
|
||||
Assert.IsNotNull(b1.Buffer);
|
||||
b1.Dispose();
|
||||
|
||||
// Second block: may succeed or partially succeed
|
||||
decoder.TryDecodeNextBlock(out var b2);
|
||||
if (b2.Buffer != null)
|
||||
b2.Dispose();
|
||||
|
||||
// Third block: MUST fail
|
||||
var ok = decoder.TryDecodeNextBlock(out var b3);
|
||||
|
||||
Assert.IsFalse(ok, "Decoder should return false at end of stream");
|
||||
|
||||
// IMPORTANT: do NOT touch b3.Buffer — it is null
|
||||
}
|
||||
|
||||
|
||||
[TestMethod]
|
||||
public void Dispose_DisposesReader()
|
||||
{
|
||||
var pool = new AudioBufferPool();
|
||||
var samples = new float[1000];
|
||||
var reader = new FakeAudioReader(samples);
|
||||
var stem = new StemTrack{ Name = "test", FilePath = "file.wav" };
|
||||
|
||||
var decoder = new StemDecoder(reader, pool, stem);
|
||||
|
||||
decoder.Dispose();
|
||||
|
||||
try
|
||||
{
|
||||
// This must throw
|
||||
reader.Read(new float[10], 0, 10);
|
||||
Assert.Fail("Expected ObjectDisposedException");
|
||||
}
|
||||
catch(AssertFailedException )
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Assert.IsInstanceOfType(ex, typeof(ObjectDisposedException));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
343
AudioCore_Tests/StemPlaybackEngine_Tests.cs
Normal file
343
AudioCore_Tests/StemPlaybackEngine_Tests.cs
Normal file
@ -0,0 +1,343 @@
|
||||
using AudioCore.Interfaces;
|
||||
using AudioCore.Models;
|
||||
using AudioCore.Impl;
|
||||
|
||||
namespace AudioCore_Tests;
|
||||
|
||||
[TestClass]
|
||||
public sealed class StemPlaybackEngine_Tests
|
||||
{
|
||||
private sealed class MockDecoder : IStemDecoder
|
||||
{
|
||||
private readonly Queue<AudioBlock> _blocks;
|
||||
private readonly AudioBufferPool _pool;
|
||||
private long _seekPosition;
|
||||
|
||||
public StemTrack Stem { get; }
|
||||
|
||||
public MockDecoder(int framesPerBlock, int blocks, AudioBufferPool pool)
|
||||
{
|
||||
_pool = pool;
|
||||
Stem = new StemTrack
|
||||
{
|
||||
Channels = 2,
|
||||
SampleRate = 44100,
|
||||
Duration = TimeSpan.FromSeconds(10),
|
||||
FilePath = "x"
|
||||
};
|
||||
|
||||
_blocks = new Queue<AudioBlock>();
|
||||
|
||||
long pos = 0;
|
||||
for (var i = 0; i < blocks; i++)
|
||||
{
|
||||
var buf = _pool.Rent(framesPerBlock * Stem.Channels);
|
||||
buf.Length = framesPerBlock * Stem.Channels;
|
||||
_blocks.Enqueue(new AudioBlock(buf, Stem.SampleRate, Stem.Channels, pos));
|
||||
pos += framesPerBlock;
|
||||
}
|
||||
}
|
||||
|
||||
public bool TryDecodeNextBlock(out AudioBlock block)
|
||||
{
|
||||
if (_blocks.Count == 0)
|
||||
{
|
||||
block = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
block = _blocks.Dequeue();
|
||||
return true;
|
||||
}
|
||||
|
||||
public void Seek(long samplePosition)
|
||||
{
|
||||
_seekPosition = samplePosition;
|
||||
}
|
||||
|
||||
public void Reset()
|
||||
{
|
||||
_seekPosition = 0;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class MockDecoderFactory : IStemDecoderFactory
|
||||
{
|
||||
private readonly AudioBufferPool _pool;
|
||||
private readonly int _frames;
|
||||
private readonly int _blocks;
|
||||
|
||||
public MockDecoderFactory(AudioBufferPool pool, int frames, int blocks)
|
||||
{
|
||||
_pool = pool;
|
||||
_frames = frames;
|
||||
_blocks = blocks;
|
||||
}
|
||||
|
||||
public IStemDecoder Create(StemTrack stem)
|
||||
{
|
||||
return new MockDecoder(_frames, _blocks, _pool);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class MockMixer(AudioBufferPool _pool) : IAudioMixer
|
||||
{
|
||||
public MixedAudioBlock Mix(IReadOnlyList<AudioBlock> stemBlocks, MixerSettings settings)
|
||||
{
|
||||
var first = stemBlocks[0];
|
||||
var buf = _pool.Rent(first.Length);
|
||||
Array.Copy(first.Buffer.Samples, buf.Samples, first.Length);
|
||||
|
||||
return new MixedAudioBlock(
|
||||
buf,
|
||||
first.Frames,
|
||||
first.Channels,
|
||||
first.SampleRate,
|
||||
first.Position);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class MockTimeStretch : ITimeStretchEngine
|
||||
{
|
||||
public void Configure(PlaybackSpeedSettings settings)
|
||||
{
|
||||
}
|
||||
|
||||
public TimeStretchedAudioBlock Process(MixedAudioBlock input)
|
||||
{
|
||||
return new TimeStretchedAudioBlock(
|
||||
input.Buffer,
|
||||
input.Frames,
|
||||
input.Channels,
|
||||
input.SampleRate);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class MockOutput : IAudioOutputDevice
|
||||
{
|
||||
public int SampleRate => 44100;
|
||||
public int Channels => 2;
|
||||
|
||||
public int WriteCount { get; private set; }
|
||||
public int LastWriteSamples { get; private set; }
|
||||
public bool Started { get; private set; }
|
||||
|
||||
public void Start()
|
||||
{
|
||||
Started = true;
|
||||
}
|
||||
|
||||
public void Stop()
|
||||
{
|
||||
Started = false;
|
||||
}
|
||||
|
||||
public void Write(ReadOnlySpan<float> samples)
|
||||
{
|
||||
WriteCount++;
|
||||
LastWriteSamples = samples.Length;
|
||||
}
|
||||
}
|
||||
|
||||
private PlaybackSession CreateSession(int stems)
|
||||
{
|
||||
var stemList = new List<StemTrack>();
|
||||
var mixList = new List<StemMixSettings>();
|
||||
|
||||
for (var i = 0; i < stems; i++)
|
||||
{
|
||||
stemList.Add(new StemTrack
|
||||
{
|
||||
Channels = 2,
|
||||
SampleRate = 44100,
|
||||
Duration = TimeSpan.FromSeconds(10),
|
||||
FilePath = "x"
|
||||
});
|
||||
|
||||
mixList.Add(new StemMixSettings
|
||||
{
|
||||
Enabled = true,
|
||||
GainDb = 0,
|
||||
Pan = 0
|
||||
});
|
||||
}
|
||||
|
||||
return new PlaybackSession
|
||||
{
|
||||
StemSet = new StemSet
|
||||
{
|
||||
OriginalFilePath = "x",
|
||||
Stems = stemList
|
||||
},
|
||||
Mixer = new MixerSettings
|
||||
{
|
||||
Stems = mixList
|
||||
},
|
||||
Loop = new LoopRegion
|
||||
{
|
||||
IsEnabled = false
|
||||
},
|
||||
Speed = new PlaybackSpeedSettings
|
||||
{
|
||||
Speed = 1.0f
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private class DummyProgressReporter : IProgressReporter<TimeSpan>
|
||||
{
|
||||
public Task ReportProgress(TimeSpan value, CancellationToken ct)
|
||||
=> Task.CompletedTask;
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task LoadSession_InitializesDecoders()
|
||||
{
|
||||
var pool = new AudioBufferPool();
|
||||
var decoderFactory = new MockDecoderFactory(pool, 1024, 5);
|
||||
var output = new MockOutput();
|
||||
var mixer = new MockMixer(pool);
|
||||
var stretch = new MockTimeStretch();
|
||||
|
||||
var engine = new StemPlaybackEngine(decoderFactory, output, mixer, stretch);
|
||||
|
||||
var session = CreateSession(3);
|
||||
await engine.LoadSessionAsync(session, new DummyProgressReporter());
|
||||
|
||||
Assert.IsFalse(output.Started);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task PlayAsync_StartsOutputDevice()
|
||||
{
|
||||
var pool = new AudioBufferPool();
|
||||
var decoderFactory = new MockDecoderFactory(pool, 1024, 5);
|
||||
var output = new MockOutput();
|
||||
var mixer = new MockMixer(pool);
|
||||
var stretch = new MockTimeStretch();
|
||||
|
||||
var engine = new StemPlaybackEngine(decoderFactory, output, mixer, stretch);
|
||||
|
||||
var session = CreateSession(2);
|
||||
await engine.LoadSessionAsync(session, new DummyProgressReporter());
|
||||
|
||||
await engine.PlayAsync();
|
||||
|
||||
Assert.IsTrue(output.Started);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task PauseAsync_StopsOutputDevice()
|
||||
{
|
||||
var pool = new AudioBufferPool();
|
||||
var decoderFactory = new MockDecoderFactory(pool, 1024, 5);
|
||||
var output = new MockOutput();
|
||||
var mixer = new MockMixer(pool);
|
||||
var stretch = new MockTimeStretch();
|
||||
|
||||
var engine = new StemPlaybackEngine(decoderFactory, output, mixer, stretch);
|
||||
|
||||
var session = CreateSession(2);
|
||||
await engine.LoadSessionAsync(session, new DummyProgressReporter());
|
||||
|
||||
await engine.PlayAsync();
|
||||
await engine.PauseAsync();
|
||||
|
||||
Assert.IsFalse(output.Started);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task RenderLoop_WritesAudioBlocks()
|
||||
{
|
||||
var pool = new AudioBufferPool();
|
||||
var decoderFactory = new MockDecoderFactory(pool, 1024, 3);
|
||||
var output = new MockOutput();
|
||||
var mixer = new MockMixer(pool);
|
||||
var stretch = new MockTimeStretch();
|
||||
|
||||
var engine = new StemPlaybackEngine(decoderFactory, output, mixer, stretch);
|
||||
|
||||
var session = CreateSession(1);
|
||||
await engine.LoadSessionAsync(session, new DummyProgressReporter());
|
||||
|
||||
await engine.PlayAsync();
|
||||
|
||||
await Task.Delay(50);
|
||||
|
||||
await engine.StopAsync();
|
||||
|
||||
Assert.IsGreaterThan(0, output.WriteCount);
|
||||
Assert.AreEqual(1024 * 2, output.LastWriteSamples);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task SeekAsync_MovesDecoders()
|
||||
{
|
||||
var pool = new AudioBufferPool();
|
||||
var decoderFactory = new MockDecoderFactory(pool, 1024, 5);
|
||||
var output = new MockOutput();
|
||||
var mixer = new MockMixer(pool);
|
||||
var stretch = new MockTimeStretch();
|
||||
|
||||
var engine = new StemPlaybackEngine(decoderFactory, output, mixer, stretch);
|
||||
|
||||
var session = CreateSession(1);
|
||||
await engine.LoadSessionAsync(session, new DummyProgressReporter());
|
||||
|
||||
await engine.SeekAsync(TimeSpan.FromSeconds(2));
|
||||
|
||||
Assert.IsFalse(output.Started);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task LoopRegion_SeeksBackOnBoundary()
|
||||
{
|
||||
var pool = new AudioBufferPool();
|
||||
var decoderFactory = new MockDecoderFactory(pool, 1024, 5);
|
||||
var output = new MockOutput();
|
||||
var mixer = new MockMixer(pool);
|
||||
var stretch = new MockTimeStretch();
|
||||
|
||||
var engine = new StemPlaybackEngine(decoderFactory, output, mixer, stretch);
|
||||
|
||||
var session = CreateSession(1);
|
||||
session.Loop = new LoopRegion
|
||||
{
|
||||
IsEnabled = true,
|
||||
Start = TimeSpan.FromSeconds(0),
|
||||
End = TimeSpan.FromSeconds(1)
|
||||
};
|
||||
|
||||
await engine.LoadSessionAsync(session, new DummyProgressReporter());
|
||||
await engine.PlayAsync();
|
||||
|
||||
await Task.Delay(50);
|
||||
|
||||
await engine.StopAsync();
|
||||
|
||||
Assert.IsGreaterThan(0, output.WriteCount);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task SpeedChange_ReconfiguresTimeStretch()
|
||||
{
|
||||
var pool = new AudioBufferPool();
|
||||
var decoderFactory = new MockDecoderFactory(pool, 1024, 5);
|
||||
var output = new MockOutput();
|
||||
var mixer = new MockMixer(pool);
|
||||
var stretch = new MockTimeStretch();
|
||||
|
||||
var engine = new StemPlaybackEngine(decoderFactory, output, mixer, stretch);
|
||||
|
||||
var session = CreateSession(1);
|
||||
await engine.LoadSessionAsync(session, new DummyProgressReporter());
|
||||
|
||||
engine.SetSpeed(1.5);
|
||||
|
||||
Assert.IsFalse(output.Started);
|
||||
}
|
||||
}
|
||||
117
AudioCore_Tests/StemWaveformService_RealDecoder_Tests.cs
Normal file
117
AudioCore_Tests/StemWaveformService_RealDecoder_Tests.cs
Normal file
@ -0,0 +1,117 @@
|
||||
using AudioCore.Models;
|
||||
using AudioCore.Impl;
|
||||
|
||||
namespace AudioCore_Tests;
|
||||
|
||||
[TestClass]
|
||||
public sealed class StemWaveformService_RealDecoder_Tests
|
||||
{
|
||||
private string GetTestInputPath()
|
||||
{
|
||||
var baseDir = AppDomain.CurrentDomain.BaseDirectory;
|
||||
return Path.Combine(baseDir, "Data", "test_input.mp3");
|
||||
}
|
||||
|
||||
private StemTrack CreateStem(string path)
|
||||
{
|
||||
return new StemTrack
|
||||
{
|
||||
FilePath = path,
|
||||
SampleRate = 44100,
|
||||
Channels = 2,
|
||||
Duration = TimeSpan.FromSeconds(10),
|
||||
Name = "TestStem",
|
||||
Type = StemType.Other
|
||||
};
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void TestInputFileExists()
|
||||
{
|
||||
var path = GetTestInputPath();
|
||||
Assert.IsTrue(File.Exists(path));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task ComputeWaveform_RealDecoder_ReturnsCorrectLength()
|
||||
{
|
||||
var path = GetTestInputPath();
|
||||
var pool = new AudioBufferPool();
|
||||
|
||||
var reader = new FfmpegAudioReader(path);
|
||||
var stem = CreateStem(path);
|
||||
|
||||
using var decoder = new StemDecoder(reader, pool, stem, blockSize: 4096);
|
||||
|
||||
var service = new StemWaveformService(pool);
|
||||
|
||||
int segments = 20;
|
||||
var result = await service.ComputeWaveformAsync(stem, decoder, segments);
|
||||
|
||||
Assert.HasCount(segments, result);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task ComputeWaveform_RealDecoder_ProducesNonZeroValues()
|
||||
{
|
||||
var path = GetTestInputPath();
|
||||
var pool = new AudioBufferPool();
|
||||
|
||||
var reader = new FfmpegAudioReader(path);
|
||||
var stem = CreateStem(path);
|
||||
|
||||
using var decoder = new StemDecoder(reader, pool, stem, blockSize: 4096);
|
||||
|
||||
var service = new StemWaveformService(pool);
|
||||
|
||||
var result = await service.ComputeWaveformAsync(stem, decoder, 10);
|
||||
|
||||
bool anyNonZero = false;
|
||||
foreach (var v in result)
|
||||
{
|
||||
if (v > 0f)
|
||||
{
|
||||
anyNonZero = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Assert.IsTrue(anyNonZero);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task ComputeWaveform_RealDecoder_CallsSeekAndReset()
|
||||
{
|
||||
var path = GetTestInputPath();
|
||||
var pool = new AudioBufferPool();
|
||||
|
||||
var reader = new FfmpegAudioReader(path);
|
||||
var stem = CreateStem(path);
|
||||
|
||||
using var decoder = new StemDecoder(reader, pool, stem, blockSize: 4096);
|
||||
|
||||
var service = new StemWaveformService(pool);
|
||||
|
||||
var result = await service.ComputeWaveformAsync(stem, decoder, 5);
|
||||
|
||||
Assert.HasCount(5, result);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task ComputeWaveform_RealDecoder_HandlesLargeSegmentCount()
|
||||
{
|
||||
var path = GetTestInputPath();
|
||||
var pool = new AudioBufferPool();
|
||||
|
||||
var reader = new FfmpegAudioReader(path);
|
||||
var stem = CreateStem(path);
|
||||
|
||||
using var decoder = new StemDecoder(reader, pool, stem, blockSize: 4096);
|
||||
|
||||
var service = new StemWaveformService(pool);
|
||||
|
||||
var result = await service.ComputeWaveformAsync(stem, decoder, 200);
|
||||
|
||||
Assert.HasCount(200, result);
|
||||
}
|
||||
}
|
||||
167
AudioCore_Tests/StemWaveformService_Tests.cs
Normal file
167
AudioCore_Tests/StemWaveformService_Tests.cs
Normal file
@ -0,0 +1,167 @@
|
||||
using AudioCore.Interfaces;
|
||||
using AudioCore.Models;
|
||||
using AudioCore.Impl;
|
||||
|
||||
namespace AudioCore_Tests;
|
||||
|
||||
[TestClass]
|
||||
public sealed class StemWaveformService_Tests
|
||||
{
|
||||
private sealed class MockDecoder : IStemDecoder
|
||||
{
|
||||
private readonly Queue<AudioBlock> _blocks;
|
||||
private readonly AudioBufferPool _pool;
|
||||
|
||||
public StemTrack Stem { get; }
|
||||
|
||||
public long LastSeek { get; private set; }
|
||||
public bool ResetCalled { get; private set; }
|
||||
|
||||
public MockDecoder(AudioBufferPool pool, int framesPerBlock, int blocks)
|
||||
{
|
||||
_pool = pool;
|
||||
|
||||
Stem = new StemTrack
|
||||
{
|
||||
Channels = 2,
|
||||
SampleRate = 44100,
|
||||
Duration = TimeSpan.FromSeconds(10),
|
||||
FilePath = "x"
|
||||
};
|
||||
|
||||
_blocks = new Queue<AudioBlock>();
|
||||
|
||||
long pos = 0;
|
||||
for (int i = 0; i < blocks; i++)
|
||||
{
|
||||
var buf = _pool.Rent(framesPerBlock * Stem.Channels);
|
||||
buf.Length = framesPerBlock * Stem.Channels;
|
||||
|
||||
var span = buf.Span;
|
||||
for (int s = 0; s < span.Length; s++)
|
||||
{
|
||||
span[s] = (float)(s % 100) / 100f;
|
||||
}
|
||||
|
||||
_blocks.Enqueue(new AudioBlock(buf, Stem.SampleRate, Stem.Channels, pos));
|
||||
pos += framesPerBlock;
|
||||
}
|
||||
}
|
||||
|
||||
public bool TryDecodeNextBlock(out AudioBlock block)
|
||||
{
|
||||
if (_blocks.Count == 0)
|
||||
{
|
||||
block = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
block = _blocks.Dequeue();
|
||||
return true;
|
||||
}
|
||||
|
||||
public void Seek(long samplePosition)
|
||||
{
|
||||
LastSeek = samplePosition;
|
||||
}
|
||||
|
||||
public void Reset()
|
||||
{
|
||||
ResetCalled = true;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
private string GetTestInputPath()
|
||||
{
|
||||
var baseDir = AppDomain.CurrentDomain.BaseDirectory;
|
||||
return Path.Combine(baseDir, "Data", "test_input.mp3");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task ComputeWaveform_ReturnsCorrectLength()
|
||||
{
|
||||
var pool = new AudioBufferPool();
|
||||
var decoder = new MockDecoder(pool, framesPerBlock: 1024, blocks: 5);
|
||||
|
||||
var service = new StemWaveformService(pool);
|
||||
|
||||
int segments = 10;
|
||||
var result = await service.ComputeWaveformAsync(new StemTrack(), decoder, segments);
|
||||
|
||||
Assert.HasCount(segments, result);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task ComputeWaveform_CallsResetAndSeek()
|
||||
{
|
||||
var pool = new AudioBufferPool();
|
||||
var decoder = new MockDecoder(pool, framesPerBlock: 1024, blocks: 5);
|
||||
|
||||
var service = new StemWaveformService(pool);
|
||||
|
||||
var result = await service.ComputeWaveformAsync(new StemTrack(), decoder, 5);
|
||||
|
||||
Assert.IsTrue(decoder.ResetCalled);
|
||||
Assert.IsGreaterThanOrEqualTo(0, decoder.LastSeek);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task ComputeWaveform_ComputesNonZeroValues()
|
||||
{
|
||||
var pool = new AudioBufferPool();
|
||||
var decoder = new MockDecoder(pool, framesPerBlock: 1024, blocks: 5);
|
||||
|
||||
var service = new StemWaveformService(pool);
|
||||
|
||||
var result = await service.ComputeWaveformAsync(new StemTrack(), decoder, 5);
|
||||
|
||||
bool anyNonZero = false;
|
||||
foreach (var v in result)
|
||||
{
|
||||
if (v > 0f)
|
||||
{
|
||||
anyNonZero = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Assert.IsTrue(anyNonZero);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task ComputeWaveform_HandlesZeroSegments()
|
||||
{
|
||||
var pool = new AudioBufferPool();
|
||||
var decoder = new MockDecoder(pool, framesPerBlock: 1024, blocks: 5);
|
||||
|
||||
var service = new StemWaveformService(pool);
|
||||
|
||||
var result = await service.ComputeWaveformAsync(new StemTrack(), decoder, 0);
|
||||
|
||||
Assert.IsEmpty(result);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task ComputeWaveform_HandlesEOF()
|
||||
{
|
||||
var pool = new AudioBufferPool();
|
||||
var decoder = new MockDecoder(pool, framesPerBlock: 1024, blocks: 1);
|
||||
|
||||
var service = new StemWaveformService(pool);
|
||||
|
||||
var result = await service.ComputeWaveformAsync(new StemTrack(), decoder, 5);
|
||||
|
||||
Assert.HasCount(5, result);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void TestInputFileExists()
|
||||
{
|
||||
var path = GetTestInputPath();
|
||||
Assert.IsTrue(File.Exists(path));
|
||||
}
|
||||
}
|
||||
130
AudioCore_Tests/TimeStretchEngine_Tests.cs
Normal file
130
AudioCore_Tests/TimeStretchEngine_Tests.cs
Normal file
@ -0,0 +1,130 @@
|
||||
using System.Diagnostics;
|
||||
using AudioCore.Impl;
|
||||
using AudioCore.Interfaces;
|
||||
using AudioCore.Models;
|
||||
|
||||
namespace AudioCore_Tests;
|
||||
|
||||
[TestClass]
|
||||
public sealed class TimeStretchEngine_Tests
|
||||
{
|
||||
private AudioBufferPool _pool = null!;
|
||||
|
||||
[TestInitialize]
|
||||
public void Init()
|
||||
{
|
||||
_pool = new AudioBufferPool();
|
||||
}
|
||||
|
||||
private MixedAudioBlock MakeBlock(int frames, int channels = 2, int sampleRate = 44100)
|
||||
{
|
||||
var buf = _pool.Rent(frames * channels);
|
||||
buf.Length = frames * channels;
|
||||
|
||||
// Fill with deterministic ramp
|
||||
for (var i = 0; i < buf.Length; i++)
|
||||
buf.Samples[i] = i * 0.001f;
|
||||
|
||||
return new MixedAudioBlock(buf, frames, channels, sampleRate, 0);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Process_Returns_Output_For_Speed_1()
|
||||
{
|
||||
using var engine = new RubberBandTimeStretchEngine(_pool, 44100, 2);
|
||||
|
||||
var input = MakeBlock(5000);
|
||||
var output = engine.Process(input);
|
||||
|
||||
Assert.IsGreaterThan(0, output.Frames, "No frames returned");
|
||||
Assert.AreEqual(2, output.Channels);
|
||||
Assert.AreEqual(44100, output.SampleRate);
|
||||
|
||||
// Output should be roughly same size at speed 1.0
|
||||
Assert.IsTrue(output.Frames >= 1000 && output.Frames <= 1300);
|
||||
|
||||
// Validate PCM
|
||||
foreach (var f in output.Buffer.Span)
|
||||
{
|
||||
Assert.IsFalse(float.IsNaN(f));
|
||||
Assert.IsFalse(float.IsInfinity(f));
|
||||
}
|
||||
|
||||
input.Dispose();
|
||||
output.Dispose();
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Process_Respects_Speed_Change()
|
||||
{
|
||||
using var engine = new RubberBandTimeStretchEngine(_pool, 44100, 2);
|
||||
var input = MakeBlock(25000);
|
||||
|
||||
// Let the engine and FFmpeg warm up with a few calls
|
||||
for (var i = 0; i < 5; i++)
|
||||
_ = engine.Process(input);
|
||||
|
||||
var normal = engine.Process(input);
|
||||
var normalFrames = normal.Frames;
|
||||
|
||||
engine.Configure(new PlaybackSpeedSettings { Speed = 1.5f });
|
||||
|
||||
for (var i = 0; i < 5; i++)
|
||||
_ = engine.Process(input);
|
||||
|
||||
var faster = engine.Process(input);
|
||||
|
||||
// Don’t insist on > 0; insist on “not more than”
|
||||
Assert.IsLessThanOrEqualTo(normalFrames,
|
||||
faster.Frames, $"Speed 1.5 should not increase frame count (normal={normalFrames}, faster={faster.Frames})");
|
||||
|
||||
input.Dispose();
|
||||
normal.Dispose();
|
||||
faster.Dispose();
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
[TestMethod]
|
||||
public void Engine_Restarts_On_Speed_Change()
|
||||
{
|
||||
using var engine = new RubberBandTimeStretchEngine(_pool, 44100, 2);
|
||||
|
||||
var input = MakeBlock(100);
|
||||
|
||||
var before = engine.Process(input);
|
||||
|
||||
engine.Configure(new PlaybackSpeedSettings { Speed = 0.75f });
|
||||
|
||||
var after = engine.Process(input);
|
||||
|
||||
// After restart, RubberBand has no buffered audio yet → zero frames expected
|
||||
Assert.AreEqual(0, after.Frames, "First block after restart must produce zero frames");
|
||||
}
|
||||
|
||||
|
||||
[TestMethod]
|
||||
public void Dispose_Kills_FFmpeg()
|
||||
{
|
||||
var engine = new RubberBandTimeStretchEngine(_pool, 44100, 2);
|
||||
|
||||
// Capture FFmpeg PID
|
||||
var ffField = typeof(RubberBandTimeStretchEngine)
|
||||
.GetField("_ff", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
|
||||
|
||||
var ff = (Process)ffField!.GetValue(engine)!;
|
||||
var pid = ff.Id;
|
||||
|
||||
engine.Dispose();
|
||||
|
||||
// Process should be gone
|
||||
var exists = Process.GetProcesses().Any(p =>
|
||||
{
|
||||
try { return p.Id == pid; }
|
||||
catch { return false; }
|
||||
});
|
||||
|
||||
Assert.IsFalse(exists, "FFmpeg process was not terminated");
|
||||
}
|
||||
}
|
||||
75
AudioCore_Tests/WasapiOutputDevice_Tests.cs
Normal file
75
AudioCore_Tests/WasapiOutputDevice_Tests.cs
Normal file
@ -0,0 +1,75 @@
|
||||
using System.Runtime.InteropServices;
|
||||
using NAudio.Wave;
|
||||
using AudioCore.Impl;
|
||||
|
||||
namespace AudioCore_Tests;
|
||||
|
||||
[TestClass]
|
||||
public sealed class WasapiOutputDevice_Tests
|
||||
{
|
||||
[TestMethod]
|
||||
public void Constructor_Sets_Properties()
|
||||
{
|
||||
var fake = new FakeWasapiOut();
|
||||
var dev = new WasapiOutputDevice(new ByteBufferPool(), fake, 48000, 1);
|
||||
|
||||
Assert.AreEqual(48000, dev.SampleRate);
|
||||
Assert.AreEqual(1, dev.Channels);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Start_Calls_Play()
|
||||
{
|
||||
var fake = new FakeWasapiOut();
|
||||
var dev = new WasapiOutputDevice(new ByteBufferPool(), fake);
|
||||
|
||||
dev.Start();
|
||||
|
||||
Assert.IsTrue(fake.Played);
|
||||
Assert.AreEqual(PlaybackState.Playing, fake.PlaybackState);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Stop_Calls_Stop()
|
||||
{
|
||||
var fake = new FakeWasapiOut();
|
||||
var dev = new WasapiOutputDevice(new ByteBufferPool(), fake);
|
||||
|
||||
dev.Start();
|
||||
dev.Stop();
|
||||
|
||||
Assert.IsTrue(fake.Stopped);
|
||||
Assert.AreEqual(PlaybackState.Stopped, fake.PlaybackState);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Write_Adds_Bytes_To_Buffer()
|
||||
{
|
||||
var fake = new FakeWasapiOut();
|
||||
var dev = new WasapiOutputDevice(new ByteBufferPool(), fake);
|
||||
|
||||
float[] samples = { 1f, -1f, 0.5f, -0.5f };
|
||||
dev.Write(samples);
|
||||
|
||||
var buffer = dev.Buffer;
|
||||
|
||||
var outBytes = new byte[buffer.BufferedBytes];
|
||||
buffer.Read(outBytes, 0, outBytes.Length);
|
||||
|
||||
// Convert back to float32
|
||||
var decoded = MemoryMarshal.Cast<byte, float>(outBytes).ToArray();
|
||||
|
||||
CollectionAssert.AreEqual(samples, decoded);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Dispose_Disposes_Output()
|
||||
{
|
||||
var fake = new FakeWasapiOut();
|
||||
var dev = new WasapiOutputDevice(new ByteBufferPool(), fake);
|
||||
|
||||
dev.Dispose();
|
||||
|
||||
Assert.IsTrue(fake.Disposed);
|
||||
}
|
||||
}
|
||||
10
Directory.Build.props
Normal file
10
Directory.Build.props
Normal file
@ -0,0 +1,10 @@
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<PlatformTarget>x64</PlatformTarget>
|
||||
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
21
Directory.Packages.props
Normal file
21
Directory.Packages.props
Normal file
@ -0,0 +1,21 @@
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageVersion Include="Avalonia" Version="12.0.3" />
|
||||
<PackageVersion Include="Avalonia.Desktop" Version="12.0.3" />
|
||||
<PackageVersion Include="Avalonia.Themes.Fluent" Version="12.0.3" />
|
||||
<PackageVersion Include="Avalonia.Fonts.Inter" Version="12.0.3" />
|
||||
<PackageVersion Include="AvaloniaUI.DiagnosticsSupport" Version="2.2.1"/>
|
||||
<PackageVersion Include="BunLabs.NAudio.Flac" Version="2.0.1" />
|
||||
<PackageVersion Include="CommunityToolkit.Mvvm" Version="8.4.1" />
|
||||
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.9" />
|
||||
<PackageVersion Include="Microsoft.ML.OnnxRuntime" Version="1.27.0" />
|
||||
<PackageVersion Include="Microsoft.ML.OnnxRuntime.DirectML" Version="1.24.4" />
|
||||
<PackageVersion Include="NAudio" Version="2.3.0" />
|
||||
<PackageVersion Include="NAudio.Flac" Version="1.0.5702.29018" />
|
||||
<PackageVersion Include="System.Text.Json" Version="10.0.5" />
|
||||
<PackageVersion Include="MSTest" Version="4.0.2" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
21
LICENSE.txt
Normal file
21
LICENSE.txt
Normal file
@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 Alexander Shabarshov
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
Loading…
x
Reference in New Issue
Block a user