Introduction

Digital information is lossless. You can copy a file from hard disk to floppy to CD to flash stick to ... and it will still be the same file. But if you use analog media, such as good old tape, you can rely on the opposite: The data you read from the medium will definitely be different from what you wrote onto it.
Anyway, there are many ways to hide secret information in the sound on an audio tape. I'll explain a quite simple one. All you need to do, before you can try it, is to take a tape recorder, connect its microphone port to the headphone (or line-out) port of your computer, and the microphone (or line-in) port of the computer to the recorder's headphone port.
This article uses code from A full-duplex audio player in C# using the waveIn/waveOut APIs. The application requires Sound Exchange (SoX).
The Idea
After recording a sound onto an audio cassette, and re-recording it into a Wave file, we cannot rely on the binary data to be the same or in any way similar. So, changing a few bits is not enough anymore. We have to change the sound in a way we can recognize, even behind loads of noise and arbitrary changes.
Your first idea may be to insert a very short beeeep every n seconds, where n is a byte from the secret message. The recipient can extract the noises with a band pass filter, put the intervals into a stream, and just read the message from it. But trying that, you'll soon run out of tape:
A = 65
B = 66
C = 67
ABC = 65 + 66 + 67 = 198
Inserting a recognizable frequency in intervals that stand for the hidden bytes, we would need 198 seconds only for a short message like ABC.
Your second idea may be to split each secret byte into high and low half-byte, so that the maximum interval between two beeps is 15, and no byte can fill more than 30 seconds on the tape. For example, the character "z", that would block 122 seconds by the first try, needs only 17 seconds this way:
z = 122 = 1111010
half bytes = 7 (0111) and 10 (1010)

That is exactly what this article's application does. It allows you to search the carrier wave for an unused or low-volume frequency, and inserts very short, hardly hearable noises of just that frequency. You can then play the result, and record it with the tape recorder. To extract the hidden message, you just play the tape, record the sound with an audio recorder software (such as GoldWave etc.), and remove the silence from beginning and end. Then you can open the file with this application, enter the frequency that had been used for hiding, and watch as a band pass filter isolates the beeps and your message gets re-constructed.
How it Works
Hide a Message
While hiding a message, the user performs five steps:
- Select a Wave file, and enter the secret message.
- Check if the message fits into the sound, and shorten it, if necessary.
- Guess a frequency that occurs only in low volume, or not at all.
If "Check sound" produces a warning, raise the threshold volume or frequency until the warning disappears.
- Write the new sound.
- Play the new sound, and record it onto the tape.

