Add project files.

This commit is contained in:
Alexander Shabarshov 2026-07-04 12:20:15 +01:00
commit ea45bef297
85 changed files with 5457 additions and 0 deletions

View 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
View 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
View 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
View 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>

View 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
View 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
View 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();
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 172 KiB

View 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;
}

View 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();
}
}

View 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();
}
}

View 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
View 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;
}
}

View 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;
}
}

View 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));
}
}
}

View 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);
// Threadsafe 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);
}
}

View 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;
}

View 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;
}

View 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;
}
}

View File

@ -0,0 +1,5 @@
namespace ABStemPlayer.ViewModels;
public abstract class ViewModelBase : ObservableObject
{
}

View 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;
}
}

View 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>

View File

@ -0,0 +1,11 @@
using Avalonia.Controls;
namespace ABStemPlayer.Views;
public partial class MainWindow : Window
{
public MainWindow()
{
InitializeComponent();
}
}

View 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>

View File

@ -0,0 +1,11 @@
using Avalonia.Controls;
namespace ABStemPlayer.Views;
public partial class MixerControl : UserControl
{
public MixerControl()
{
InitializeComponent();
}
}

View 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>

View File

@ -0,0 +1,11 @@
using Avalonia.Controls;
namespace ABStemPlayer.Views;
public partial class PlaybackControls : UserControl
{
public PlaybackControls()
{
InitializeComponent();
}
}

View 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>

View 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
View 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>

View 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>

View 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;
}
}

View File

@ -0,0 +1,4 @@
global using AudioCore.Interfaces;
global using AudioCore.Models;

View 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;
}
}

View 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);
}

View 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();
}

View File

@ -0,0 +1,9 @@
namespace AudioCore.Impl;
public sealed class FfmpegAudioReaderFactory : IAudioReaderFactory
{
public IAudioReader Create(string filePath)
{
return new FfmpegAudioReader(filePath);
}
}

View 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();
}
}

View 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> { }

View 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();
}
}

View 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();
}

View 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();
}

View 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);
}
}

View 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>();
}
}

View 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");
}

View 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();
}
}

View File

@ -0,0 +1,8 @@
namespace AudioCore.Interfaces;
public interface IAudioMixer
{
MixedAudioBlock Mix(
IReadOnlyList<AudioBlock> stemBlocks,
MixerSettings settings);
}

View 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);
}

View 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();
}

View File

@ -0,0 +1,6 @@
namespace AudioCore.Interfaces;
public interface IAudioReaderFactory
{
IAudioReader Create(string filePath);
}

View File

@ -0,0 +1,6 @@
namespace AudioCore.Interfaces;
public interface IProgressReporter<T>
{
Task ReportProgress(T progress, CancellationToken ct = default);
}

View 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();
}

View File

@ -0,0 +1,6 @@
namespace AudioCore.Interfaces;
public interface IStemDecoderFactory
{
IStemDecoder Create(StemTrack stem);
}

View 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);
}

View File

@ -0,0 +1,9 @@
namespace AudioCore.Interfaces;
public interface IStemSeparator
{
Task<StemSet> SeparateAsync(
StemSeparationRequest request,
IProgressReporter<double> progress,
CancellationToken ct = default);
}

View 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);
}

View 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);
}

View 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();
}

View 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; }
}

View 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();
}

View 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; }
}

View 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();
}

View File

@ -0,0 +1,7 @@
namespace AudioCore.Models;
public class StemSeparationRequest
{
public string SourceFilePath { get; init; } = "";
public string OutputDirectory { get; init; } = "";
}

View 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>();
}

View 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; } = [];
}

View File

@ -0,0 +1,11 @@
namespace AudioCore.Models;
public enum StemType
{
Drums,
Bass,
Other,
Vocals,
Guitar,
Piano
}

View 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();
}

View 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>

View 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();
}
}

Binary file not shown.

View 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;
}

View 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;
}
}

View 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 */ }
}
}
}

View 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;
}
}
}

View File

@ -0,0 +1 @@
[assembly: Parallelize(Scope = ExecutionScope.MethodLevel)]

View 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");
}
}

View 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));
}
}
}

View 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);
}
}

View 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);
}
}

View 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));
}
}

View 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);
// Dont 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");
}
}

View 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
View 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
View 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
View 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.