diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..b4c7187
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2023 Christian Kegel
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
\ No newline at end of file
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..e69de29
diff --git a/index.html b/index.html
new file mode 100644
index 0000000..d3896e8
--- /dev/null
+++ b/index.html
@@ -0,0 +1,39 @@
+
+
+
+
+
+
+
+ Web-SSTV
+
+
+
+
Web SSTV
+
Send SSTV signals from your browser
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/script.js b/script.js
new file mode 100644
index 0000000..d0352fc
--- /dev/null
+++ b/script.js
@@ -0,0 +1,508 @@
+/*
+MIT License
+
+Copyright (c) 2023 Christian Kegel
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+*/
+//---------- Encoding Constants ----------//
+
+const PREFIX_PULSE_LENGTH = 0.1; //100 ms
+const HEADER_PULSE_LENGTH = 0.3; //300 ms
+const HEADER_BREAK_LENGTH = 0.01; //10 ms
+const VIS_BIT_LENGTH = 0.03; //30 ms
+const SYNC_PULSE_FREQ = 1200;
+const BLANKING_PULSE_FREQ = 1500;
+const VIS_BIT_FREQ = {
+ ONE: 1100,
+ ZERO: 1300,
+};
+
+class Format {
+
+ #numScanLines;
+ #vertResolution;
+ #blankingInterval;
+ #scanLineLength;
+ #syncPulseLength;
+ #VISCode;
+ #preparedImage = [];
+
+ constructor(numScanLines, vertResolution, blankingInterval, scanLineLength, syncPulseLength, VISCode) {
+ this.#numScanLines = numScanLines;
+ this.#vertResolution = vertResolution;
+ this.#blankingInterval = blankingInterval;
+ this.#scanLineLength = scanLineLength;
+ this.#syncPulseLength = syncPulseLength;
+ this.#VISCode = VISCode;
+ }
+
+ getRGBValueAsFreq(data, scanLine, vertPos) {
+ const index = scanLine * (this.#vertResolution * 4) + vertPos * 4;
+ let red = data[index] * 3.137 + 1500;
+ let green = data[index + 1] * 3.137 + 1500;
+ let blue = data[index + 2] * 3.137 + 1500;
+ return [red, green, blue];
+ }
+
+ encodePrefix(oscillator, startTime) {
+ let time = startTime;
+
+ oscillator.frequency.setValueAtTime(1900, time);
+ time += PREFIX_PULSE_LENGTH;
+ oscillator.frequency.setValueAtTime(1500, time);
+ time += PREFIX_PULSE_LENGTH;
+ oscillator.frequency.setValueAtTime(1900, time);
+ time += PREFIX_PULSE_LENGTH;
+ oscillator.frequency.setValueAtTime(1500, time);
+ time += PREFIX_PULSE_LENGTH;
+ oscillator.frequency.setValueAtTime(2300, time);
+ time += PREFIX_PULSE_LENGTH;
+ oscillator.frequency.setValueAtTime(1500, time);
+ time += PREFIX_PULSE_LENGTH;
+ oscillator.frequency.setValueAtTime(2300, time);
+ time += PREFIX_PULSE_LENGTH;
+ oscillator.frequency.setValueAtTime(1500, time);
+ time += PREFIX_PULSE_LENGTH;
+
+ return time;
+ }
+
+ encodeHeader(oscillator, startTime) {
+ let time = startTime;
+
+ //----- Format Header -----//
+ oscillator.frequency.setValueAtTime(1900, time);
+ time += HEADER_PULSE_LENGTH;
+ oscillator.frequency.setValueAtTime(SYNC_PULSE_FREQ, time);
+ time += HEADER_BREAK_LENGTH;
+ oscillator.frequency.setValueAtTime(1900, time);
+ time += HEADER_PULSE_LENGTH;
+
+ //-----VIS Code-----//
+ //--- Start Bit ---//
+ oscillator.frequency.setValueAtTime(SYNC_PULSE_FREQ, time);
+ time += VIS_BIT_LENGTH;
+ //--- 7 Bit Format Code ---//
+ let parity = 0;
+ let bitFreq;
+ this.#VISCode.reverse().forEach((bit) => {
+ if(bit){
+ bitFreq = VIS_BIT_FREQ.ONE;
+ ++parity;
+ }
+ else
+ bitFreq = VIS_BIT_FREQ.ZERO;
+ oscillator.frequency.setValueAtTime(bitFreq, time)
+ time += VIS_BIT_LENGTH;
+ });
+ //--- Even Parity Bit ---//
+ bitFreq = parity % 2 == 0 ? VIS_BIT_FREQ.ZERO : VIS_BIT_FREQ.ONE;
+ oscillator.frequency.setValueAtTime(bitFreq, time)
+ time += VIS_BIT_LENGTH;
+ //--- End Bit ---//
+ oscillator.frequency.setValueAtTime(SYNC_PULSE_FREQ, time);
+ time += VIS_BIT_LENGTH;
+
+ return time;
+ }
+
+ prepareImage(data) {
+ this.#preparedImage = data;
+ }
+
+ encodeSSTV(oscillator, startTime) {
+ throw new Error("Must be defined by a subclass");
+ }
+
+ get numScanLines() {
+ return this.#numScanLines;
+ }
+ get vertResolution() {
+ return this.#vertResolution;
+ }
+ get blankingInterval() {
+ return this.#blankingInterval;
+ }
+ get scanLineLength() {
+ return this.#scanLineLength;
+ }
+ get syncPulseLength() {
+ return this.#syncPulseLength;
+ }
+ get VISCode() {
+ return this.#VISCode;
+ }
+ get preparedImage(){
+ return this.#preparedImage;
+ }
+}
+
+//---------- Format Encode Implementation ----------//
+
+class MartinMOne extends Format {
+
+ constructor() {
+ let numScanLines = 256;
+ let vertResolution = 320;
+ let blankingInterval = 0.000572;
+ let scanLineLength = 0.146432;
+ let syncPulseLength = 0.004862;
+ let VISCode = [false, true, false, true, true, false, false];
+
+ super(numScanLines, vertResolution, blankingInterval, scanLineLength, syncPulseLength, VISCode);
+ }
+
+ prepareImage(data) {
+ let preparedImage = [];
+ for(let scanLine = 0; scanLine < this.numScanLines; ++scanLine){
+ let red = [];
+ let green = [];
+ let blue = [];
+ for(let vertPos = 0; vertPos < this.vertResolution; ++vertPos){
+ let freqs = this.getRGBValueAsFreq(data, scanLine, vertPos);
+ red.push(freqs[0]);
+ green.push(freqs[1]);
+ blue.push(freqs[2]);
+ }
+ preparedImage.push([green, blue, red]);
+ }
+
+ super.prepareImage(preparedImage);
+ }
+
+ encodeSSTV(oscillator, startTime) {
+ let time = startTime;
+
+ time = super.encodePrefix(oscillator, time);
+ time = super.encodeHeader(oscillator, time);
+
+ for(let scanLine = 0; scanLine < super.numScanLines; ++scanLine){
+ oscillator.frequency.setValueAtTime(SYNC_PULSE_FREQ, time);
+ time += super.syncPulseLength;
+ oscillator.frequency.setValueAtTime(BLANKING_PULSE_FREQ, time);
+ time += super.blankingInterval;
+ for(let dataLine = 0; dataLine < 3; ++dataLine) {
+ oscillator.frequency.setValueCurveAtTime(super.preparedImage[scanLine][dataLine], time, super.scanLineLength);
+ time += super.scanLineLength;
+ oscillator.frequency.setValueAtTime(BLANKING_PULSE_FREQ, time);
+ time += super.blankingInterval;
+ }
+ }
+
+ oscillator.start(startTime);
+ oscillator.stop(time);
+ }
+}
+class MartinMTwo extends Format {
+
+ constructor() {
+ let numScanLines = 256;
+ let vertResolution = 320;
+ let blankingInterval = 0.000572;
+ let scanLineLength = 0.073216;
+ let syncPulseLength = 0.004862;
+ let VISCode = [false, true, false, true, false, false, false];
+
+ super(numScanLines, vertResolution, blankingInterval, scanLineLength, syncPulseLength, VISCode);
+ }
+
+ prepareImage(data) {
+ let preparedImage = [];
+ for(let scanLine = 0; scanLine < this.numScanLines; ++scanLine){
+ let red = [];
+ let green = [];
+ let blue = [];
+ for(let vertPos = 0; vertPos < this.vertResolution; ++vertPos){
+ let freqs = this.getRGBValueAsFreq(data, scanLine, vertPos);
+ red.push(freqs[0]);
+ green.push(freqs[1]);
+ blue.push(freqs[2]);
+ }
+ preparedImage.push([green, blue, red]);
+ }
+
+ super.prepareImage(preparedImage);
+ }
+
+ encodeSSTV(oscillator, startTime) {
+ let time = startTime;
+
+ time = super.encodePrefix(oscillator, time);
+ time = super.encodeHeader(oscillator, time);
+
+ for(let scanLine = 0; scanLine < super.numScanLines; ++scanLine){
+ oscillator.frequency.setValueAtTime(SYNC_PULSE_FREQ, time);
+ time += super.syncPulseLength;
+ oscillator.frequency.setValueAtTime(BLANKING_PULSE_FREQ, time);
+ time += super.blankingInterval;
+ for(let dataLine = 0; dataLine < 3; ++dataLine) {
+ oscillator.frequency.setValueCurveAtTime(super.preparedImage[scanLine][dataLine], time, super.scanLineLength);
+ time += super.scanLineLength;
+ oscillator.frequency.setValueAtTime(BLANKING_PULSE_FREQ, time);
+ time += super.blankingInterval;
+ }
+ }
+
+ oscillator.start(startTime);
+ oscillator.stop(time);
+ }
+}
+class ScottieOne extends Format {
+
+ constructor() {
+ let numScanLines = 256;
+ let vertResolution = 320;
+ let blankingInterval = 0.0015;
+ let scanLineLength = 0.138240;
+ let syncPulseLength = 0.009;
+ let VISCode = [false, true, true, true, true, false, false];
+
+ super(numScanLines, vertResolution, blankingInterval, scanLineLength, syncPulseLength, VISCode);
+ }
+
+ prepareImage(data) {
+ let preparedImage = [];
+ for(let scanLine = 0; scanLine < this.numScanLines; ++scanLine){
+ let red = [];
+ let green = [];
+ let blue = [];
+ for(let vertPos = 0; vertPos < this.vertResolution; ++vertPos){
+ let freqs = this.getRGBValueAsFreq(data, scanLine, vertPos);
+ red.push(freqs[0]);
+ green.push(freqs[1]);
+ blue.push(freqs[2]);
+ }
+ preparedImage.push([green, blue, red]);
+ }
+
+ super.prepareImage(preparedImage);
+ }
+
+ encodeSSTV(oscillator, startTime) {
+ let time = startTime;
+
+ time = super.encodePrefix(oscillator, time);
+ time = super.encodeHeader(oscillator, time);
+
+ oscillator.frequency.setValueAtTime(SYNC_PULSE_FREQ, time);
+ time += super.syncPulseLength;
+
+ for(let scanLine = 0; scanLine < super.numScanLines; ++scanLine){
+ for(let dataLine = 0; dataLine < 3; ++dataLine) {
+ if(dataLine == 2){
+ oscillator.frequency.setValueAtTime(SYNC_PULSE_FREQ, time);
+ time += super.syncPulseLength;
+ }
+ oscillator.frequency.setValueAtTime(BLANKING_PULSE_FREQ, time);
+ time += super.blankingInterval;
+ oscillator.frequency.setValueCurveAtTime(super.preparedImage[scanLine][dataLine], time, super.scanLineLength);
+ time += super.scanLineLength;
+ }
+ }
+
+ oscillator.start(startTime);
+ oscillator.stop(time);
+ }
+}
+class ScottieTwo extends Format {
+
+ constructor() {
+ let numScanLines = 256;
+ let vertResolution = 320;
+ let blankingInterval = 0.0015;
+ let scanLineLength = 0.088064;
+ let syncPulseLength = 0.009;
+ let VISCode = [false, true, true, true, false, false, false];
+
+ super(numScanLines, vertResolution, blankingInterval, scanLineLength, syncPulseLength, VISCode);
+ }
+
+ prepareImage(data) {
+ let preparedImage = [];
+ for(let scanLine = 0; scanLine < this.numScanLines; ++scanLine){
+ let red = [];
+ let green = [];
+ let blue = [];
+ for(let vertPos = 0; vertPos < this.vertResolution; ++vertPos){
+ let freqs = this.getRGBValueAsFreq(data, scanLine, vertPos);
+ red.push(freqs[0]);
+ green.push(freqs[1]);
+ blue.push(freqs[2]);
+ }
+ preparedImage.push([green, blue, red]);
+ }
+
+ super.prepareImage(preparedImage);
+ }
+
+ encodeSSTV(oscillator, startTime) {
+ let time = startTime;
+
+ time = super.encodePrefix(oscillator, time);
+ time = super.encodeHeader(oscillator, time);
+
+ oscillator.frequency.setValueAtTime(SYNC_PULSE_FREQ, time);
+ time += super.syncPulseLength;
+
+ for(let scanLine = 0; scanLine < super.numScanLines; ++scanLine){
+ for(let dataLine = 0; dataLine < 3; ++dataLine) {
+ if(dataLine == 2){
+ oscillator.frequency.setValueAtTime(SYNC_PULSE_FREQ, time);
+ time += super.syncPulseLength;
+ }
+ oscillator.frequency.setValueAtTime(BLANKING_PULSE_FREQ, time);
+ time += super.blankingInterval;
+ oscillator.frequency.setValueCurveAtTime(super.preparedImage[scanLine][dataLine], time, super.scanLineLength);
+ time += super.scanLineLength;
+ }
+ }
+
+ oscillator.start(startTime);
+ oscillator.stop(time);
+ }
+}
+class ScottieDX extends Format {
+
+ constructor() {
+ let numScanLines = 256;
+ let vertResolution = 320;
+ let blankingInterval = 0.0015;
+ let scanLineLength = 0.3456;
+ let syncPulseLength = 0.009;
+ let VISCode = [true, false, false, true, true, false, false];
+
+ super(numScanLines, vertResolution, blankingInterval, scanLineLength, syncPulseLength, VISCode);
+ }
+
+ prepareImage(data) {
+ let preparedImage = [];
+ for(let scanLine = 0; scanLine < this.numScanLines; ++scanLine){
+ let red = [];
+ let green = [];
+ let blue = [];
+ for(let vertPos = 0; vertPos < this.vertResolution; ++vertPos){
+ let freqs = this.getRGBValueAsFreq(data, scanLine, vertPos);
+ red.push(freqs[0]);
+ green.push(freqs[1]);
+ blue.push(freqs[2]);
+ }
+ preparedImage.push([green, blue, red]);
+ }
+
+ super.prepareImage(preparedImage);
+ }
+
+ encodeSSTV(oscillator, startTime) {
+ let time = startTime;
+
+ time = super.encodePrefix(oscillator, time);
+ time = super.encodeHeader(oscillator, time);
+
+ oscillator.frequency.setValueAtTime(SYNC_PULSE_FREQ, time);
+ time += super.syncPulseLength;
+
+ for(let scanLine = 0; scanLine < super.numScanLines; ++scanLine){
+ for(let dataLine = 0; dataLine < 3; ++dataLine) {
+ if(dataLine == 2){
+ oscillator.frequency.setValueAtTime(SYNC_PULSE_FREQ, time);
+ time += super.syncPulseLength;
+ }
+ oscillator.frequency.setValueAtTime(BLANKING_PULSE_FREQ, time);
+ time += super.blankingInterval;
+ oscillator.frequency.setValueCurveAtTime(super.preparedImage[scanLine][dataLine], time, super.scanLineLength);
+ time += super.scanLineLength;
+ }
+ }
+
+ oscillator.start(startTime);
+ oscillator.stop(time);
+ }
+}
+
+//---------- Frontend Controls ----------//
+
+const audioCtx = new AudioContext();
+let imageLoaded = false;
+
+let modeSelect = document.getElementById("modeSelect")
+let startButton = document.getElementById("startButton");
+let imgPicker = document.getElementById("imgPicker");
+let warningText = document.getElementById("warningText");
+
+let canvas = document.getElementById("imgCanvas");
+let canvasCtx = canvas.getContext("2d");
+
+imgPicker.addEventListener("change", (e) => {
+ var reader = new FileReader();
+ reader.onload = function(event){
+ var img = new Image();
+ img.onload = function(){
+ canvas.width = 320;
+ canvas.height = 256;
+ canvasCtx.drawImage(img,0,0, canvas.width, canvas.height);
+ }
+ img.src = event.target.result;
+ if(modeSelect.value != "none"){
+ warningText.textContent = "";
+ startButton.disabled = false;
+ imageLoaded = true;
+ }
+ }
+ reader.readAsDataURL(e.target.files[0]);
+});
+
+modeSelect.addEventListener("change", (e) => {
+ if(modeSelect.value != "none"){
+ warningText.textContent = "";
+ startButton.disabled = !imageLoaded;
+ }
+});
+
+startButton.onclick = () => {
+ let format;
+ if(modeSelect.value == "M1")
+ format = new MartinMOne();
+ else if(modeSelect.value == "M2")
+ format = new MartinMTwo();
+ else if(modeSelect.value == "S1")
+ format = new ScottieOne();
+ else if(modeSelect.value == "S2")
+ format = new ScottieTwo();
+ else if(modeSelect.value == "SDX")
+ format = new ScottieDX();
+ else {
+ warningText.textContent = "You must select a mode";
+ startButton.disabled = true;
+ return;
+ }
+
+ if(!imageLoaded){
+ warningText.textContent = "You must upload an image";
+ startButton.disabled = true;
+ return;
+ }
+
+ let canvasData = canvasCtx.getImageData(0, 0, canvas.width, canvas.height);
+
+ warningText.textContent = "";
+ if (audioCtx.state === "suspended") {
+ audioCtx.resume();
+ }
+
+ let oscillator = audioCtx.createOscillator();
+ oscillator.type = "sine";
+
+ oscillator.connect(audioCtx.destination);
+
+ format.prepareImage(canvasData.data);
+ format.encodeSSTV(oscillator, audioCtx.currentTime + 1);
+};
\ No newline at end of file
diff --git a/style.css b/style.css
new file mode 100644
index 0000000..9f32eeb
--- /dev/null
+++ b/style.css
@@ -0,0 +1,7 @@
+#imgCanvas {
+ padding: 5px;
+}
+#imgArea {
+ padding: 10px;
+ color: red;
+}
\ No newline at end of file