Why do almost all plugins start at 20 Hz? And how can you properly work with real low‑frequency or “Lo‑Fi” content?
I also noticed an issue in Steinberg’s Studio EQ: It’s brickwall filter doesn’t actually start at 20 Hz but at 20.5 Hz.
Here are some results using a test file I created. It contains sine waves from 1 Hz up to 500 Hz in 1‑Hz steps, each lasting 5 seconds. Every sine wave starts and ends at 0.
With this PowerShell-script on Windows, I’m sharing it as-is, with no guarantees. Logging still needs improvement, and if you find a better approach, I’d love to read about it. (I use the PowerShell ISE.)
# --- Parameters ---
# Sample rate in Hz (samples per second). 48 kHz is a common high‑quality audio rate.
$sampleRate = 48000
# Duration of each generated tone in seconds.
$toneDuration = 5.0
# Duration of silence after each tone in seconds.
$pauseDuration = 1.0
# If true, the tone will try to end at a zero crossing to avoid audible clicks.
$useZeroCross = $true
# *** Maximum frequency to generate ***
# The script generates tones from 1 Hz up to this frequency.
# IMPORTANT:
# With the current settings (48 kHz, 5 s tone + 1 s pause = 288000 samples/segment),
# int32 overflows when:
# maxFrequency > 2,147,483,647 / 288000 ≈ 7456
# So maxFrequency must NOT exceed 7456.
$maxFrequency = 500
# --- NEW: automatic int32 safety check (fully integrated, no comments removed) ---
$segmentSamples = [int](($toneDuration + $pauseDuration) * $sampleRate)
$maxInt32 = [int]::MaxValue
$maxSafeFrequency = [math]::Floor($maxInt32 / $segmentSamples)
if ($maxFrequency -gt $maxSafeFrequency) {
throw "maxFrequency=$maxFrequency exceeds safe limit $maxSafeFrequency for current settings. Reduce maxFrequency!"
}
# --- Generate a dynamic class name (timestamp-based) ---
# This ensures that each script run creates a unique C# class,
# preventing name collisions when Add-Type compiles the code.
$typeName = "AudioGen_{0}" -f (Get-Date -Format 'yyyyMMdd_HHmmss_ffff')
# --- Embedded C# code for generating tone segments ---
# This code defines a static method AddSegment() that writes a sine wave
# followed by silence into a float buffer.
$csCode = @"
using System;
namespace DynamicAudio
{
public static class $typeName
{
public static int AddSegment(
float[] buffer,
int start,
double frequency,
int sampleRate,
double toneDuration,
double pauseDuration,
bool useZeroCrossing)
{
// Number of samples for the tone portion
int toneSamples = (int)Math.Floor(toneDuration * sampleRate);
// Number of samples for the silence portion
int pauseSamples = (int)Math.Floor(pauseDuration * sampleRate);
// Phase accumulator for sine generation
double phase = 0.0;
// Phase increment per sample: 2πf / sampleRate
double step = 2.0 * Math.PI * frequency / sampleRate;
int pos = start;
int samplesWritten = 0;
// --- Tone generation loop ---
for (int i = 0; i < toneSamples; i++)
{
// Write sine sample into buffer
buffer[pos++] = (float)Math.Sin(phase);
samplesWritten++;
// Advance phase
phase += step;
// If zero-crossing mode is enabled and we are at the end of the tone:
// try to end exactly at a full cycle (phase wraps to 0)
if (useZeroCrossing && i >= toneSamples - 1)
{
if (phase >= 2.0 * Math.PI)
{
phase = 0.0; // reset phase for clean zero crossing
break;
}
}
// If zero-crossing is disabled, simply stop at the last sample
if (!useZeroCrossing && i == toneSamples - 1)
break;
// Keep phase within 0..2π to avoid numeric overflow
if (phase >= 2.0 * Math.PI)
phase -= 2.0 * Math.PI;
}
// --- Fill remaining tone duration with zeros ---
// If the tone ended early due to zero-crossing logic,
// pad the rest of the tone block with silence.
while (samplesWritten < toneSamples)
{
buffer[pos++] = 0f;
samplesWritten++;
}
// --- Silence segment ---
for (int i = 0; i < pauseSamples; i++)
buffer[pos++] = 0f;
// Return new write position
return pos;
}
}
}
"@
# --- Compile the C# code ---
# Add-Type compiles the embedded C# and returns the resulting assembly.
$asm = Add-Type -TypeDefinition $csCode -PassThru
# --- Retrieve the generated type ---
# Build the fully qualified type name including namespace and assembly.
$audioType = [Type]::GetType("DynamicAudio.$typeName, " + $asm.Assembly.FullName)
# --- WAV writer function ---
# Writes a float array as a 32‑bit IEEE float WAV file.
function Write-FloatWav {
param(
[string]$OutputFile,
[int]$SampleRate,
[System.Single[]]$FloatData
)
# Convert float array to byte array (4 bytes per float)
$byteData = New-Object byte[] ($FloatData.Length * 4)
[Buffer]::BlockCopy($FloatData, 0, $byteData, 0, $byteData.Length)
# --- NEW: robust file open with error handling ---
try {
$fileStream = [System.IO.File]::Open(
$OutputFile,
[System.IO.FileMode]::Create,
[System.IO.FileAccess]::Write,
[System.IO.FileShare]::None
)
}
catch {
throw "Could not open '$OutputFile' for writing. File is locked or in use. Error: $($_.Exception.Message)"
}
$bw = New-Object System.IO.BinaryWriter($fileStream)
try {
# --- Write WAV header (RIFF format) ---
$bw.Write([Text.Encoding]::ASCII.GetBytes('RIFF'))
$bw.Write([int]($byteData.Length + 36)) # File size minus 8 bytes
$bw.Write([Text.Encoding]::ASCII.GetBytes('WAVE'))
# Format chunk
$bw.Write([Text.Encoding]::ASCII.GetBytes('fmt '))
$bw.Write(16) # PCM header size
$bw.Write([int16]3) # Format 3 = IEEE float
$bw.Write([int16]1) # Mono
$bw.Write([int]$SampleRate)
$bw.Write([int]($SampleRate * 4)) # Byte rate
$bw.Write([int16]4) # Block align
$bw.Write([int16]32) # Bits per sample
# Data chunk
$bw.Write([Text.Encoding]::ASCII.GetBytes('data'))
$bw.Write([int]$byteData.Length)
$bw.Write($byteData)
}
finally {
# Always close file handles
$bw.Close()
$fileStream.Close()
}
}
# --- Calculate total sample count ---
$totalSamples = $segmentSamples * $maxFrequency
# Safety check: prevent integer overflow
if ($totalSamples -gt [int]::MaxValue) {
throw "Too many samples: $totalSamples (Limit: $([int]::MaxValue)). Reduce maxFrequency!"
}
# Allocate the full audio buffer
$buffer = New-Object 'System.Single[]' $totalSamples
$pos = 0
# --- Logging list ---
# Stores metadata for each generated frequency segment.
$log = New-Object System.Collections.Generic.List[string]
$log.Add("Frequency_Hz;StartSec;EndSec;StartSample;EndSample")
# --- Main loop: generate tones from 1 Hz to maxFrequency ---
for ($f = 1; $f -le $maxFrequency; $f++) {
$startSample = $pos
# Start time in seconds (integer accumulation)
$startSec = ($f - 1) * ($toneDuration + $pauseDuration)
# Call the C# AddSegment() method
$pos = $audioType::AddSegment(
$buffer,
$pos,
[double]$f,
$sampleRate,
$toneDuration,
$pauseDuration,
$useZeroCross
)
$endSample = $pos - 1
$endSec = $endSample / $sampleRate
# Add log entry
$log.Add("$f;$startSec;$endSec;$startSample;$endSample")
}
# --- Write WAV file ---
Write-FloatWav -OutputFile 'I:\yyyy\freq_1_to_max_fast.wav' -SampleRate $sampleRate -FloatData $buffer
# --- Write log file ---
$log | Set-Content -Path 'I:\yyyy\freq_1_to_max_log.txt'
Not quite communicating with submarines, I’m afraid — though the thought did make me smile .
What I’m doing is rather more down‑to‑earth: careful audio processing of pipe organ recordings.
As you may know, large historic organs can produce extraordinarily deep fundamentals, often reaching well below 20 Hz, and the acoustic environment contributes additional sub‑sonic resonance, rumble and flare. When analysing the material in iZotope RX, I found that everything beneath roughly 7.5 Hz could be removed without affecting the musical content.
However, simply cutting those frequencies introduces a number of unwanted artefacts, which you can see in the screenshots I posted. RX itself shows that some residual rubbish remains — or is even generated — after the cut.
At the moment I may have to rely on SoX or RX to handle this, but if anyone knows of better approaches or more elegant solutions, I would be very glad to read about them.
The pipe organ is more than a musical instrument; it is a complex acoustic machine. Beyond the soaring melodies and crystalline mixtures, great organs possess a “foundation” that extends well below the threshold of conscious human hearing, creating a tactile experience unique to the medium.
Gravity in Sound: 16’ and 32’ Stops
The scale of an organ’s depth is defined by its largest pipes. These massive resonators are responsible for the “gravity” of the instrument:
16-foot stops: Produce a fundamental frequency around 32 Hz, the bottom edge of most high-end consumer speakers.
32-foot stops: Produce a fundamental frequency around 16 Hz.
64-foot stops: (Extremely rare) reach down to 8 Hz, which is pure infrasound.
At these depths, the air is not just vibrating; it is being displaced. The listener doesn’t “hear” a pitch so much as they feel a shimmering pressure against their chest or a subtle shaking of the floorboards.
Infrasound and Physical Perception
In the lowest registers, the organ moves into the realm of infrasound (frequencies below 20 Hz). These are perceived not as musical notes, but as:
Acoustic Pressure: A sensation of “weight” in the ears or chest.
Slow Modulations: The “beating” or “fluttering” of air as massive columns of wind stabilize.
Atmospheric “Heaviness”: A psychoacoustic effect where the room feels physically denser during loud, low passages.
The Cathedral as a Resonator
An organ cannot be separated from its room. The building acts as the final stage of the instrument’s amplification. This interaction creates:
Standing Waves: Areas in the building where low frequencies either cancel out or double in volume.
Sub-bass Room Modes: The physical dimensions of the stone and wood vibrating in sympathy with the pipes.
Resultant Tones: When two pipes (like a 16’ and a 10 2/3’) are played together, the ear perceives a “virtual” 32’ tone, further deepening the bass floor.
Challenges in Modern Recording
Capturing these “earth-shaking” frequencies requires specialized equipment. High-fidelity recordings often reveal data that the human ear might overlook in the moment:
Sub-Harmonic Clouds: Spectrograms often show energy peaks below 10 Hz caused by the initial “chiff” (the speech) of the pipes.
The “Wind” Sound: The mechanical rush of air from the bellows and windchests, which provides an organic, breathing quality to the recording.
Structural Rumble: The sound of the organ case and the building itself reacting to the massive energy output.
In Summary
The low-end frequencies of a great pipe organ are a complex tapestry of musical fundamentals, subharmonic oscillations, and infrasound. They are essential to the majestic, tactile character of the instrument—providing a physical foundation that turns a musical performance into a full-body experience.
<#
.SYNOPSIS
Frequency Sweep Generator (1Hz to 500Hz).
.DESCRIPTION
This script generates a high-fidelity mono audio file (WAV) containing a sequence
of sine wave tones. It starts at 1Hz and increments by 1Hz up to a defined maximum.
Key Features:
- C# Integration: Uses inline C# for sample-level calculations to ensure high performance.
- Period-Aware Fading: Instead of fixed-length fades, it calculates fades based on
the signal period (3 full cycles). This prevents "thumping" or clicks in low frequencies.
- Zero-Crossing Alignment: Optionally extends tone duration to the next full sine cycle
to prevent DC offset and audio pops.
- Exponential Smoothing: Applies a natural "analog-style" volume curve at the start
and end of each tone.
- Professional Export: Outputs 32-bit IEEE Float WAV files and a detailed CSV log
mapping frequencies to specific timestamps.
.NOTES
Output Directory: I:\xxx\zzzz
Format: 32-bit Float, 48kHz, Mono
#>
# --- Global Parameters ---
$sampleRate = 48000 # Standard audio sample rate (Hz)
$toneDuration = 5.0 # Length of each individual frequency tone (seconds)
$pauseDuration = 1.0 # Length of silence between tones (seconds)
$useZeroCross = $true # Ensure tones end at a zero-crossing to prevent "clicks"
$maxFrequency = 500 # Highest frequency to generate (1Hz to 500Hz)
$basePath = "I:\xxx\zzzz" # OUTPUT PATH - PLEASE ADJUST
# --- Dynamic Class Naming ---
# Using a timestamped class name allows re-running the script in the same
# PowerShell session without "Type already exists" errors.
$dateSuffix = Get-Date -Format 'yyyyMMdd_HHmm'
$typeName = "AudioGen_$dateSuffix"
$csCode = @"
using System;
using System.IO;
namespace DynamicAudio
{
public static class $typeName
{
/// <summary>
/// Generates a sine wave segment with exponential fading and optional zero-crossing alignment.
/// </summary>
public static int AddSegment(float[] buffer, int start, double frequency, int sampleRate, double toneDuration, double pauseDuration, bool useZeroCrossing)
{
int toneSamples = (int)Math.Floor(toneDuration * sampleRate);
int pauseSamples = (int)Math.Floor(pauseDuration * sampleRate);
// Calculate fade duration based on the period of the frequency (3 cycles)
// This ensures smooth transitions even at very low frequencies.
double periodTime = 1.0 / frequency;
int fadeSamples = (int)(3.0 * periodTime * sampleRate);
// Safety checks for fade lengths
if (fadeSamples * 2 > toneSamples) fadeSamples = (int)(toneSamples * 0.45);
if (fadeSamples < 10) fadeSamples = 10;
double phase = 0.0;
double step = 2.0 * Math.PI * frequency / sampleRate;
int pos = start;
int samplesWritten = 0;
// --- TONE GENERATION LOOP ---
for (int i = 0; i < toneSamples; i++)
{
double amplitude = 1.0;
// Apply Exponential Fade-In
if (i < fadeSamples) {
amplitude = 1.0 - Math.Exp(-5.0 * i / fadeSamples);
}
// Apply Exponential Fade-Out
else if (i >= toneSamples - fadeSamples) {
int reverseIdx = toneSamples - 1 - i;
amplitude = 1.0 - Math.Exp(-5.0 * reverseIdx / fadeSamples);
}
if (pos < buffer.Length) {
buffer[pos++] = (float)(Math.Sin(phase) * amplitude);
}
samplesWritten++;
phase += step;
// Zero-Crossing Logic: If enabled, continue playing until the sine wave
// reaches the start of a new cycle (prevents DC offset clicks).
if (useZeroCrossing && i >= toneSamples - 1)
{
if (phase >= 2.0 * Math.PI) break;
}
// Keep phase within 0 and 2PI
if (phase >= 2.0 * Math.PI) phase -= 2.0 * Math.PI;
}
// --- PAUSE/SILENCE GENERATION ---
int totalExpectedSamples = toneSamples + pauseSamples;
while (samplesWritten < totalExpectedSamples)
{
if (pos < buffer.Length) buffer[pos++] = 0f;
samplesWritten++;
}
return pos; // Return new buffer position
}
/// <summary>
/// Writes raw float data to a standard RIFF/WAVE file (32-bit IEEE Float).
/// </summary>
public static void WriteWav(string path, float[] buffer, int length, int sampleRate)
{
using (FileStream fs = new FileStream(path, FileMode.Create))
using (BinaryWriter bw = new BinaryWriter(fs))
{
long dataLength = (long)length * 4; // 4 bytes per float
// RIFF Header
bw.Write(System.Text.Encoding.ASCII.GetBytes("RIFF"));
bw.Write((int)(dataLength + 36)); // Total file size - 8 bytes
bw.Write(System.Text.Encoding.ASCII.GetBytes("WAVE"));
// Format Subchunk
bw.Write(System.Text.Encoding.ASCII.GetBytes("fmt "));
bw.Write(16); // Subchunk size
bw.Write((short)3); // Audio Format 3 = IEEE Float
bw.Write((short)1); // Mono
bw.Write(sampleRate);
bw.Write(sampleRate * 4); // Byte rate (SampleRate * NumChannels * BitsPerSample/8)
bw.Write((short)4); // Block align
bw.Write((short)32); // Bits per sample
// Data Subchunk
bw.Write(System.Text.Encoding.ASCII.GetBytes("data"));
bw.Write((int)dataLength);
// Write audio data in chunks to optimize I/O performance
byte[] byteBuffer = new byte[4096 * 4];
for (int i = 0; i < length; i += 4096)
{
int count = Math.Min(4096, length - i);
Buffer.BlockCopy(buffer, i * 4, byteBuffer, 0, count * 4);
bw.Write(byteBuffer, 0, count * 4);
}
}
}
}
}
"@
# --- Compile & Get Type Reference ---
# Compiles the C# code into memory
Add-Type -TypeDefinition $csCode
# Cast the dynamically named class to a type variable for easy access
$audioType = "DynamicAudio.$typeName" -as [type]
# Ensure directory exists
if (!(Test-Path $basePath)) { New-Item -ItemType Directory -Path $basePath -Force }
# Calculate Buffer Size
# We add a small buffer (0.1s) per segment to account for Zero-Crossing extensions
$estimatedSamples = [int](($toneDuration + $pauseDuration + 0.1) * $sampleRate * $maxFrequency)
Write-Host "Initializing buffer for approx. $estimatedSamples samples..." -ForegroundColor Gray
$buffer = New-Object float[] $estimatedSamples
$pos = 0
# CSV Logging to track start/end times of each frequency
$log = New-Object System.Collections.Generic.List[string]
$log.Add("Frequency_Hz;Start_Sec;End_Sec")
Write-Host "Generating Tones (1 to $maxFrequency Hz)..." -ForegroundColor Yellow
for ($f = 1; $f -le $maxFrequency; $f++) {
$startSec = $pos / $sampleRate
# Execute C# method to fill the buffer with the sine segment
$pos = $audioType::AddSegment($buffer, $pos, [double]$f, $sampleRate, $toneDuration, $pauseDuration, $useZeroCross)
$log.Add("$f;$startSec;$($pos / $sampleRate)")
if ($f % 50 -eq 0) { Write-Host "Progress: $f Hz..." }
}
Write-Host "Saving WAV file..." -ForegroundColor Cyan
$audioType::WriteWav("$basePath\sweep_dynamic.wav", $buffer, $pos, $sampleRate)
# Save the log for reference
$log | Set-Content -Path "$basePath\sweep_log.csv"
Write-Host "Done! File saved to: $basePath" -ForegroundColor Green