Add the ability to download encoded images as a WAV file. Dervive Scotty modes from the Scottie base class.

This commit is contained in:
ckegel 2024-11-16 01:12:00 -05:00
parent a65f308216
commit f325a9855e
3 changed files with 127 additions and 15 deletions

View File

@ -58,6 +58,7 @@
</select>
</div>
<button id="startButton" class="contrast" disabled>Encode</button>
<button id="downloadButton" class="contrast" disabled>Download</button>
</main>
<footer class="container">

138
encode.js
View File

@ -13,7 +13,6 @@ 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
@ -143,6 +142,10 @@ class Format {
throw new Error("Must be defined by a subclass");
}
getEncodedLength() {
throw new Error("Must be defined by a subclass");
}
get numScanLines() {
return this.#numScanLines;
}
@ -205,8 +208,10 @@ class MartinBase extends Format {
}
}
oscillator.start(startTime);
oscillator.stop(time);
return time;
}
getEncodedLength() {
return (super.numScanLines * (super.syncPulseLength + super.blankingInterval + 3 * (super.scanLineLength + super.blankingInterval)));
}
}
class MartinMOne extends MartinBase {
@ -275,8 +280,11 @@ class ScottieBase extends Format {
}
}
oscillator.start(startTime);
oscillator.stop(time);
return time;
}
getEncodedLength() {
return (super.syncPulseLength + (super.numScanLines * (super.syncPulseLength / 2 + super.blankingInterval + super.scanLineLength)));
}
}
class ScottieOne extends ScottieBase {
@ -291,7 +299,7 @@ class ScottieOne extends ScottieBase {
super(numScanLines, vertResolution, blankingInterval, scanLineLength, syncPulseLength, VISCode);
}
}
class ScottieTwo extends Format {
class ScottieTwo extends ScottieBase {
constructor() {
let numScanLines = 256;
let vertResolution = 320;
@ -303,7 +311,7 @@ class ScottieTwo extends Format {
super(numScanLines, vertResolution, blankingInterval, scanLineLength, syncPulseLength, VISCode);
}
}
class ScottieDX extends Format {
class ScottieDX extends ScottieBase {
constructor() {
let numScanLines = 256;
let vertResolution = 320;
@ -364,8 +372,11 @@ class PDBase extends Format {
time += super.scanLineLength;
}
oscillator.start(startTime);
oscillator.stop(time);
return time;
}
getEncodedLength() {
return (super.numScanLines * (super.syncPulseLength + super.blankingInterval + super.scanLineLength * 4));
}
}
class PD50 extends PDBase {
@ -489,8 +500,11 @@ class WrasseSC2 extends Format {
}
}
oscillator.start(startTime);
oscillator.stop(time);
return time;
}
getEncodedLength() {
return (super.numScanLines * (super.syncPulseLength + super.blankingInterval + super.scanLineLength * 3));
}
}
class WrasseSC2180 extends WrasseSC2 {
@ -511,12 +525,13 @@ class WrasseSC2180 extends WrasseSC2 {
const audioCtx = new AudioContext();
let imageLoaded = false;
let modeSelect = document.getElementById("modeSelect")
let modeSelect = document.getElementById("modeSelect");
let startButton = document.getElementById("startButton");
let downloadButton = document.getElementById('downloadButton');
let imgPicker = document.getElementById("imgPicker");
let warningText = document.getElementById("warningText");
let callSignEntry = document.getElementById("callSign");
let callSignLocation = document.getElementById("callSignLocation")
let callSignLocation = document.getElementById("callSignLocation");
let canvas = document.getElementById("imgCanvas");
let canvasCtx = canvas.getContext("2d");
@ -560,6 +575,7 @@ imgPicker.addEventListener("change", (e) => {
if(modeSelect.value != "none"){
warningText.textContent = "";
startButton.disabled = false;
downloadButton.disabled = false;
}
}
reader.readAsDataURL(e.target.files[0]);
@ -569,6 +585,7 @@ modeSelect.addEventListener("change", (e) => {
if(modeSelect.value != "none"){
warningText.textContent = "";
startButton.disabled = !imageLoaded;
downloadButton.disabled = !imageLoaded;
imgPicker.disabled = false;
}
if(modeSelect.value == "M1")
@ -631,5 +648,98 @@ startButton.onclick = () => {
oscillator.connect(audioCtx.destination);
sstvFormat.prepareImage(canvasData.data);
sstvFormat.encodeSSTV(oscillator, audioCtx.currentTime + 1);
let startTime = audioCtx.currentTime + 1;
let endTime = sstvFormat.encodeSSTV(oscillator, audioCtx.currentTime + 1);
oscillator.start(startTime);
oscillator.end(endTime);
};
function createWAVHeader(audioLength) {
const headerSize = 44;
const header = new ArrayBuffer(headerSize);
const view = new DataView(header);
// RIFF chunk
view.setUint32(0, 0x52494646, false); // "RIFF"
view.setUint32(4, 36 + audioLength, true); // File size
view.setUint32(8, 0x57415645, false); // "WAVE"
// fmt chunk
view.setUint32(12, 0x666d7420, false); // "fmt "
view.setUint32(16, 16, true); // Chunk size
view.setUint16(20, 1, true); // Audio format (PCM)
view.setUint16(22, 1, true); // Number of channels (mono)
view.setUint32(24, 48000, true); // Sample rate
view.setUint32(28, 48000 * 1 * 16 / 8, true); // Byte rate
view.setUint16(32, 1 * 16 / 8, true); // Block align
view.setUint16(34, 16, true); // Bits per sample
// data chunk
view.setUint32(36, 0x64617461, false); // "data"
view.setUint32(40, audioLength, true); // Data size
return header;
}
downloadButton.onclick = () => {
if (modeSelect.value == "none") {
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();
}
sstvSignalDuration = sstvFormat.getEncodedLength() + 1;
const offlineCtx = new OfflineAudioContext(1, 48000 * sstvSignalDuration, 48000);
let oscillator = offlineCtx.createOscillator();
oscillator.type = "sine";
oscillator.connect(offlineCtx.destination);
sstvFormat.prepareImage(canvasData.data);
endTime = sstvFormat.encodeSSTV(oscillator, 1);
console.log(endTime - 1);
oscillator.start(1);
offlineCtx.startRendering().then((audioBuffer) => {
const audioData = audioBuffer.getChannelData(0); // Get first audio channel
// Convert Float32Array to Int16Array
const audioLength = audioData.length;
const audioInt16 = new Int16Array(audioLength);
for (let i = 0; i < audioLength; i++) {
audioInt16[i] = Math.max(-1, Math.min(1, audioData[i])) * 32767;
}
// Convert the audio data to a Blob for download
const wavHeader = createWAVHeader(audioInt16.length * 2); // 2 bytes per sample
const wavFile = new Uint8Array(wavHeader.byteLength + audioInt16.byteLength);
wavFile.set(new Uint8Array(wavHeader), 0);
wavFile.set(new Uint8Array(audioInt16.buffer), wavHeader.byteLength);
const blob = new Blob([wavFile], { type: 'audio/wav' });
// Download the Blob
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'sstv_signal.wav';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
});
};

View File

@ -22,9 +22,10 @@
<h4>About:</h4>
<p>Web SSTV aims to both encode and decode SSTV using plain JavaScript and Web Audio API. Web SSTV can be run entirely offline (without styling), and on any platform from Chromebooks to phones, so long as they support JavaScript and Web Audio. By making SSTV readily available on many platforms, we aim to create educational opportunities and introduce more people to STEM and amateur radio.</p>
<h4>Changelog:</h4>
<p>November 16, 2024 - Added the ability to download encoded images as WAVE files.</p>
<p>October 16, 2024 - Added all PD Modes, patched several usability issues, and updated the styling.</p>
<p>October 13, 2024 - Restructured the site in preperation for decoding. Added the ability to mark images with the users call sign.</p>
<p>December 2023 - Initial site published via Github pages.</p>
<p>December 14, 2023 - Initial site published via Github pages.</p>
</main>
<footer class="container">
<small>SSTV signal decoding and more encoding modes coming soon.</small>