Skip to content
James Brundage edited this page Oct 23, 2026 · 1 revision

Sound

Wave Sounds

Plays the notes in the current Melody, using any number of Instruments.

View Source
<#
.SYNOPSIS
    Wave Sounds
.DESCRIPTION
    Plays the notes in the current Melody, using any number of Instruments.
#>
[Diagnostics.CodeAnalysis.SuppressMessageAttribute(
    'PSAvoidAssignmentToAutomaticVariable', '', Justification='We need $this to work'
)]
param()

# Replace angle brackets and periods,
# and split on whitespace to get a sequence of instruments
$Instruments = @(
    foreach ($arg in $args) {
        $arg -replace '[\.<>]' -split '\s+'         
    }
)

$currentWave = $this
if (-not $currentWave) {
    $currentWave = wave
}

# If we could not detect instruments
if (-not $Instruments) {
    $Instruments = @(if ($currentWave.Instrument) {
        $currentWave.Instrument -replace '[\.<>]' -split '\s+'
    } else {
        'sine'
    })
}

# If we have no melody
if (-not $currentWave.Melody) { 
    # warn and return
    Write-Warning "No Melody Defined"
    return
}

# Prepare a progress bar
$progress = @{
    id = Get-Random
    status = 'Generating'
}

# And prepare to generate events.
# (We may as well log what happens, as it gives us history and readable note sequences)
$events = [Runspace]::DefaultRunspace.Events

# Start at the first instrument.
$InstrumentIndex = 0 

$waveFormat = $currentWave.WaveFormat

# We never need to make the same wave twice,
# so we want a runspace-wide cache of our waves.
# This makes it _much_ faster to generate most notes
$waveTable = Get-TypeData -TypeName WaveTable -ErrorAction Ignore

# If the cache is uninitialized
if (-not $waveTable.Members.WaveCache) {
    # Create it
    Update-TypeData -TypeName WaveTable -MemberName "WaveCache" -MemberType NoteProperty -Value (
        [Ordered]@{}
    ) -Force
}

# Create a Wave Table prototype
$waveTable = [PSCustomObject]@{PSTypeName='WaveTable'}
# and get the static cache.
$waveCache = $waveTable.WaveCache

filter waveID {
    $note = $_
    "data-audio-format='$($currentWave.AudioFormat)'"
    "data-channel-count='$($currentWave.ChannelCount)'"
    "data-sample-rate='$($currentWave.SampleRate)'"
    "data-bits-per-sample='$($currentWave.BitsPerSample)'"
    "data-instrument='$($Instrument -replace "'", "''")'"
    foreach ($key in $note.Keys) {
        "data-$key='$($note[$key] -replace "'","''")'"
        # that is a parameter for that instrument
        if ($instrumentParameterNames -contains $key) {
            # becomes an instrument parameter.
            $instrumentParameters[$key] = $note[$key]
        }
    }
}

# Unroll the notes in the melody
$melody = @(foreach ($note in $currentWave.Melody) {
    $note
})

$noteSequence = @(foreach ($note in $Melody) {
    $noteTable = [Ordered]@{}
    if ($note -is [Collections.IDictionary]) {
        $noteTable += $note
    } else {
        foreach ($prop in $note.psobject.properties) {
            if ($prop -isnot [psnoteproperty]) { continue }
            if (-not $prop.IsInstance) { continue }
            $noteTable[$prop.Name] = $note.($prop.Name)
        }
    }
    
    $noteTable
})

