├── .gitignore ├── dist ├── output.wav ├── RTTY_170Hz_45.45Bd.mp3 └── index.html ├── shell.nix ├── babel.config.json ├── package.json └── src └── index.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist/main.js 3 | -------------------------------------------------------------------------------- /dist/output.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mgdm/rtty-decoder/main/dist/output.wav -------------------------------------------------------------------------------- /dist/RTTY_170Hz_45.45Bd.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mgdm/rtty-decoder/main/dist/RTTY_170Hz_45.45Bd.mp3 -------------------------------------------------------------------------------- /shell.nix: -------------------------------------------------------------------------------- 1 | with import {}; 2 | 3 | pkgs.mkShell { 4 | buildInputs = [ 5 | pkgs.nodejs 6 | ]; 7 | 8 | } 9 | -------------------------------------------------------------------------------- /babel.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | [ 4 | "@babel/preset-env", 5 | { 6 | "targets": { 7 | "edge": "17", 8 | "firefox": "60", 9 | "chrome": "67", 10 | "safari": "11.1" 11 | } 12 | } 13 | ] 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "rtty", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "app.js", 6 | "scripts": { 7 | "build": "npx webpack", 8 | "watch": "npx webpack watch --mode=development" 9 | }, 10 | "keywords": [], 11 | "author": "", 12 | "license": "ISC", 13 | "dependencies": { 14 | "goertzel": "^3.0.1" 15 | }, 16 | "devDependencies": { 17 | "@babel/cli": "^7.16.7", 18 | "@babel/core": "^7.16.7", 19 | "@babel/preset-env": "^7.16.7", 20 | "acorn": "^8.7.0", 21 | "webpack": "^5.65.0", 22 | "webpack-cli": "^4.9.1", 23 | "webpack-dev-server": "^4.7.4" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /dist/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | RTTY decoder 9 | 10 | 28 | 29 | 30 |
31 |
32 | 33 | 34 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import * as goertzel from "goertzel"; 2 | 3 | const SPACE_FREQ = 2295; 4 | const MARK_FREQ = 2125; 5 | 6 | const EXAMPLE_SOURCE = "output.wav"; 7 | 8 | const LETTERS = { 9 | '00000': "\0", 10 | '00100': ' ', 11 | '10111': 'Q', 12 | '10011': 'W', 13 | '00001': 'E', 14 | '01010': 'R', 15 | '10000': 'T', 16 | '10101': 'Y', 17 | '00111': 'U', 18 | '00110': 'I', 19 | '11000': 'O', 20 | '10110': 'P', 21 | '00011': 'A', 22 | '00101': 'S', 23 | '01001': 'D', 24 | '01101': 'F', 25 | '11010': 'G', 26 | '10100': 'H', 27 | '01011': 'J', 28 | '01111': 'K', 29 | '10010': 'L', 30 | '10001': 'Z', 31 | '11101': 'X', 32 | '01110': 'C', 33 | '11110': 'V', 34 | '11001': 'B', 35 | '01100': 'N', 36 | '11100': 'M', 37 | '01000': "\r", 38 | '00010': "\n", 39 | }; 40 | 41 | 42 | async function getInput(url) { 43 | return fetch(EXAMPLE_SOURCE) 44 | .then(response => response.arrayBuffer()); 45 | } 46 | 47 | function detectBits(samples) { 48 | const markDecoder = goertzel({ 49 | targetFrequency: MARK_FREQ, 50 | sampleRate: samples.sampleRate, 51 | samplesPerFrame: 128 52 | }); 53 | 54 | const spaceDecoder = goertzel({ 55 | targetFrequency: SPACE_FREQ, 56 | sampleRate: samples.sampleRate, 57 | samplesPerFrame: 128 58 | }); 59 | 60 | const channelData = samples.getChannelData(0); 61 | const result = []; 62 | 63 | for (let i = 0; i < channelData.length; i += 128) { 64 | const data = channelData.slice(i, i + 128); 65 | result.push([markDecoder(data), spaceDecoder(data)]); 66 | } 67 | 68 | return result; 69 | } 70 | 71 | function goetzelBitstream(bits) { 72 | let result = []; 73 | 74 | for (let i = 0; i < bits.length; i++) { 75 | let [mark, space] = bits[i]; 76 | let goertzelBit = 0; 77 | 78 | if (mark == false && space == false) { 79 | goertzelBit = 0; 80 | result.push(goertzelBit); 81 | continue; 82 | } 83 | 84 | if (mark) { 85 | goertzelBit = 1; 86 | } else { 87 | goertzelBit = 0; 88 | } 89 | 90 | result.push(goertzelBit); 91 | } 92 | 93 | return result; 94 | } 95 | 96 | function baudotBitstream(goetzelBits) { 97 | let baudotBits = []; 98 | 99 | console.log(goetzelBits.toString()); 100 | 101 | /* This is largely inspired by 102 | * https://files.tapr.org/meetings/DCC_2014/DCC2014-Radioteletype-Over-Sampling-Decoder-K0JJR.pdf 103 | */ 104 | 105 | let i = 0; 106 | /* Look for a mark */ 107 | while (goetzelBits[i] === 0) { 108 | i++; 109 | } 110 | 111 | /* We've found a mark, now let's look for the space */ 112 | while (goetzelBits[i] === 1) { 113 | i++; 114 | } 115 | 116 | i++; 117 | /* Hopefully by now we are synced up, start the main loop */ 118 | 119 | for (let jump = 7; i < goetzelBits.length; i += jump) { 120 | /* 121 | The decoder adds up the second, third, fourth, fifth, and sixth Goertzel bits of each RTTY Baudot bit. 122 | If those five Goertzel bits add up to zero, one, or two, the RTTY Baudot bit is declared to be a Space. 123 | If those five Goertzel bits add up to three, four, or five, the RTTY Baudot bit is declared to be a Mark. 124 | */ 125 | 126 | const bits = goetzelBits.slice(i, i + jump); 127 | /* Start from the second bit, grab the next five */ 128 | const subBits = bits.slice(1, 6); 129 | // console.log("Bits: " + bits.toString()); 130 | // console.log("Subbits: " + subBits.toString()); 131 | const total = subBits.reduce((prev, curr) => prev + curr, 0); 132 | 133 | let result = 0; 134 | if (total >= 3) { 135 | result = 1; 136 | } 137 | 138 | baudotBits.push(result); 139 | 140 | /* 141 | With an over-sampling rate of 7.6 Goertzel bits during the duration of a Mark or Space tone, 142 | accurate timing can be achieved by alternating between seven and eight Goertzel bits for each RTTY 143 | Baudot bit, so that the average is approximately 7.6 144 | */ 145 | jump = (jump == 7 ? 8 : 7); 146 | } 147 | 148 | return baudotBits; 149 | } 150 | 151 | function decodeGroup(group) { 152 | let baudotBits = group.slice(1, 6); 153 | let str = baudotBits.reduce((prev, curr) => prev + curr.toString(), ''); 154 | 155 | if (LETTERS.hasOwnProperty(str)) { 156 | return LETTERS[str]; 157 | } 158 | 159 | return ' '; 160 | } 161 | 162 | async function main() { 163 | const ctx = new window.AudioContext(); 164 | const input = await getInput(EXAMPLE_SOURCE); 165 | 166 | const audio = await ctx.decodeAudioData(input); 167 | const bitStream = detectBits(audio); 168 | const goetzelBits = goetzelBitstream(bitStream); 169 | const baudotBits = baudotBitstream(goetzelBits); 170 | 171 | let bitGroups = []; 172 | let skip = 8; 173 | for (let i = 0; i < baudotBits.length; i += skip) { 174 | bitGroups.push(baudotBits.slice(i, i + skip)); 175 | } 176 | 177 | let chars = bitGroups.map(decodeGroup).join(""); 178 | 179 | document.getElementById("stats").innerText = `Decoded ${chars.length} characters from ${goetzelBits.length} samples and ${baudotBits.length} detected bits`; 180 | document.getElementById("output").innerText = chars; 181 | } 182 | 183 | main(); --------------------------------------------------------------------------------