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