On this page
Imagine standing in a field with two friends. Somewhere nearby, a balloon pops. You all hear the same sound, but it reaches each of you at a slightly different time.
That small difference is enough to start working out where the pop came from.
For a useful calculation, we replace the three listeners with microphones. We measure where each microphone is, record the same sound on all three, and compare when it arrives. The rest is geometry.
Let's walk through it with a simple example, then look at what happens when we try it outside a textbook.
Loading interactive example…
<style> *{box-sizing:border-box}body{margin:0;padding:0;background:#1b1922;color:#eee7f6;font:15px/1.7 system-ui,sans-serif}h2,p{margin:0}button,input{font:inherit}button{cursor:pointer}button:focus-visible,input:focus-visible{outline:3px solid #d6b6ef;outline-offset:4px}.sound-demo{padding:28px}.eyebrow{font-size:10px;letter-spacing:1.6px;color:#bda2d7}.demo-heading h2{font-size:clamp(23px,4vw,30px);font-weight:500;line-height:1.35;margin:12px 0}.demo-heading>p:last-child{font-size:13px;color:#c0b3cd;max-width:650px}.demo-layout{display:grid;grid-template-columns:minmax(0,1.2fr) minmax(270px,1fr);gap:25px;margin:25px 0}.sound-map{min-width:0}.sound-map svg{width:100%;height:auto;display:block;touch-action:none;cursor:crosshair;user-select:none}.sound-map text{font:13px system-ui;fill:#b4a6c1}.microphone circle{fill:#9cabb8}.microphone text{fill:#d5c9e0}.map-caption{font-size:10px;color:#aa9db7;text-align:center;line-height:1.7;margin-top:8px}.demo-panel{min-width:0;padding-top:10px}.source-controls label{display:flex;justify-content:space-between;font-size:12px;color:#c0b4cc;margin-bottom:10px}.source-controls output{font-variant-numeric:tabular-nums;color:#e0c8f4}.source-controls input{width:100%;min-height:35px;accent-color:#ba9ad8;margin:0 0 17px;cursor:pointer}.arrival-table{width:100%;border-collapse:collapse;font-size:11px;font-variant-numeric:tabular-nums;white-space:nowrap}.arrival-table caption{font-size:10px;color:#b1a1bf;text-align:left;padding-bottom:12px}.arrival-table th,.arrival-table td{padding:12px 3px;border-bottom:1px solid #ffffff18;text-align:right}.arrival-table th:first-child{text-align:left}.arrival-table thead th{font-weight:400;color:#bdabd0}.arrival-table tbody th{font-weight:400;color:#d9c0ed}.sound-insight{font-size:12px;line-height:1.8;color:#c6b2d9;min-height:65px;margin-top:17px}.demo-reset{border:1px solid #ffffff30;background:transparent;color:#d1c1e0;border-radius:999px;padding:10px 15px;min-height:44px;font-size:12px}.demo-reset:hover{background:#ffffff0a}.demo-note{font-size:11px;line-height:1.9;color:#b3a6c0;border-top:1px solid #ffffff18;padding-top:20px}@media(max-width:680px){.sound-demo{padding:22px 16px}.demo-layout{grid-template-columns:1fr;gap:10px;margin-top:15px}.sound-map svg{max-height:330px}.demo-panel{padding-top:0}.source-controls{display:grid;grid-template-columns:1fr 1fr;gap:0 22px}.source-controls label{grid-row:1}.source-controls input{grid-row:2}.sound-insight{min-height:45px}.arrival-table{font-size:11px}.demo-note{font-size:10px}}@media(prefers-reduced-motion:reduce){*{scroll-behavior:auto!important;animation:none!important;transition:none!important}} </style> <section class="sound-demo" data-sound-demo aria-labelledby="sound-demo-title"> <div class="demo-heading"><p class="eyebrow">TRY THE IDEA</p><h2 id="sound-demo-title">One sound. Three arrival times.</h2><p>Move the source and watch the geometry change. Drag the dot, tap the map, or use the sliders.</p></div> <div class="demo-layout"><div class="sound-map"><svg viewBox="0 0 600 480" role="img" aria-label="Microphones A at zero zero, B at ten zero and C at zero ten metres. The movable sound source is controlled with the sliders below."> <defs><pattern id="sound-grid" width="36" height="36" patternUnits="userSpaceOnUse"><path d="M36 0H0V36" fill="none" stroke="#ffffff0e"/></pattern></defs> <rect x="48" y="24" width="504" height="432" rx="12" fill="#111219"/><rect x="48" y="24" width="504" height="432" fill="url(#sound-grid)"/> <path d="M120 24V384H480" fill="none" stroke="#ffffff35"/><text x="470" y="420">10 m</text><text x="65" y="34">10 m</text> <g data-sound-lines></g><g class="microphone"><circle cx="120" cy="384" r="7"/><text x="98" y="411">A</text><circle cx="480" cy="384" r="7"/><text x="492" y="389">B</text><circle cx="120" cy="24" r="7"/><text x="98" y="48">C</text></g> <g data-sound-source><circle r="22" fill="#bba0dc20"/><circle r="9" fill="#ceb4ef" stroke="#f3e9ff" stroke-width="2"/></g> </svg><p class="map-caption">Coordinates in metres · A (0, 0), B (10, 0), C (0, 10)</p></div> <div class="demo-panel"><div class="source-controls"><label for="sound-x">Source X <output for="sound-x" data-sound-x-value>3.0 m</output></label><input id="sound-x" data-sound-x type="range" min="-1" max="11" step="0.1" value="3"><label for="sound-y">Source Y <output for="sound-y" data-sound-y-value>4.0 m</output></label><input id="sound-y" data-sound-y type="range" min="-1" max="10" step="0.1" value="4"></div> <table class="arrival-table"><caption>Travel time and delay relative to A</caption><thead><tr><th scope="col">Mic</th><th scope="col">Distance</th><th scope="col">Arrival</th><th scope="col">Δ vs A</th></tr></thead><tbody data-sound-values></tbody></table> <p class="sound-insight" data-sound-insight role="status" aria-live="polite"></p><button type="button" class="demo-reset" data-sound-reset>Reset example ↺</button></div></div> <p class="demo-note">Illustrative 2D model: direct paths, microphones at the same height and sound travelling at 343 m/s (approximately 20°C). Arrival times start at emission; delays compare each microphone with A. This demonstrates forward travel times, not a solver for real recordings.</p><noscript><p>Enable JavaScript to move the source. At (3, 4), A hears the sound after 14.58 ms, B after 23.51 ms and C after 19.56 ms.</p></noscript></section> <script> document.querySelectorAll('[data-sound-demo]').forEach(demo => { const svg = demo.querySelector('svg'); const xInput = demo.querySelector('[data-sound-x]'), yInput = demo.querySelector('[data-sound-y]'); const source = demo.querySelector('[data-sound-source]'); const lines = demo.querySelector('[data-sound-lines]'), rows = demo.querySelector('[data-sound-values]'); const ns = 'http://www.w3.org/2000/svg'; const microphones = [{name:'A',x:0,y:0}, {name:'B',x:10,y:0}, {name:'C',x:0,y:10}]; const cells = microphones.map(mic => { const line = document.createElementNS(ns, 'line'); line.setAttribute('stroke', '#baa2d380'); line.setAttribute('stroke-width', '2'); line.setAttribute('x1', 120 + mic.x*36); line.setAttribute('y1', 384 - mic.y*36); lines.append(line); const row = document.createElement('tr'); const heading = document.createElement('th'); heading.scope = 'row'; heading.textContent = mic.name; row.append(heading); const values = Array.from({length:3}, () => { const cell = document.createElement('td'); row.append(cell); return cell; }); rows.append(row); return {line, values}; }); let statusTimer; const update = () => { const x = Number(xInput.value), y = Number(yInput.value); demo.querySelector('[data-sound-x-value]').value = `${x.toFixed(1)} m`; demo.querySelector('[data-sound-y-value]').value = `${y.toFixed(1)} m`; xInput.setAttribute('aria-valuetext', `${x.toFixed(1)} metres`); yInput.setAttribute('aria-valuetext', `${y.toFixed(1)} metres`); source.setAttribute('transform', `translate(${120+x*36} ${384-y*36})`); const distances = microphones.map(mic => Math.hypot(x-mic.x,y-mic.y)); distances.forEach((distance,index) => { cells[index].line.setAttribute('x2',120+x*36); cells[index].line.setAttribute('y2',384-y*36); cells[index].values[0].textContent = `${distance.toFixed(2)} m`; cells[index].values[1].textContent = `${(distance/343*1000).toFixed(2)} ms`; const delta = (distance-distances[0])/343*1000; cells[index].values[2].textContent = `${Math.abs(delta) < .005 ? '0.00' : (delta > 0 ? '+' : '−')+Math.abs(delta).toFixed(2)} ms`; }); clearTimeout(statusTimer); statusTimer = setTimeout(() => { const first = Math.min(...distances); const names = microphones.filter((_,i) => Math.abs(distances[i]-first)<.0001).map(mic=>mic.name); demo.querySelector('[data-sound-insight]').textContent = names.length === 3 ? 'All three microphones hear the sound together.' : `${names.join(' and ')} ${names.length > 1 ? 'hear' : 'hears'} the sound first. Negative delays mean earlier than A.`; },200); }; xInput.addEventListener('input',update); yInput.addEventListener('input',update); let dragging = null; function move(event) { const matrix = svg.getScreenCTM(); if (!matrix) return; const point = new DOMPoint(event.clientX,event.clientY).matrixTransform(matrix.inverse()); xInput.value = Math.max(-1,Math.min(11,(point.x-120)/36)).toFixed(1); yInput.value = Math.max(-1,Math.min(10,(384-point.y)/36)).toFixed(1); update(); } svg.addEventListener('pointerdown',event => { if (event.button !== 0 || !event.isPrimary) return; dragging = event.pointerId; svg.setPointerCapture(event.pointerId); move(event); }); svg.addEventListener('pointermove',event => { if (dragging === event.pointerId) move(event); }); const stop = () => { dragging = null; }; svg.addEventListener('pointerup',stop); svg.addEventListener('pointercancel',stop); svg.addEventListener('lostpointercapture',stop); demo.querySelector('[data-sound-reset]').addEventListener('click',() => { xInput.value = '3'; yInput.value = '4'; update(); }); update(); }); </script>Interactive preview
Start with the travel time
Sound travels through air at about 343 metres per second at 20°C. The exact speed changes with conditions, but this is a reasonable starting value.
If a sound travels 10 metres, it takes about 29 milliseconds to get there. A microphone five metres away hears it sooner than one eight metres away.
The useful relationship is:
Extra distance travelled = sound speed × extra travel time
Suppose microphone B hears a pop 10 milliseconds after microphone A. Convert milliseconds into seconds first:
10 milliseconds = 0.010 seconds
343 × 0.010 = 3.43 metres
The source is 3.43 metres farther from B than from A. We still don't know how far it is from either microphone. We only know the difference.
That distinction matters. A time difference gives us a distance difference, not a direct distance to the sound.
Three microphones, one shared recording
Place the microphones in a triangle. For our example, use these coordinates, in metres:
| Microphone | x | y |
|---|---|---|
| A | 0 | 0 |
| B | 10 | 0 |
| C | 0 | 10 |
Think of A as the bottom-left corner of a map. B is 10 metres to its right. C is 10 metres above it.

Figure 1. A known source position lets us check the example. The circles illustrate sound spreading outwards; the coloured lines show the three travel distances.
For now, assume the microphones and source are all at the same height. We are solving for a position on a flat map. Also assume a direct path to each microphone and a constant sound speed.
Choose A as the reference. We need two measurements:
- How much later B hears the sound than A.
- How much later C hears the sound than A.
If another microphone hears it before A, its delay is negative. A is simply our reference, so it does not have to hear the sound first.
This method is called time difference of arrival, usually shortened to TDOA.
Follow a sound from start to finish
Let's put the source at (3, 4). That means three metres right and four metres up from A.
We know the answer in this example because we're creating a test case. In a real recording, this is the position we would be trying to find.
The distance between two points comes from Pythagoras:
distance = sqrt((source_x - microphone_x)²
+ (source_y - microphone_y)²)
Here, sqrt means square root. Apply it to each microphone:
| Microphone | Distance from the source | Arrival after the sound starts | Delay relative to A |
|---|---|---|---|
| A | 5.000 m | 14.58 ms | 0 ms |
| B | 8.062 m | 23.51 ms | +8.93 ms |
| C | 6.708 m | 19.56 ms | +4.98 ms |
Values are rounded for readability. The order is A, then C, then B.
For B, subtract A's travel distance and divide by the sound speed:
delay at B = (8.062 - 5.000) / 343
≈ 0.00893 seconds
≈ 8.93 milliseconds
For C, the same calculation gives about 4.98 milliseconds.

Figure 2. These are synthetic waveforms, not field recordings. Their heights are kept equal to make the timing easier to see.
Notice that we don't need to know when the balloon actually popped. We can set A's arrival to zero and compare everything with it. Subtracting the arrival times removes the unknown start time.
Turning those delays into a location
Now reverse the problem. Forget that we placed the source at (3, 4). All we have are the microphone coordinates and two measured delays.
B's delay tells us that the source is about 3.062 metres farther from B than from A. Many positions fit that description. On a map, they form a curved line called a hyperbola.
C's delay tells us that the source is about 1.708 metres farther from C than from A. That gives us another curve.
Where the curves meet, both measurements agree.

Figure 3. Each curve represents one timing constraint. The intersection in this search area matches our original source position.
MathWorks' TDOA example explains this geometry in more detail.
You may hear this called triangulation. Strictly speaking, triangulation uses angles. Here we use differences in travel distance, so multilateration is the more precise term.
The equations behind the picture
Call the unknown source position (x, y). Its distances to the three microphones are:
rA = sqrt(x² + y²)
rB = sqrt((x - 10)² + y²)
rC = sqrt(x² + (y - 10)²)
The measured delays give us two equations:
rB - rA = 343 × delay_BA
rC - rA = 343 × delay_CA
Use seconds for the delays. With our measurements, the right sides are approximately 3.062 metres and 1.708 metres.
A numerical solver tries positions and adjusts them until the predicted distance differences match the measurements. For noisy recordings, we ask it to find the best fit rather than expecting a perfect match.
There are three microphone pairs, but only two independent delays. Once we know B relative to A and C relative to A, we can calculate B relative to C. The third comparison is useful as a consistency check, but it doesn't add a new independent position constraint.
Finding the delays in actual audio
Looking for the tallest peak in each recording can work in a clean demonstration, but it can fail in a room. An echo might be louder than the first arrival.
A common approach is cross-correlation. Imagine sliding one recording sideways against another. At each shift, the software measures how well their patterns line up. A strong match suggests a likely delay.
Researchers at Clemson explain how cross-correlation relates to acoustic localization.
For a first experiment, use a short, distinct sound such as a hand clap in a quiet space. Compare the same short section of audio on all three channels. Limit the delay search to what is physically possible for the microphone spacing.
Be careful with the sign. Different software functions use different lag conventions. Before working with real audio, test your code with a copy of a signal shifted by a known number of samples. Confirm that a later arrival produces the positive delay expected by your position equations.
The clocks must agree
This is one of the easiest parts to underestimate.
Three phones recording at the same time do not automatically have synchronized audio. Their recordings may start at different moments, and their clocks can drift. A recorder's clock offset looks like a sound travel delay unless you correct for it.
For a first build, connect three microphones to one multichannel recorder or audio interface with a shared sample clock. Check for fixed differences in channel delay too, especially if the channels use different processing.
At a sample rate of 48 kHz:
time per sample = 1 / 48,000
≈ 20.8 microseconds
sound travel in that time = 343 / 48,000
≈ 7.1 millimetres
That does not mean the system locates sounds to within 7.1 millimetres. It describes one sample's worth of travel distance. Position accuracy also depends on noise, signal bandwidth, echoes and microphone placement.
Why microphone placement matters
Put all three microphones on a straight line and a sound on one side can have exactly the same distances as its mirror image on the other side. The timings alone cannot tell them apart.

Figure 4. The right-hand layout cannot distinguish the two stars without extra information. A triangle avoids this particular mirror ambiguity.
A triangle is a better starting point, especially when it surrounds the area you want to monitor. It still doesn't guarantee a unique answer in every case. Even with three microphones spread across a plane, some timing measurements can fit more than one source position.
Source distance matters too. If the microphones occupy a small area and the sound is far away, the incoming wave is almost flat across the array. Direction can be easier to estimate than range. Small timing errors may then move the estimated position a long way.
Use a known search area where possible, try several solver starting points, and add microphones when you need more confidence.
A small Python example
The following example starts with the two synthetic delays from our calculation. It solves the geometry only. It does not open audio files or estimate their timing offsets.
Install NumPy and SciPy with python -m pip install numpy scipy, then save and run this code:
import numpy as np
from scipy.optimize import least_squares
speed = 343.0
mics = np.array([[0., 0.], [10., 0.], [0., 10.]])
# Synthetic delays in seconds, relative to microphone A.
delays = np.array([
0.008927865155389356,
0.004980186392126442,
])
def residual(position):
distances = np.linalg.norm(mics - position, axis=1)
return distances[1:] - distances[0] - speed * delays
# Explicit search area for this example, in metres.
lower = np.array([-2., -2.])
upper = np.array([12., 12.])
candidates = []
for x in np.linspace(-1.5, 11.5, 5):
for y in np.linspace(-1.5, 11.5, 5):
fit = least_squares(
residual, [x, y], bounds=(lower, upper),
xtol=1e-12, ftol=1e-12, gtol=1e-12,
)
if not fit.success:
continue
error = np.linalg.norm(fit.fun)
is_new = all(
np.linalg.norm(fit.x - p) > 1e-3
for p in candidates
)
# Tight acceptance is suitable for this noise-free test.
if error < 1e-6 and is_new:
candidates.append(fit.x)
for point in candidates:
print(f"Candidate: x={point[0]:.3f} m, y={point[1]:.3f} m")
if not candidates:
print("No exact candidate found inside the chosen search area.")
For these inputs, the result is:
Candidate: x=3.000 m, y=4.000 m
The search bounds are an assumption we chose for this demonstration. They are not something the microphones measured. The multiple starting points help find candidates, but they are not a mathematical guarantee that every solution has been found.
For real measurements, use a tolerance based on timing uncertainty, inspect the fit error, and report uncertainty alongside the estimated position. A tiny residual alone doesn't prove the position is accurate, particularly with only two independent measurements.
Checks worth doing before trusting a result
Check the units. Mixing milliseconds with seconds introduces a factor of 1,000. Keep coordinates in metres and delays in seconds throughout the solver.
Check the largest possible delay. Two microphones 10 metres apart cannot have a direct-path arrival difference greater than about 29.15 milliseconds at 343 m/s. A larger value points to a timing problem or the wrong event. This check assumes synchronized, calibrated channels and a common propagation speed.
Check for echoes. Walls, floors and buildings create extra arrivals. If one channel aligns to an echo and another to the direct sound, the delays describe different paths.
Check the environment. Temperature changes sound speed. Wind can affect travel time differently in different directions. For longer distances, a single fixed speed may be too rough an approximation.
Check against known positions. Make several recordings with the source at measured locations. Compare the estimated positions with those locations, including positions near the edge of the array. This tells you much more than one successful test in the middle.
What if the sound is above us?
Our example solves two coordinates. A flying drone or a sound on another floor adds an unknown height.
Three microphones provide only two independent time differences, which is generally insufficient for an unconstrained three-dimensional position. At least four microphones are needed to supply three independent differences. Their layout still matters, and more sensors or other information may be needed to resolve ambiguity.
For example, microphones all lying in one plane can leave a mirror ambiguity above and below that plane. A flat-map calculation is useful when height is known or its effect is acceptably small. Otherwise, include height in the model.
Trying it yourself
Start with three microphones on a shared recorder, a tape measure, and a quiet space with as few hard reflecting surfaces as possible. Mark a triangle and write down the microphone coordinates. Make a clap at a measured point, compare the recordings, and solve for its location.
Then move the clap and repeat. Try a point outside the triangle. Try a point farther away. Watch how the estimate changes as the timing gets harder to measure.
The calculation is manageable. Getting reliable measurements is where most of the work sits. Once the recordings share a clock and the microphone positions are known, those few milliseconds become something you can put on a map.
THE CONVERSATION
What do you think?
A question, a different approach, or something worth adding.
Loading the conversation…
START A CONVERSATION
Have a problem worth solving?
Let’s talk about your idea, your team, or the work you’ve seen here.


