A Tone You Cannot Hear, Keyed Slowly Enough for a Camera
Morse over sound, light or vibration, including carriers above adult hearing. One pulse train feeds every channel, and most of the work was finding out which of them a phone actually carries.
Type a message and send it as a tone, a screen flash or a vibration pattern. Read it back with a camera or a microphone. The tone can sit at 18 kHz, which almost nobody over twenty-five hears, and a phone microphone picks up perfectly.
Try the browser preview — no install. Your browser builds the tone, the flash and the ATAK package itself.

The camera receiver is the only optional extra. Everything else is self-contained. The interesting part is not the encoding — morse is a lookup table — it is that almost every hard problem turned out to be about measuring what a device actually does rather than what its documentation claims.
One pulse train, four channels
Everything hangs off a single intermediate: a list of pulses, each one a key-down flag and a duration in milliseconds. Every transmitter and every exporter consumes only that. ITU timing scaled by one unit of 1200 / wpm milliseconds — dot 1 unit, dash 3, gap between elements 1, between characters 3, between words 7. At the default 8 WPM the unit is exactly 150 ms.
Adding a channel is then a few lines rather than a reimplementation of morse timing. Android vibration is the same list flattened into the OFF/ON alternating array VibrationEffect.createWaveform wants. The audio path renders it as a keyed sine. The screen flash toggles a div.
The receiver benefits from the same collapse. A camera watching a lamp produces brightness over time; a microphone hearing a tone produces energy at one frequency over time. Both are a one-dimensional two-level envelope, so once a Goertzel filter reduces audio to an envelope, the camera decoder's existing thresholding handles it unchanged. None of that logic was ever audio-aware and none of it had to change.
The web page transmits from file:// — on a phone in airplane mode, from a USB stick. Listening does not, because browsers only grant microphone access in a secure context, which is why the desktop app runs a small local HTTPS server and puts the page on your phone over the wifi.
There is a Cursor-on-Target export for ATAK, and it is deliberately the only module that knows TAK exists. Nothing else imports it, two tests enforce that, and deleting it leaves the rest of the project working.
The sync mark is 14 units, not 8
Messages open with a long continuous mark so a receiver can find the start of a repeating beacon rather than joining one halfway through.
Eight units is the obvious choice and it is wrong. A dash is three times a dot, so an 8-unit mark is only 2.67× a dash — close enough that the final dash of ATTACK AT DAWN reads as a sync, the three dots after it land near 3 units of the implied unit, every consistency check passes, and the decoder confidently loses the message. At 14 units the mark is 4.67× a dash and no dot/dash ratio can imitate it. A 1200-case sweep put false positives at zero.
Two further rules were needed and both were found by failure: a mark must open the capture or follow an idle gap, and a candidate is validated only against the pulses before the next mark, so a repeating beacon does not disqualify every candidate in the recording.
The mark also hands you a deterministic unit — divide by 14 — which feels strictly better than inferring one by clustering. It is not. It is one pulse, and jitter on that single measurement scales every threshold for the whole message. The clustered estimate from the body wins; the mark's value is kept only as a sanity bound.
Nyquist is enforced, not warned about
A high-frequency file has to be written at 48 kHz. An 8 kHz file cannot represent anything above 4 kHz, and quietly writing one would alias the tone down into the audible band — the exact opposite of what was asked for. So requesting a rate that cannot carry the frequency is an error rather than a surprise.
The envelope matters just as much. A hard-keyed 18 kHz tone is not silent: every edge is a step, a step contains every frequency, and you hear a train of ticks. A 5 ms raised-cosine ramp removes them. Measured afterwards, the audible band sits at the 16-bit noise floor, 82 dB below the carrier.
It is not silent. It is inaudible to you.
What broke
It decoded perfectly and blamed the wrong band. The carrier finder compared candidate frequencies after normalising each to its own peak, so a faint tone looked identical to a strong one. It confidently picked 700 Hz out of an 18 kHz recording and sometimes decoded anyway, because keying transients leak a little energy into every band. Wrong metadata attached to a right answer is worse than an error, because nothing looks broken. Normalisation belongs at the point of use, never before a comparison.
A 9 dB gate transcribed an empty room. Narrowband noise energy is Rayleigh distributed, so a band's own 98th percentile sits about 11 dB above its 25th with nothing transmitting at all. The listener duly read pages of IEEEI LTUEEL out of silence. The gate is 20 dB now. That failure was invisible to every unit test and obvious within seconds of watching the page run, which is the argument for doing both.
A receiver measured its own baseline wrong. The rolling baseline used to track exposure drift was shorter than a 14-unit mark at slow speeds, so the mark dominated its own background window and had its tail subtracted away — a 2100 ms mark measured 1270 ms. The general rule, which applies to the camera path too: any window used to estimate a background must be long enough that no single foreground event can dominate it.
The one-time pad was malleable. A pad gives perfect secrecy and no integrity whatsoever: flipping ciphertext letters turns RETREAT into ADVANCE and the recipient cannot tell. Encryption without authentication is not a secure channel, it is a private one. The fix is a one-time polynomial MAC over the plaintext, keyed with fresh pad letters — authenticating the plaintext rather than the ciphertext, because ciphertext authentication happily verifies a message decrypted at the wrong pad offset. Pairing an unconditionally secret cipher with a computationally secure MAC would let the weaker half set the strength of the whole.
205 tests, about two minutes, no network. Two of them lift JavaScript straight out of the web pages and run it under node, because a throttled background browser tab never reaches the thresholds they are checking.
You do not beat a perceptual codec, you avoid needing to
Ultrasonic morse kept failing over Bluetooth, and the instinct was to fight the codec — send louder, send slower, add redundancy. Measuring first showed all three are the wrong idea.
Sweeping 10–23 kHz through the real A2DP encoders and decoding what came back:
| codec | 14k | 16k | 17k | 17.5k | 18k | 20k |
|---|---|---|---|---|---|---|
| SBC | −6 | −6 | −6 | −6 | −7 | −8 |
| aptX | −6 | −6 | −6 | −6 | −7 | −8 |
| AAC 128k stereo | −6 | −6 | −6 | −55 | −67 | −73 |
Fifty decibels between 17.0 and 17.5 kHz is not a psychoacoustic judgement made tone by tone. It is a brick-wall lowpass, and its corner moves with the bits available per channel — the same 128 kbps in mono passes everything the stereo version destroys.
That explains the two results that looked strange. Dropping 8 WPM to 3 WPM rescued nothing: more integration time helps against noise, and this is not noise. The band is simply absent, and no amount of looking at absence produces a signal. Amplitude made no difference either, for the same reason — a filter does not care how loud the thing it removed was.
The useful finding is that SBC passes the whole ladder flat at every bitrate down to 96 kbps. SBC has no perceptual model: it allocates bits by subband energy, so it never forms an opinion about audibility and never deletes a tone for being inaudible. Being crude is exactly what makes it transparent, and it is mandatory in A2DP, so every Bluetooth speaker has it.
So the Bluetooth-safe mode keys 17 and 19 kHz simultaneously. Not because two are needed — 17 alone clears every chain worth naming — but because neither end can discover which codec was negotiated, and the receiver already scans the ladder. Amplitude is divided between the carriers rather than summed at full scale: clipping is broadband, and a clipped edge would put audible harmonics into a signal whose entire point is being inaudible.
Lit is not the same as carrying
The live band meter reported all eight bands working, including 21 kHz, which no laptop speaker and phone microphone pair is likely to manage. It was measuring honestly and concluding wrongly.
A sweep transmits one band at a time, so at any instant at most one band should be lit. But energy at a frequency cannot distinguish a carrier from a leak, and every key-down is a broadband transient. Eight amber bars and one green one is one working channel and seven leaks — and it is the reading most easily mistaken for a great result. The meter credits only the winner of each sample, and only when it beats the runner-up clearly.
There is a test card as well: every rung of the ladder, labelled, with what to expect from each.
The same instinct runs through the whole project. A failed decode is the least useful possible answer: it collapses the speaker, the room, the microphone, the capture settings and the decoder into one word. Whether 18 kHz works is not a fact about 18 kHz. It is a fact about a particular microphone, speaker and codec.
The thing I would tell you before you start: build the instrument that tells you which layer is broken before you start tuning the layer you suspect. Every hour lost on this project was spent guessing which of five things had failed, and every one of those hours ended with a measurement that took ten minutes.

Comments