Types/Wave/Sound.ps1

<#
.SYNOPSIS
    Wave Sounds
.DESCRIPTION
    Plays the notes in the current Melody, using any number of Instruments.
#>

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+'         
    }
)

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

# If we have no melody
if (-not $this.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 

$waveSplat = $this.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='$($this.AudioFormat)'"
    "data-channel-count='$($this.ChannelCount)'"
    "data-sample-rate='$($this.SampleRate)'"
    "data-bits-per-sample='$($this.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 $this.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 ',')) {
        if (-not $this.$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 $this.$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[]]
            ) {
                <#
                
                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($this.Strings)
                    {    
                        $this.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
                    }
                }
            }
        )
        # We want to be able to play multiple frequencies, too.
        # So split our note frequency by commas
        foreach ($frequency in @($frequencyList)) {
            $note = [Ordered]@{} + $noteSequence[$index]
            $note.Frequency = $Frequency

            # If we are shifting octaves
            if ($note.ShiftOctave) {
                if ($note.ShiftOctave -gt 0) {
                    for ($shiftNumber = 1; $shiftNumber -le $note.ShiftOctave; $shiftNumber++) {
                        $note.Frequency *= 2
                    }            
                }
                elseif ($note.ShiftOctave -lt 0) {
                    for ($shiftNumber = -1; $shiftNumber -ge $note.ShiftOctave; $shiftNumber--) {
                        $note.Frequency /= 2
                    }
                }
            }

            # 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)
            $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', $this, @($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.
                $samples = . $this.$instrument.Script @instrumentParameters
                if ($samples.Length) {
                    if ($samples[0] -is [byte]) {
                        $WaveCache[$cacheKey] = (wave @waveSplat -PCM $samples) 
                    }
                    elseif ($samples[0] -is [double]) {
                        $WaveCache[$cacheKey] = (wave @waveSplat -Samples $samples)
                    }
                }
            }

            ,$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 = $this.Melody
$NewWave.Instrument = $Instruments
return $newWave