Steps three and four are not self-explaining. Let's start with step three: Checking a frequency for existence and maximum volume. The user guesses a frequency and clicks the "Check sound" button. To check if the highest amplitude of such a frequency is lower than the selected volume value (that means, we can use the selected combination), we first have to isolate the frequency with a band pass filter. SoX does that for us. Then we compare the result's samples to the selected maximum amplitude (call it volume, that's nearly the same in this case), and count the samples that are too loud.
String outFileName = waveUtility.FindFrequency(
Path.GetTempPath(),
soxPath,
(int)numFrequency.Value);
WaveUtility filterUtility = new WaveUtility(outFileName);
WaveSound waveSound = filterUtility.WaveSound;
long countLoudSamples = 0;
short minVolumne = (short)(numHideVolume.Value - 100);
short[] samples = waveSound.Samples;
for (int n = 0; n < samples.Length; n++) {
if (Math.Abs(samples[n]) > minVolumne) {
countLoudSamples++;
}
}
if (countLoudSamples > 0) {
MessageBox.Show(String.Format("The Frequency might be" +
" a bad choice, because there are already {0} " +
"too loud samples in the sound.", countLoudSamples));
errorProvider.SetError(numHideFrequency,
"Frequency not fitting, oder selected volume too low.");
} else {
errorProvider.SetError(numHideFrequency, String.Empty);
}
Just for the case that you're interested in how waveUtility.FindFrequency
works: it combines the parameters to an argument string, calls SoX, and reads the error output (if any).
public String FindFrequency(String tempDirectory, String soxPath, int frequency)
{
String inFileName = Path.Combine(tempDirectory, "in.wav");
String outFileName = Path.Combine(tempDirectory, "out.wav");
int fileLength = this.WaveSound.SaveToFile(inFileName);
String soxArguments = String.Format(
"-t wav \"{0}\" -t .wav -c 1 -s -w \"{1}\" band {2} 10",
inFileName,
outFileName,
frequency);
RunSox(soxPath, soxArguments);
return outFileName;
}
private void RunSox(String soxPath, String soxArguments)
{
ProcessStartInfo startInfo = new ProcessStartInfo(
soxPath,
soxArguments);
startInfo.RedirectStandardError = true;
startInfo.UseShellExecute = false;
Process sox = Process.Start(startInfo);
StreamReader errorReader = sox.StandardError;
String errors = errorReader.ReadLine();
if (errors != null) {
throw new ApplicationException("sox failed: " + errors);
}
sox.WaitForExit(10000);
}
Once we have found a frequency/amplitude that will be unique in the carrier sound, so that it won't get mixed up with innocent samples when extracting the message, we can hide the secret stream.
public void Hide(Stream message, int frequencyHz, int volumne)
{
Stream preparedMessage = PrepareMessage(message);
int messageByte;
int offset = 0;
while ((messageByte = preparedMessage.ReadByte()) > -1) {
offset += messageByte;
InsertBeep(offset, frequencyHz, volumne);
}
}
The short piece of code above calls two important methods, PrepareMessage
and InsertBeep
. PrepareMessage
takes the stream with the secret message, and splits each byte in two half-bytes. The interval between two beeps cannot be zero seconds, but some half-bytes may be zero, so every value in the prepared stream will be halfByte
+ 1. Later on, the extracting method will have to subtract 1 from every interval, to get the original half-bytes.
private Stream PrepareMessage(Stream message)
{
message.Position = 0;
MemoryStream preparedMessage = new MemoryStream();
int messageByte;
int highHalfByte;
int lowHalfByte;
while ((messageByte = message.ReadByte()) > -1) {
highHalfByte = (messageByte >> 4);
lowHalfByte = (messageByte - (highHalfByte << 4));
preparedMessage.WriteByte((byte)(highHalfByte + 1));
preparedMessage.WriteByte((byte)(lowHalfByte + 1));
}
preparedMessage.Position = 0;
return preparedMessage;
}
What are we waiting for? We have the sound, the prepared messages stream, and a useable frequency. That's all we need to insert little noises at the specific seconds, which is the job of CreateBeep
and InsertBeep
.
private WaveSound CreateBeep(int frequencyHz, int volumne)
{
short[] samples = new short[waveSound.Format.SamplesPerSec / 32];
double xValue = 0;
short yValue;
double xStep = (2 * Math.PI) / waveSound.Format.SamplesPerSec;
xStep = xStep * frequencyHz;
for (int n = 0; n < samples.Length; n++) {
xValue += xStep;
yValue = (short)(Math.Sin(xValue) * volumne);
samples[n] = yValue;
}
WaveSound beep = new WaveSound(waveSound.Format, samples);
return beep;
}
public void InsertBeep(float insertAtSecond, int frequencyHz, int volumne)
{
short[] beepWave = CreateBeep(frequencyHz, volumne).Samples;
int insertAtSample = (int)(waveSound.Format.SamplesPerSec * insertAtSecond);
int longWaveIndex = insertAtSample;
for (int index = 0; index < beepWave.Length; index++) {
waveSound[longWaveIndex] = beepWave[index];
longWaveIndex++;
}
}
After Hide
has finished the while
-loop, the complete message stream has been beeped into the sound. If you chose a fitting frequency an a not too high amplitude, it is hardly hearable (if not, it can still seem as usual noise to the average human ear, when played on average hardware). You can save the result to a .wav file, or play and record it without saving.
Extract a Message
Before reading a hidden message, the user has to filter the recorded sound. If the tape player added very bad noise in just our frequency, so that wrong beeps are detected, those errors can be deselected.
- Enter the frequency of the expected beeps, and band pass filter the sound. The second filter button - threshold volume - is not really necessary. You can use it to remove samples from the graphic, that will anyway be treated as silence.
- Find the noises. A beep is a group of samples that are greater than the selected threshold volume. In the graphic, the beginning and end of each detected beep are marked with red lines. The CheckBoxes allow you to exclude single beeps from evaluation, if you're sure that they don't belong to the message.
- Read the message. The last step lists the intervals between the selected noises, and re-constructs the hidden message.

"Filter sound" applies the same band pass filter we already know. This time, we don't count big samples, but open and display the filtered wave.
String outFileName = waveUtility.FindFrequency(
Path.GetTempPath(),
soxPath,
(int)numExtractFrequency.Value);
waveControl.OpenFromFile(outFileName);
Every beep is 1/32 seconds long. That's not much for human ears, but it's a lot of samples. The groups of samples are separated by silence (due to band pass filter and threshold volume), so we can move a scan window over the samples, and define a piece of silence as all samples in the current time window are below the threshold. Samples above the threshold are treated as parts of one beep, if there is no silence between them.
public void FindAnything(short tolerance) {
int scanWindowSize = waveSound.Format.SamplesPerSec / beepLength;
float scanWindowSizeSeconds = (float)scanWindowSize /
(float)waveSound.Format.SamplesPerSec;
int startIndex = -1;
int endIndex = -1;
int countSilentSamples = 0;
for (int n = 0; n < waveSound.Count; n++) {
if (Math.Abs(WaveSound[n]) > tolerance) {
countSilentSamples = 0;
if(startIndex < 0){
startIndex = n;
}
} else if (startIndex > -1) {
countSilentSamples++;
if (countSilentSamples == scanWindowSize) {
endIndex = n - scanWindowSize;
NotifyOnBeep(startIndex, endIndex, scanWindowSizeSeconds);
countSilentSamples = 0;
startIndex = -1;
}
}
}
if (startIndex > -1) {
NotifyOnBeep(startIndex, waveSound.Count-1, scanWindowSizeSeconds);
}
}
When the user finally clicks the "Read message" button, all information has already been extracted, we only have to put the half-bytes together.
private void btnExtract_Click(object sender, EventArgs e)
{
Collection<Beep> selectedItems = waveControl.SelectedItems;
Collection<float> startSeconds = new Collection<float>();
foreach (Beep beep in selectedItems) {
startSeconds.Add(beep.StartSecond);
}
Stream messageStream = waveUtility.Extract(startSeconds);
StreamReader messageReader = new StreamReader(messageStream, Encoding.Default);
String message = messageReader.ReadToEnd();
messageReader.Close();
txtExtractMessage.Text = message;
}
Sounds like an Example Wave
If you don't have Wave files at hand, for playing with the application, feel free to use these example records: demoWaves.zip.
- 101seconds.wav - 101 seconds long original sound (composed and played by me).
- result_f2500_v2000.wav - The wave with "! C# RULES !" hidden at 2500Hz with an amplitude of 2000.
- record_f2500_v2000.wav - Recorded by my notebook from the tape player on the photo, via headphone/microphone ports. Just try it, "! C# RULES !" can still be extracted without problems.