Wave.ps1
|
<# .SYNOPSIS Makes `.wav`es .DESCRIPTION Makes a Wave stream. This lets us make music with PowerShell. Wave has an open-ended syntax. Any number of arguments can be passed to Wave .INPUTS Accepts any input. If the input is a `[double[]]` of samples, and there are no -Samples provided, this will become the samples. If the input is a `[double[]]` of samples and there are `-Samples`, this will mix the two samples together. If the input was already an `audio/wav` it will operate on the current wave. If the input was a `[IO.FileInfo]` with the `.wav` extension, it will load the wave into memory and operate on that wave. .NOTES ### What This Script Does This script return a Wave in a `[IO.Stream]`. If a `-Path` is provided, it will read the file into a `[IO.MemoryStream]`, and we will call it a `audio/wav`. If a `-Stream` is provided, we will call it an `audio/wav`. If neither is provided, will create a new waveform. .OUTPUTS Normally outputs the current `audio/wav` object. If properties are requested, will output the values of those properties. If methods return values that cannot become a wave, they will output. .LINK https://en.wikipedia.org/wiki/WAV#WAV_file_header .EXAMPLE # Creates an empty wave wave .EXAMPLE # Creates a wave tone at 440hz (A4) wave tone 440 .EXAMPLE # Creates a tone at 440 hz (A4), # then another tone at 220hz (A3) wave tone 440 tone 220 .EXAMPLE # Play a series of notes wave note abba play .EXAMPLE # Set the bpm, then play notes wave bpm 256 a2a3a4a5 play .EXAMPLE # Go to the cafe wave note cafe play .EXAMPLE # Go to the cafe and back wave note cafeefac play .EXAMPLE # Go to the cafe in reverse wave note cafe rev play .EXAMPLE # Or make a palindrome wave note cafe palindrome play .EXAMPLE # Or play it backwards wave note cafe palindrome backmask play .EXAMPLE # Play notes in octaves wave note a2a3b2b3 play .EXAMPLE # Add rests wave bpm 256 note a2a3~~b2b3~~ play #> [Alias('wav', '.wav','〜','🌊')] [CmdletBinding(PositionalBinding=$false)] [Diagnostics.CodeAnalysis.SuppressMessageAttribute( 'PSAvoidAssignmentToAutomaticVariable', '', Justification='We need $this to work' )] param( # The arguments to pass to Wave. # This is a flexible syntax that lets you make music from the command line. [ArgumentCompleter({ param ($commandName, $parameterName, $wordToComplete, $commandAst, $fakeBoundParameters ) if (-not $script:WaveTypeData) { $script:WaveTypeData = Get-TypeData -TypeName audio/wav } $memberNames = @($script:WaveTypeData.Members.Keys) if ($wordToComplete) { return $memberNames -like "$wordToComplete*" } else { return $memberNames } })] [Parameter(ValueFromRemainingArguments)] [Alias('Arguments','Argument','Args','ArgV')] [PSObject[]] $ArgumentList, # Any input object to process. # If this is already a wave object, the arguments will be applied to this object. # If the input object is not a wave object, it will be ignored and a new wave object will be created. [Parameter(ValueFromPipeline)] [Alias('Input')] [PSObject] $InputObject, # The number of channels [Alias('Channels','CC')] [uint16] $ChannelCount = 1, # The bits per sample [Alias('BPS')] [uint16] $BitsPerSample = 32, # The sample rate, in hertz [uint32] [Alias('Rate','SR')] $SampleRate = 44100, # Audio format (2 bytes) (1: PCM integer, 3: IEEE 754 float) [ValidateScript({ if ($_ -notin 1,3) { throw "Audio Format must be 1 (PCM integer) or 3 (IEEE float)" } return $true })] [Alias('AF')] [uint16] $AudioFormat = 3, # The number of bytes per block [Alias('BPB')] [uint16] $BytesPerBlock, # The bytes per second [uint32] $BytesPerSecond, # The path to a wave file. # If this is provided, # all bytes will be read into a memory stream (which will be treated as a wave). [string] $Path, # An existing stream. # If this is provided, it will try to treat it as a wave. # (this will be harmless, but may result in very odd looking metadata) [IO.Stream] $Stream, # A series of samples. # These will be converted to PCM data [Alias('Sample')] [double[]] $Samples, # The sample data as a byte array. [Alias('PulseControlModulation','Data')] [byte[]] $PCM, # If set, will run as a background job. # This will run using `Start-ThreadJob` if available, # and `Start-Job` if not. [switch] $AsJob ) # Collect any piped input $allInput = @($input) # If there was no piped input if (-not $allInput.Length) { # collect any input provided directly to the parameter $allInput = $InputObject } # If we are running in a background job if ($AsJob) { # create a copy of our parameters $IO = [Ordered]@{} + $PSBoundParameters # Remove our `AsJob` parameter so we do not infinitely recurse. $IO.Remove('AsJob') # Set our module path so we can load all of the surrounding conext. $IO.ModulePath = $MyInvocation.MyCommand.Module.Path -replace '\.psm1$', '.psd1' # And bind any input $IO.InputObject = $allInput # The Job definition is always the same: $JobDefinition = { # Accept a dictionary of parameters param([Collections.IDictionary]$Parameter) # If the parameter contains a module path if ($Parameter.Contains('ModulePath')) { if ($Parameter.ModulePath) { Import-Module $parameter.ModulePath } $Parameter.Remove('ModulePath') } Wave @Parameter } if ($ExecutionContext.SessionState.InvokeCommand.GetCommand('Start-ThreadJob', 'Cmdlet')) { $waveJob = Start-ThreadJob -ScriptBlock $JobDefinition -ArgumentList $IO } else { $waveJob = Start-Job -ScriptBlock $JobDefinition -ArgumentList $IO } $waveJob.pstypenames.insert(0, 'Wave.Job') return $waveJob } # Define a filter to decorate our wave and execute arguments filter makeWave { # Take the input object. $WaveStream = $_ # If it is not yet a `Wave` if ($WaveStream.pstypenames -notcontains 'Wave') { # make it a `Wave`. $WaveStream.pstypenames.insert(0,'Wave') } # If it is not yet an `audio/wav` if ($WaveStream.pstypenames -notcontains 'audio/wav') { # make it an `audio/wav` $WaveStream.pstypenames.insert(0,'audio/wav') } # If we have no arguments if (-not $ArgumentList.Length) { $WaveStream # simply output the wave. } else { if (-not $WaveStream.Go.Script) { Write-Warning "Interpreter not found. Please import the Wave module." return $WaveStream } # If we have any arguments # try to execute them. try { # Set `$this` first. $this = $WaveStream # Then call `Go` with splatting # (so arguments bind properly and do not get unrolled). . $WaveStream.Go.Script @ArgumentList } catch { # If this failed, write an error $PSCmdlet.WriteError($_) } } } # If we have any input, we want to work with it. if ($allInput.Length) { # One simple and cool possibility is being piped a series of samples. # If we can cast all of the input to a double array, # we can treat it as samples. $inputSamples = $allInput -as [double[]] # If we have input samples and no samples if ($inputSamples -and -not $Samples) { # our input is now our samples $samples = $inputSamples } # If we have input samples and samples elseif ($inputSamples -and $Samples) { # we can mix them together for ($mixIndex = 0 ; $mixIndex -lt $Samples.Count; $mixIndex++) { # just add our input to our samples $samples[$mixIndex] += $inputSamples[$mixIndex % $inputSamples.Length] } } else { # Walk over each input :nextInput foreach ($in in $allInput) { # if it is a wave if ($in.pstypenames -contains 'audio/wav') { # just make waves `$this` to be the wave. $in | makeWave continue nextInput } if ($in -is [IO.FileInfo] -and $in.Extension -eq '.wav' ) { # Create a new memory stream, $memoryStream = [IO.MemoryStream]::new() # read our file bytes, $fileBytes = [IO.File]::ReadAllBytes($in.FullName) # write our file bytes to our stream, $memoryStream.Write($fileBytes, 0,$fileBytes.Length) # and make waves. $memoryStream | makeWave continue nextInput } # Pass thru unknown input $in } } if (-not $samples) { return } } # If we have any input, if ($allInput.Length -and -not $allInput -as [double[]]) { # This will attempt to be a bit clever, # but hopefully not too much. # Walk over each input :nextInput foreach ($in in $allInput) { # if it is a wave if ($in.pstypenames -contains 'audio/wav') { # Set `$this` to be the wave. $in | makeWave # Then call `Go` with splatting # (so arguments bind properly and do not get unrolled). continue nextInput } if ($in -is [IO.FileInfo] -and $in.Extension -eq '.wav' ) { # Create a new memory stream, $memoryStream = [IO.MemoryStream]::new() # read our file bytes, $fileBytes = [IO.File]::ReadAllBytes($in.FullName) # write our file bytes to our stream, $memoryStream.Write($fileBytes, 0,$fileBytes.Length) # and make waves. $memoryStream | makeWave continue nextInput } # Pass thru unknown input $in } # If we had any piped input, return now return } # If a stream is provided if ($Stream) { # make it a wave and return return $stream | makeWave } # If a path was provided if ($Path) { # Get all items at that path foreach ($unresolvedPath in $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($path) ) { # If the item is not a file, continue. if (-not [IO.File]::Exists($unresolvedPath)) { continue } # If we create the stream directly from the file bytes, # then we cannot change it's length in the future. # Since we want waves to be editable, # let's do things a little differently. # Create a new memory stream, $memoryStream = [IO.MemoryStream]::new() # read our file bytes, $fileBytes = [IO.File]::ReadAllBytes($unresolvedPath) # write our file bytes to our stream, $memoryStream.Write($fileBytes, 0,$fileBytes.Length) # and make waves. $memoryStream | makeWave } # Return after all paths have been become waves. return } # If we have gotten this far, # we are going to create a wave from scratch. # Create a new memory stream. $memoryStream = [IO.MemoryStream]::new() # If the audio format is `3` (IEEE float), # and the bits per sample is less than 32 if ($AudioFormat -eq 3 -and $BitsPerSample -lt 32) { $BitsPerSample = 32 # force it to be the right size for IEEE float. } # If we have no computed the bytes per block if (-not $BytesPerBlock) { # It should be the channel count times the number of bytes in each sample. $BytesPerBlock = $ChannelCount * $BitsPerSample/8 } if (-not $BytesPerSecond) { # Number of bytes to read per second (Frequency * BytePerBloc). $BytesPerSecond = $SampleRate * $BytesPerBlock } # If we have been provided samples, but not PCM data if ($samples -and -not $PCM) { # now would be the time to encode it. $sampleNumber = 0 # Since this may take a bit, let's write progress as we go. $progress = [Ordered]@{ Id = Get-Random Status = " " Activity = "Encoding" } # We will reassign our PCM data to the encoded samples $PCM = @(foreach ($sample in $samples) { #region Encode Sample # We _could_ encapsulate the encoding off into it's own procedure. # However, callstacks have overhead. # Wave samples should only be encoded two places: # here, and in Wave.Type.set_Samples. #region Encoding Progress # Progress bars also have overhead, # and we _really_ don't want to generate a progress bar per sample. # (that would generate more overhead from progress than encoding) # So we will only write progress once every 64kb samples # (or about every 1.5 seconds worth of CD quality audio in mono) if (-not ($sampleNumber % 64kb)) { # Our status is simply the ratio of progress. $progress.Status = "$sampleNumber / $($Samples.Length)" # Our percentage complete is that ratio times 100. $progress.PercentComplete = ($sampleNumber * 100 / $samples.Length) # Our seconds remaining is the number of remaining seconds to process $progress.SecondsRemaining = ( # We can determine this based off of the number of samples left ($Samples.Length - $sampleNumber) * # multiplied by the 1/SampleRate*ChannelCount. (1 / ($SampleRate * $ChannelCount)) ) Write-Progress @progress } # Increment our sample number. $sampleNumber++ #endregion Encoding Progress # If we are using 32-bit floating point audio if ($BitsPerSample -eq 32 -and $audioFormat -eq 3) { # we are basically done. # No clamping required. # Just cast to float, [BitConverter]::GetBytes([float]$sample) # get the bytes, continue # and continue } # If we are dealing with whole number audio formats, # We've got to clamp it down to an amplitude between -1 and 1. # Unfortunately, `Clamp` is not part of older .NET framework versions # So we will clamp the old-fashioned way, with an `if` if ($sample -gt 1) { $sample = 1 } elseif ($sample -lt -1) { $sample = -1 } # If there are 8 bits per sample if ($BitsPerSample -eq 8) { # round each sample into bytes, with 127 as the zero point. [byte][Math]::Floor( 127 + $sample * 127 ) } # If there are 16 bits per sample elseif ($BitsPerSample -eq 16) { # we just need to scale an `[int16]` # Hardcode `[int16]::MaxValue` for speed [BitConverter]::GetBytes([int16]($sample * 32767)) } # If there are 32 bits per sample elseif ($BitsPerSample -eq 32) { # we can just scale to an `[int32]`. # Hardcode `[int32]::MaxValue` for speed [BitConverter]::GetBytes([int32]($sample * 2147483647)) } #endregion Encode Sample }) $memoryStream | Add-Member NoteProperty '#Samples' $Samples -Force $progress.Remove('PercentComplete') $progress.Completed = $true Write-Progress @progress } #region Make Wave # Create a binary writer for our stream $binaryWriter = [IO.BinaryWriter]::new($memoryStream) # Wave Files are `RIFF` files, # thus all `.wav` Files must start with `RIFF` # > Bytes: 0..3 $binaryWriter.Write( [Text.Encoding]::ASCII.GetBytes("RIFF") ) # Then they have the the total file size minus the wave header (8 bytes) # This is the size of the remaining headers: 36 bytes. # > Bytes: 4..7 $binaryWriter.Write(36 + $PCM.Length) # `WAVE` is the primary `RIFF` type for this file # > Bytes: 8..11 $binaryWriter.Write( [Text.Encoding]::ASCII.GetBytes("WAVE") ) # Everything else in a WAVE file is a chunk. # Chunks take the following format: # # * 4 byte ASCII name # * 4 byte uint32 length # * A [byte[]] containing data. # The first RIFF chunk must be a format block. # This is "fmt " in ASCCI (the space is important) # > Bytes: 12..15 $binaryWriter.Write( [Text.Encoding]::ASCII.GetBytes("fmt ") ) # The format chunk is exactly 16 bytes. # > Bytes: 16..19 $binaryWriter.Write([uint32]16) # While Audio Format can be custom, # we only care about two audio formats: # # * 1: PCM integer # * 3: IEEE 754 float # > Bytes 20..21 $binaryWriter.Write($AudioFormat) # The next two bytes are the number of channels # > Bytes 22..23 $binaryWriter.Write($ChannelCount) # The next four bytes are the sample rate. # > Bytes 24..27 $binaryWriter.Write($SampleRate) # Bytes Per Second (ChannelCount * SampleRate * BitsPerSample / 8) # > Bytes 28..31 $binaryWriter.Write($BytesPerSecond) # Bytes Per Block (ChannelCount * BitsPerSample / 8) # > Bytes 32..33 $binaryWriter.Write($BytesPerBlock) # Number of bits per sample # > Bytes 34..35 $binaryWriter.Write($BitsPerSample) # The data chunk starts with a `data` header # > Bytes 36..39 $binaryWriter.Write( [Text.Encoding]::ASCII.GetBytes("data") ) # Followed by the length of the byte array: # > Bytes 40..43 $binaryWriter.Write($PCM.Length) # Everything beyond this point is data # (if we have it) if ($PCM.Length) { $binaryWriter.Write($PCM) } # Seek the stream back to 0. $memoryStream.Position = 0 # and make it a wave. return $memoryStream | makeWave |