# We go over each note in the sequence,
[byte[]]$wavaData = @(for ($index = 0; $index -lt $NoteSequence.Length; $index++) {
    $note = $NoteSequence[$index]
    $friendly = $note.Name
    # and writing progress as we go.
    $progress.PercentComplete = $index * 100 / $NoteSequence.Length
    $progress.Activity = "$($note.Name)@$($note.Duration) $index / $($NoteSequence.Length)"
    Write-Progress @progress

    # Initialize our instrument list (we can play multiple instruments at once)
    $InstrumentList = ''
    # Get the instrument list used for this sample.
    if ($Instruments.Length) {
        $InstrumentList = $Instruments[$InstrumentIndex % $Instruments.Length]
        $InstrumentIndex++
    }
    
    # If the note name is rest or the frequency is zero
    if ((-not $note.Frequency) -or ($note.Name -eq 'REST')) {
        # the instrument is `Silence`.
        $InstrumentList = 'Silence'
    }

    # We want to be able to make multiple sounds at once.
    # Our combined wave is the sum of all of the samples.
    # Start by split our instruments by commas.
    $AllSamples = @(foreach ($instrument in @($InstrumentList -split ',')) {        
        $newWave = wave @waveFormat
        if (-not $newWave.$Instrument.Script) {
            throw "Invalid Instrument $Instrument (must be a script method)"
        }

        # Then find our instrument parameter names
        $instrumentParameterNames =
            # by walking over the parameters in the script's AST
            foreach ($param in $newWave.$Instrument.Script.Ast.ParamBlock.Parameters) {
                "$($param.Name)" -replace '^\$'
                foreach ($attr in $param.Attributes) {
                    if ($attr.TypeName.Name -eq 'alias') {
                        $attr.PositionalArguments.Value
                    }
                }
            }

        if (-not $instrumentParameterNames.Count) {
            throw "No Instrument Parameters for $instrument"
        }

        # Get the list of frequencies played.
        $frequencyList = @(
            # This is the frequency list, split by commas
            foreach ($frequency in 
                $note.Frequency -split ',' -replace 
                    'hz', ',' -split ',' -as [double[]]
            ) {
                # If we are shifting octaves
                if ($note.ShiftOctave) {
                    if ($note.ShiftOctave -gt 0) {
                        for ($shiftNumber = 1; $shiftNumber -le $note.ShiftOctave; $shiftNumber++) {
                            $frequency *= 2
                        }
                    }
                    elseif ($note.ShiftOctave -lt 0) {
                        for ($shiftNumber = -1; $shiftNumber -ge $note.ShiftOctave; $shiftNumber--) {
                            $frequency /= 2
                        }
                    }
                }
                <#
                
                A frequency can be played on any number of "strings"

                We can think of each vibration within a sound as a literal string, 
                vibrating at a frequency.  
                
                That's what a lot of "real" music actually is.

                Each string is a harmonic vibration.

                Strings can be present on the note or on this wave.

                If strings is a positive number, each string will vibrate at half the frequency.

                If strings is a negative number, each string will vibrate at twice the frequency.

                If strings can be a list of `[double[]]`, 
                each frequency will be generated at that ratio.

                #>
                
                $strings = 
                    # strings can be defined on our notes
                    if ($note.Strings) {
                        $note.Strings
                    } 
                    # Or on this object
                    elseif($currentWave.Strings)
                    {    
                        $currentWave.Strings
                    }

                # If no strings are defined,
                if (-not $strings) { 
                    # we are simply playing this frequency
                    $frequency
                    continue
                }
                # Set the number of strings on the note itself
                # this will influence how the note is cached.
                $note.Strings = $strings

                # If the strings is an integer, it's a multiple 
                if ($strings -is [int]) {
                    # If the number of strings is positive
                    if ($strings -ge 1) {
                        # we are generating that number of frequencies
                        foreach ($noteString in 1..$Strings) {
                            # Each string is half of the previous
                            # so we will divide by powers of two
                            $frequency / [Math]::Pow(2, ($noteString - 1))
                        }
                    }
                    # If the number of strings is negative 
                    elseif ($strings -le 1) {
                        # we are generating the absolute value of that number of frequencies
                        # Each frequency is double the previous                        
                        foreach ($noteString in 1..([Math]::Abs($Strings))) {
                            # So we will multiply by powers of two.
                            $frequency * [Math]::Pow(2, ($noteString - 1))
                        }
                    }
                    else {
                        # If the number of strings is zero,
                        # we obviously must be generating silence.
                        0
                    }
                }
                elseif (
                    # If the strings is a series of doubles
                    $strings -as [double[]]
                ) {
                    # emit the original frequency
                    $frequency                    
                    foreach ($noteString in ($strings -as [double[]])) {
                        # and the frequency at each of those ratios.
                        $frequency * $noteString
                    }
                }
            }
        )
        
        # Now that our instruments are natively polyphonic
        # we can just pass our list of frequencies
        $note = [Ordered]@{} + $noteSequence[$index]
        $note.Frequency = $frequencyList            

        # Collect our instrument parameters
        $instrumentParameters = [Ordered]@{}
        
        # As we map our instrument parameters, let us also construct a cache key.
        # If the same instrument and parameters are requested,
        # we can reliably avoid making the same note twice.
        
        # We might as well keep this key in a html friendly format.
        # (we might want this information later)
        $noteStartTime = $noteSequence[$index].Time
        $noteSequence[$index].Remove('Time')
        $noteSequence[$index].Remove('MatchNumber')
        $cacheKey = @($note | waveID) -join ' '

        $noteData = [Ordered]@{} + $noteSequence[$index] + @{
            # played with the current instrument
            Instrument = $Instrument
            Id = $cacheKey 
        }
        
        $noteData.PSTypeName = 'Note'
            
        # Generate an event representing a note.
        # Even if we have already cached the note, 
        # we still want to log that we want to play it.
        $null = $events.GenerateEvent(
            'Note', $currentWave, @($friendly), [PSCustomObject]$noteData
        )

        if (-not $WaveCache[$cacheKey]) {
            # Call our instrument script.  
            # This should output be a `[byte[]]` stream of PCM data
            # Or a `[double[]]` stream of samples.                
            $newWave = wave @waveFormat
            $this = $newWave
            $sample = & $newWave.$instrument.Script @instrumentParameters
            if (
                $sample.pstypenames -contains 'Wave'
            ) {
                $WaveCache[$cacheKey] = $sample
            }
            elseif ($sample.Length) {
                if ($sample[0] -is [byte]) {
                    $WaveCache[$cacheKey] = (wave @waveFormat -PCM $sample) 
                }
                elseif ($sample[0] -is [double]) {
                    $WaveCache[$cacheKey] = (wave @waveFormat -Samples $sample)
                }
            }
        }

        ,$WaveCache[$cacheKey].Samples
    })
    
    if (-not $AllSamples.Length) { continue }

    $note = [Ordered]@{} + $noteSequence[$index]
    # Join all instruments by comma.    
    $note.Instrument = $InstrumentList -join ','
    # This will let us make an accurate cache key for the combined sound.
    $cacheKey = @($note | waveID) -join ' '

    # If we have not cached the combined wave
    if (-not $WaveCache[$cacheKey]) {
        # Create an array of samples to hold the sums
        [double[]]$sumSamples = (
            @(0) * $AllSamples[0].Length
        )

        # Go thru each set of samples
        foreach ($samples in $AllSamples) {
            # starting at sample 0
            $sampleNumber = 0
            # and Add It Up.
            foreach ($sample in $samples) {
                $sumSamples[$sampleNumber] += $sample
                $sampleNumber++
            }            
        }
        
        # While a pure add is mathematically accurate,
        foreach ($sample in $sumSamples) {
            # an average of all samples may sound a bit better.
            $sample /= $allSamples.Length
        }
        # Create and cache new wave with the summed samples        
        $WaveCache[$cacheKey] = wave @waveSplat -Samples $sumSamples
    }
    
    # Emit the cached wave data at this point
    # By emitting data instead of samples, 
    # we make it faster to concatenate the wave.
    $WaveCache[$cacheKey].Data
})

# Last but not least: complete our progress bars
$progress.Remove('PercentComplete')
$progress.Completed = $true
Write-Progress @progress

$newWave = wave @waveSplat -PCM $wavaData
$newWave.Melody = $currentWave.Melody
$NewWave.Instrument = $Instruments
return $newWave

Clone this wiki locally