Invoke-DJScratchLooper.ps1

function Invoke-DJScratchLooper {

    <#
 
    .SYNOPSIS
 
    Launches the DJ Scratch Looper WPF application with customizable tracks and images.
 
    .DESCRIPTION
 
    Creates and displays an interactive DJ scratch looper interface with pitch shifting capabilities.
 
    Users can specify custom MP3 tracks and button images.
 
    .PARAMETER Tracks
 
    Array of 9 MP3 file paths for the pads. If not specified, uses defaults.
    
    .PARAMETER BackgroundImage
 
    Path to background/vinyl image. Defaults to module's included image.
 
    .PARAMETER Button1 through Button9
 
    Paths to individual pad button images. Defaults to module's included images.
    
    .PARAMETER Title
 
    Window title. Default: "DJ SCRATCH LOOPER = POWERSHELL MAGIC"
    
    .EXAMPLE
 
    Invoke-DJScratchLooper -Tracks @("C:\beat1.mp3", "C:\beat2.mp3", ...) -BackgroundImage "C:\custom_bg.png"
 
    .EXAMPLE
    Invoke-DJScratchLooper
 
    #>

   
    param(
        [Parameter(Mandatory = $false)]
        [string[]]$Tracks,
        [Parameter(Mandatory = $false)]
        [string]$BackgroundImage,
        [Parameter(Mandatory = $false)]
    [string]$Button1,    
    [Parameter(Mandatory = $false)]
    [string]$Button2,
    [Parameter(Mandatory = $false)]
    [string]$Button3,
        [Parameter(Mandatory = $false)]
        [string]$Button4,
        [Parameter(Mandatory = $false)]
        [string]$Button5,
        [Parameter(Mandatory = $false)]
        [string]$Button6,
        [Parameter(Mandatory = $false)]
        [string]$Button7,
        [Parameter(Mandatory = $false)]
        [string]$Button8,
        [Parameter(Mandatory = $false)]
        [string]$Button9,
        [Parameter(Mandatory = $false)]
        [string]$Title = "DJ SCRATCH LOOPER = POWERSHELL MAGIC"
    )

    # Get module root directory for default resources
    $moduleRoot = $PSScriptRoot   

    # Set defaults for images if not provided
    if (-not $BackgroundImage) { $BackgroundImage = Join-Path $moduleRoot "resources\images\bg.png" }
    if (-not $Button1) { $Button1 = Join-Path $moduleRoot "resources\images\pad1.png" }
    if (-not $Button2) { $Button2 = Join-Path $moduleRoot "resources\images\pad2.png" }
    if (-not $Button3) { $Button3 = Join-Path $moduleRoot "resources\images\pad3.png" }
    if (-not $Button4) { $Button4 = Join-Path $moduleRoot "resources\images\pad4.png" }
    if (-not $Button5) { $Button5 = Join-Path $moduleRoot "resources\images\pad5.png" }
    if (-not $Button6) { $Button6 = Join-Path $moduleRoot "resources\images\pad6.png" }
    if (-not $Button7) { $Button7 = Join-Path $moduleRoot "resources\images\pad7.png" }
    if (-not $Button8) { $Button8 = Join-Path $moduleRoot "resources\images\pad8.png" }
    if (-not $Button9) { $Button9 = Join-Path $moduleRoot "resources\images\pad9.png" }
   
    # Set defaults for tracks if not provided
    if (-not $Tracks -or $Tracks.Count -ne 9) {
        $Tracks = @(
            (Join-Path $moduleRoot "resources\beats\beat1.mp3"),
            (Join-Path $moduleRoot "resources\beats\beat2.mp3"),
            (Join-Path $moduleRoot "resources\beats\beat3.mp3"),
            (Join-Path $moduleRoot "resources\beats\beat4.mp3"),
            (Join-Path $moduleRoot "resources\beats\beat5.mp3"),
            (Join-Path $moduleRoot "resources\beats\beat6.mp3"),
            (Join-Path $moduleRoot "resources\beats\beat7.mp3"),
            (Join-Path $moduleRoot "resources\beats\beat8.mp3"),
            (Join-Path $moduleRoot "resources\beats\beat9.mp3")
        )
    }   
    
    # Load assemblies
    Add-Type -AssemblyName PresentationFramework
    Add-Type -AssemblyName PresentationCore
    Add-Type -AssemblyName WindowsBase
   
    # Load NAudio if not already loaded
    $naudioPath = Join-Path $moduleRoot "lib\NAudio.dll"
    if (-not ([AppDomain]::CurrentDomain.GetAssemblies() | Where-Object { $_.GetName().Name -eq "NAudio" })) {
        if (Test-Path $naudioPath) {
            Add-Type -Path $naudioPath
        }
    }
   
    # Load pitch-shift assembly
    $pitchShiftPath = Join-Path $moduleRoot "lib\PitchShiftStep.dll"
    if (Test-Path $pitchShiftPath) {
        Add-Type -Path $pitchShiftPath
    } else {
        Write-Error "PitchShiftStep.dll not found at $pitchShiftPath"
        return
    }
   
    # Build XAML with custom images
    $xaml = @"
    <Window xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:media="clr-namespace:System.Windows.Media;assembly=PresentationCore"
        xmlns:effects="clr-namespace:System.Windows.Media.Effects;assembly=PresentationCore"
        xmlns:animation="clr-namespace:System.Windows.Media.Animation;assembly=PresentationCore"
        Title="DJ SCRATCH LOOPER = POWERSHELL MAGIC"
        Height="690"
        Width="685"
        WindowStyle="None"
        AllowsTransparency="True"
        Background="Transparent">
    <Window.Resources>
        <Style x:Key="WindowGlowBorder" TargetType="Border">
            <Setter Property="CornerRadius" Value="40"/>
            <Setter Property="BorderThickness" Value="5"/>
            <Setter Property="BorderBrush" Value="#00FFFF"/>
            <Setter Property="Background" Value="#FFFFFF"/>
                    <Setter Property="Cursor" Value="Hand"/>
            <Setter Property="Effect">
                <Setter.Value>
                    <DropShadowEffect Color="#00FFFF" BlurRadius="20" ShadowDepth="0" Opacity="0.8"/>
                </Setter.Value>
            </Setter>
        </Style>
        <Style x:Key="PitchButtonNeon" TargetType="Button">
            <Setter Property="Foreground" Value="#00FFFF"/>
            <Setter Property="FontSize" Value="26"/>
            <Setter Property="FontWeight" Value="Bold"/>
            <Setter Property="Padding" Value="20,10"/>
            <Setter Property="Cursor" Value="Hand"/>
            <Setter Property="Background" Value="#111"/>
            <Setter Property="BorderBrush" Value="#00FFFF"/>
            <Setter Property="BorderThickness" Value="3"/>
            <Setter Property="Effect">
                <Setter.Value>
                    <DropShadowEffect Color="#00FFFF" BlurRadius="20" ShadowDepth="0" Opacity="0.8"/>
                </Setter.Value>
            </Setter>
            <Setter Property="Template">
                <Setter.Value>
                    <ControlTemplate TargetType="Button">
                        <Grid>
                            <Border Background="{TemplateBinding Background}"
                                    BorderBrush="{TemplateBinding BorderBrush}"
                                    BorderThickness="{TemplateBinding BorderThickness}"
                                    CornerRadius="18"/>
                            <ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
                        </Grid>
                        <ControlTemplate.Triggers>
                            <Trigger Property="IsMouseOver" Value="True">
                                <Setter Property="Effect">
                                    <Setter.Value>
                                        <DropShadowEffect Color="#00FFFF" BlurRadius="35" ShadowDepth="0" Opacity="1"/>
                                    </Setter.Value>
                                </Setter>
                            </Trigger>
                            <Trigger Property="IsPressed" Value="True">
                                <Setter Property="RenderTransform">
                                    <Setter.Value>
                                        <ScaleTransform ScaleX="0.95" ScaleY="0.95"/>
                                    </Setter.Value>
                                </Setter>
                            </Trigger>
                        </ControlTemplate.Triggers>
                    </ControlTemplate>
                </Setter.Value>
            </Setter>
        </Style>
        <Style x:Key="LedPadStyle" TargetType="Border">
            <Setter Property="CornerRadius" Value="12"/>
            <Setter Property="BorderThickness" Value="4"/>
            <Setter Property="BorderBrush" Value="#444"/>
        </Style>
        <Style x:Key="ArcadeStopButton" TargetType="Button">
            <Setter Property="Foreground" Value="White"/>
            <Setter Property="FontSize" Value="38"/>
            <Setter Property="FontWeight" Value="Bold"/>
            <Setter Property="Padding" Value="20,10"/>
            <Setter Property="Cursor" Value="Hand"/>
            <Setter Property="HorizontalAlignment" Value="Right"/>
            <Setter Property="VerticalAlignment" Value="Center"/>
            <Setter Property="Template">
                <Setter.Value>
                    <ControlTemplate TargetType="Button">
                        <Grid>
                            <Ellipse x:Name="Glow" Width="140" Height="140" Fill="DarkRed" Stroke="Red" StrokeThickness="4" Opacity="0.7">
                                <Ellipse.Effect>
                                    <DropShadowEffect Color="Red" BlurRadius="25" ShadowDepth="0" Opacity="1"/>
                                </Ellipse.Effect>
                            </Ellipse>
                            <Ellipse x:Name="Bevel" Width="120" Height="120" Fill="DarkRed">
                                <Ellipse.Effect>
                                    <DropShadowEffect Color="#550000" BlurRadius="10" ShadowDepth="4" Opacity="0.8"/>
                                </Ellipse.Effect>
                            </Ellipse>
                            <ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
                        </Grid>
                        <ControlTemplate.Triggers>
                            <Trigger Property="IsMouseOver" Value="True">
                                <Setter TargetName="Glow" Property="Opacity" Value="1"/>
                                <Setter TargetName="Glow" Property="RenderTransform">
                                    <Setter.Value>
                                        <ScaleTransform ScaleX="1.05" ScaleY="1.05"/>
                                    </Setter.Value>
                                </Setter>
                            </Trigger>
                            <Trigger Property="IsPressed" Value="True">
                                <Setter TargetName="Glow" Property="Opacity" Value="0.3"/>
                                <Setter TargetName="Bevel" Property="RenderTransform">
                                    <Setter.Value>
                                        <ScaleTransform ScaleX="0.95" ScaleY="0.95"/>
                                    </Setter.Value>
                                </Setter>
                                <Setter Property="RenderTransform">
                                    <Setter.Value>
                                        <ScaleTransform ScaleX="0.95" ScaleY="0.95"/>
                                    </Setter.Value>
                                </Setter>
                            </Trigger>
                        </ControlTemplate.Triggers>
                    </ControlTemplate>
                </Setter.Value>
            </Setter>
        </Style>
    </Window.Resources>
    <Border Opacity="0.85" Style="{StaticResource WindowGlowBorder}">
    <Grid>
        <!-- VINYL BEHIND EVERYTHING -->
        <Image x:Name="VinylImage"
               Source="$BackgroundImage" Stretch="UniformToFill" RenderTransformOrigin="0.5,0.5" Panel.ZIndex="0">
            <Image.RenderTransform>
                <RotateTransform x:Name="VinylRotate" Angle="0"/>
            </Image.RenderTransform>
        </Image>
        <!-- FOREGROUND UI -->
        <Grid Panel.ZIndex="1">
            <Grid.RowDefinitions>
                <RowDefinition Height="*" />
                <RowDefinition Height="Auto" />
            </Grid.RowDefinitions>
            <Button Name="CloseButton"
        Content="X"
        Width="32"
        Height="32"
        FontSize="18"
        FontWeight="Bold"
        Foreground="White"
        Background="#AA0000"
        BorderBrush="Red"
        HorizontalAlignment="Right"
        VerticalAlignment="Top"
        Margin="15">
    <Button.Template>
        <ControlTemplate TargetType="Button">
            <Border Background="{TemplateBinding Background}"
                    BorderBrush="{TemplateBinding BorderBrush}"
                    BorderThickness="1"
                    CornerRadius="8">
                <ContentPresenter HorizontalAlignment="Center"
                                  VerticalAlignment="Center"/>
            </Border>
        </ControlTemplate>
    </Button.Template>
</Button>
            <UniformGrid Rows="3" Columns="3" Margin="10" Grid.Row="0">
                <Button Name="Pad1" Margin="5" Width="110" Height="110">
                    <Border Style="{StaticResource LedPadStyle}">
                        <Image Source="$Button1" Width="110" Height="110" Stretch="Uniform"/>
                    </Border>
                </Button>
                <Button Name="Pad2" Margin="5" Width="110" Height="110">
                    <Border Style="{StaticResource LedPadStyle}">
                        <Image Source="$Button2" Width="110" Height="110" Stretch="Uniform"/>
                    </Border>
                </Button>
                <Button Name="Pad3" Margin="5" Width="110" Height="110">
                    <Border Style="{StaticResource LedPadStyle}">
                        <Image Source="$Button3" Width="110" Height="110" Stretch="Uniform"/>
                    </Border>
                </Button>
                <Button Name="Pad4" Margin="5" Width="110" Height="110">
                    <Border Style="{StaticResource LedPadStyle}">
                        <Image Source="$Button4" Width="110" Height="110" Stretch="Uniform"/>
                    </Border>
                </Button>
                <Button Name="Pad5" Margin="5" Width="110" Height="110">
                    <Border Style="{StaticResource LedPadStyle}">
                        <Image Source="$Button5" Width="110" Height="110" Stretch="Uniform"/>
                    </Border>
                </Button>
                <Button Name="Pad6" Margin="5" Width="110" Height="110">
                    <Border Style="{StaticResource LedPadStyle}">
                        <Image Source="$Button6" Width="110" Height="110" Stretch="Uniform"/>
                    </Border>
                </Button>
                <Button Name="Pad7" Margin="5" Width="110" Height="110">
                    <Border Style="{StaticResource LedPadStyle}">
                        <Image Source="$Button7" Width="110" Height="110" Stretch="Uniform"/>
                    </Border>
                </Button>
                <Button Name="Pad8" Margin="5" Width="110" Height="110">
                    <Border Style="{StaticResource LedPadStyle}">
                        <Image Source="$Button8" Width="110" Height="110" Stretch="Uniform"/>
                    </Border>
                </Button>
                <Button Name="Pad9" Margin="5" Width="110" Height="110">
                    <Border Style="{StaticResource LedPadStyle}">
                        <Image Source="$Button9" Width="110" Height="110" Stretch="Uniform"/>
                    </Border>
                </Button>
            </UniformGrid>
            <Grid Grid.Row="1" Margin="10">
                <Grid.RowDefinitions>
                    <RowDefinition Height="Auto"/>
                    <RowDefinition Height="Auto"/>
                </Grid.RowDefinitions>
                <TextBlock Name="TrackStatus"
                           Grid.Row="0"
                           HorizontalAlignment="Center"
                           VerticalAlignment="Center"
                           Margin="0,0,0,10"
                           FontSize="22"
                           FontWeight="Bold"
                           FontFamily="Consolas"
                           Foreground="#00FF00"
                           Text="No Track Loaded">
                    <TextBlock.Effect>
                        <DropShadowEffect Color="#00FF00"
                                          BlurRadius="15"
                                          ShadowDepth="0"
                                          Opacity="1"/>
                    </TextBlock.Effect>
                </TextBlock>
                <StackPanel Grid.Row="1"
                            Orientation="Horizontal"
                            HorizontalAlignment="Center">
                    <Button Name="PitchDown"
                            Content="Pitch -"
                            Width="140"
                            Height="60"
                            Margin="10"
                            Style="{StaticResource PitchButtonNeon}"/>
                    <Button Name="PitchUp"
                            Content="Pitch +"
                            Width="140"
                            Height="60"
                            Margin="10"
                            Style="{StaticResource PitchButtonNeon}"/>
                    <Button Name="StopButton"
                            Content="STOP"
                            Style="{StaticResource ArcadeStopButton}"
                            Margin="20"/>
                </StackPanel>
            </Grid>
        </Grid>
    </Grid>
</Border>
</Window>
"@

#endregion

#region Window creation and application setup
# Load XAML
$reader = [System.Xml.XmlReader]::Create([System.IO.StringReader]$xaml)
$window = [Windows.Markup.XamlReader]::Load($reader)

# Make window draggable
$window.Add_MouseLeftButtonDown({
    $window.DragMove()
})

# Close the window as using X
$window.FindName("CloseButton").Add_Click({
    Stop-AllLoops
    $window.Close()
})
# Stop audio when window closes double check if used alt+f4 to close
$window.Add_Closing({
    Stop-AllLoops
})

# Setting globalk variables to calculate vinyl speed
$global:vinylanime = 5
$global:MinPitchSpeed = 1   # 4 clicks up from 5 -> 1
$global:MaxPitchSpeed = 9  # 4 clicks down from 5 -> 9
$global:PitchLevel = 0
$global:CurrentTrack = "None"

#endregion

 

#region Animation helpers

#This function hides the pitch buttons if maximum speed has been reached
function Update-PitchButtons { 
    if ($global:PitchLevel -ge 4) {
        $pitchUp.Visibility = [System.Windows.Visibility]::Collapsed
    }
    else {
        $pitchUp.Visibility = [System.Windows.Visibility]::Visible
    }
    if ($global:PitchLevel -le -4) {
        $pitchDown.Visibility = [System.Windows.Visibility]::Collapsed
    }
    else {
        $pitchDown.Visibility = [System.Windows.Visibility]::Visible
    }
}

# This function animates the vinyl spinning
function Start-VinylSpin {
    $rotate = $window.FindName("VinylRotate")
    $animation = New-Object System.Windows.Media.Animation.DoubleAnimation
    $animation.From = 0
    $animation.To = 360
    $animation.Duration = [System.Windows.Duration]::new([System.TimeSpan]::FromSeconds($($global:vinylanime)))
    $animation.RepeatBehavior = [System.Windows.Media.Animation.RepeatBehavior]::Forever 
    $rotate.BeginAnimation([System.Windows.Media.RotateTransform]::AngleProperty, $animation)
}

# This function stops the vinyl animation
function Stop-VinylSpin {
    $rotate = $window.FindName("VinylRotate")
    # Passing $null stops the animation cleanly
    $rotate.BeginAnimation([System.Windows.Media.RotateTransform]::AngleProperty, $null)
}
#endregion

#region Visual helpers

# This function will set the glow effect on a pad button when it's active
function Set-PadGlow {
    param($padName, $active)
    $border = $window.FindName($padName).Content 
    if ($active) {
        $effect = New-Object System.Windows.Media.Effects.DropShadowEffect
        $effect.Color = "#FFFF33"
        $effect.BlurRadius = 25
        $effect.ShadowDepth = 0
        $effect.Opacity = 1
        $border.Effect = $effect
    }
    else {
        $border.Effect = $null
    }
}

#endregion

#region Audio playback helpers
# Store active players
# Dictionary of active audio players keyed by file path.
$global:Players = @{}

# This function will start playing an MP3 file in a loop, stopping any currently playing loops
function Start-Mp3Loop {
    param($Path) 
    # Stop ALL currently playing loops
    foreach ($key in $Players.Keys) {
        try {
            $Players[$key].Stop()
            $Players[$key].Dispose()
        } catch {}
    }
    $Players.Clear()
 
    # Create new pitch-enabled player
    $player = New-Object PitchShiftStepLib.StepPitchPlayer($Path)
 
    # Play
    $player.Play()
    $global:PitchLevel = 0
    $global:vinylanime = 5
    Update-PitchButtons
    # Start vinyl animation
    Start-VinylSpin 
    # Store player
    $global:Players[$Path] = $player
}

# This function will stop all currently playing loops and clear the Players dictionary
function Stop-AllLoops {
    foreach ($player in $Players.Values) {
        try { $player.Stop() } catch {}
        try { $player.Dispose() } catch {}
    }
    $Players.Clear()
    $global:CurrentTrack = "None"
    $global:PitchLevel = 0
    $global:vinylanime = 5
    Update-PitchButtons
    Stop-VinylSpin
}

#endregion

 

#region Beat mapping

# Beat map, mapping pad names to MP3 file paths
if ($Tracks -and $Tracks.Count -eq 9) {
    $beats = @{
        Pad1 = $Tracks[0]
        Pad2 = $Tracks[1]
        Pad3 = $Tracks[2]
        Pad4 = $Tracks[3]
        Pad5 = $Tracks[4]
        Pad6 = $Tracks[5]
        Pad7 = $Tracks[6]
        Pad8 = $Tracks[7]
        Pad9 = $Tracks[8]
    }
} else {
    $beats = @{
        Pad1 = Join-Path $moduleRoot "resources\beats\beat1.mp3"
        Pad2 = Join-Path $moduleRoot "resources\beats\beat2.mp3"
        Pad3 = Join-Path $moduleRoot "resources\beats\beat3.mp3"
        Pad4 = Join-Path $moduleRoot "resources\beats\beat4.mp3"
        Pad5 = Join-Path $moduleRoot "resources\beats\beat5.mp3"
        Pad6 = Join-Path $moduleRoot "resources\beats\beat6.mp3"
        Pad7 = Join-Path $moduleRoot "resources\beats\beat7.mp3"
        Pad8 = Join-Path $moduleRoot "resources\beats\beat8.mp3"
        Pad9 = Join-Path $moduleRoot "resources\beats\beat9.mp3"
    }
}

# Music track mapping
$trackNames = @{
    Pad1 = "Track One"
    Pad2 = "Track Two"
    Pad3 = "Track Three"
    Pad4 = "Track Four"
    Pad5 = "Track Five"
    Pad6 = "Track Six"
    Pad7 = "Track Seven"
    Pad8 = "Track Eight"
    Pad9 = "Track Nine"
}
#endregion

 

#region Button bindings

# Bind pad buttons (use sender.Name to avoid closure issues)
foreach ($pad in $beats.Keys) {
    $button = $window.FindName($pad)
    if ($button) {
        $button.Add_Click({
            param($sender, $args) 
            foreach ($padName in $beats.Keys) {
                Set-PadGlow $padName $false
            }
            Set-PadGlow $sender.Name $true
            $global:CurrentTrack = $trackNames[$sender.Name]
            $trackStatus.Text = "Playing $($global:CurrentTrack)"
            Start-Mp3Loop $beats[$sender.Name]
        })
    }
}
 
# Bind stop and pitch control buttons from the UI.
$stopButton = $window.FindName("StopButton")
$pitchUp = $window.FindName("PitchUp")
$pitchDown = $window.FindName("PitchDown")
$trackStatus = $window.FindName("TrackStatus")

# Update the pitch buttons
Update-PitchButtons

 

# Add click functionality pitch down
$pitchUp.Add_Click({
    if ($global:PitchLevel -lt 4) { 
        $Players.Values | ForEach-Object {
            $_.IncreasePitchStep()
        }
        $global:PitchLevel++ 
        if ($global:vinylanime -gt 1) {
            $global:vinylanime--
            Start-VinylSpin
        } 
        Update-PitchButtons
    }
})
 
# Add click functionality pitch down
$pitchDown.Add_Click({
    if ($global:PitchLevel -gt -4) {
        $Players.Values | ForEach-Object {
            $_.DecreasePitchStep()
        }
        $global:PitchLevel-- 
        if ($global:vinylanime -lt 10) {
            $global:vinylanime++
            Start-VinylSpin
        } 
        Update-PitchButtons
    }
})
 
# Stop the music when the STOP button is clicked
$stopButton.Add_Click({
    if ($global:CurrentTrack -ne "None") {
        $trackStatus.Text = "Stopped $($global:CurrentTrack)"
    }
    else {
        $trackStatus.Text = "No Track Loaded"
    }
    Stop-AllLoops
})
#endregion

#region Application launch
# Show the window and start the WPF message loop.
$window.ShowDialog()
#endregion
}