diff --git a/app/src/main/java/xdsopl/robot36/BaseMode.java b/app/src/main/java/xdsopl/robot36/BaseMode.java new file mode 100644 index 0000000..78b0327 --- /dev/null +++ b/app/src/main/java/xdsopl/robot36/BaseMode.java @@ -0,0 +1,16 @@ +/* +Base class for all modes + +Copyright 2025 Marek Ossowski +*/ + +package xdsopl.robot36; + +import android.graphics.Bitmap; + +public abstract class BaseMode implements Mode { + @Override + public Bitmap postProcessScopeImage(Bitmap bmp) { + return bmp; + } +} diff --git a/app/src/main/java/xdsopl/robot36/Decoder.java b/app/src/main/java/xdsopl/robot36/Decoder.java index dc7d9ea..8c62a05 100644 --- a/app/src/main/java/xdsopl/robot36/Decoder.java +++ b/app/src/main/java/xdsopl/robot36/Decoder.java @@ -38,6 +38,7 @@ public class Decoder { private final int visCodeBitSamples; private final int visCodeSamples; private final Mode rawMode; + private final Mode hfFaxMode; private final ArrayList syncPulse5msModes; private final ArrayList syncPulse9msModes; private final ArrayList syncPulse20msModes; @@ -95,6 +96,7 @@ public class Decoder { double scanLineToleranceSeconds = 0.001; scanLineToleranceSamples = (int) Math.round(scanLineToleranceSeconds * sampleRate); rawMode = new RawDecoder(rawName, sampleRate); + hfFaxMode = new HFFax(sampleRate); Mode robot36 = new Robot_36_Color(sampleRate); currentMode = robot36; currentScanLineSamples = robot36.getScanLineSamples(); @@ -157,7 +159,7 @@ public class Decoder { private Mode findMode(ArrayList modes, int code) { for (Mode mode : modes) - if (mode.getCode() == code) + if (mode.getVISCode() == code) return mode; return null; } @@ -332,7 +334,7 @@ public class Decoder { } if (lockMode && mode != currentMode) return false; - mode.reset(); + mode.resetState(); imageBuffer.width = mode.getWidth(); imageBuffer.height = mode.getHeight(); imageBuffer.line = 0; @@ -346,29 +348,29 @@ public class Decoder { for (int i = 0; i < pulses.length; ++i) pulses[i] = oldestSyncPulseIndex + i * currentScanLineSamples; Arrays.fill(lines, currentScanLineSamples); - shiftSamples(lastSyncPulseIndex + mode.getBegin()); + shiftSamples(lastSyncPulseIndex + mode.getFirstPixelSampleIndex()); drawLines(0xff00ff00, 8); drawLines(0xff000000, 10); return true; } - private boolean processSyncPulse(ArrayList modes, float[] freqOffs, int[] pulses, int[] lines, int index) { - for (int i = 1; i < pulses.length; ++i) - pulses[i - 1] = pulses[i]; - pulses[pulses.length - 1] = index; - for (int i = 1; i < lines.length; ++i) - lines[i - 1] = lines[i]; - lines[lines.length - 1] = pulses[pulses.length - 1] - pulses[pulses.length - 2]; + private boolean processSyncPulse(ArrayList modes, float[] freqOffs, int[] syncIndexes, int[] lineLengths, int latestSyncIndex) { + for (int i = 1; i < syncIndexes.length; ++i) + syncIndexes[i - 1] = syncIndexes[i]; + syncIndexes[syncIndexes.length - 1] = latestSyncIndex; + for (int i = 1; i < lineLengths.length; ++i) + lineLengths[i - 1] = lineLengths[i]; + lineLengths[lineLengths.length - 1] = syncIndexes[syncIndexes.length - 1] - syncIndexes[syncIndexes.length - 2]; for (int i = 1; i < freqOffs.length; ++i) freqOffs[i - 1] = freqOffs[i]; - freqOffs[pulses.length - 1] = demodulator.frequencyOffset; - if (lines[0] == 0) + freqOffs[syncIndexes.length - 1] = demodulator.frequencyOffset; + if (lineLengths[0] == 0) return false; - double mean = scanLineMean(lines); + double mean = scanLineMean(lineLengths); int scanLineSamples = (int) Math.round(mean); if (scanLineSamples < scanLineMinSamples || scanLineSamples > scratchBuffer.length) return false; - if (scanLineStdDev(lines, mean) > scanLineToleranceSamples) + if (scanLineStdDev(lineLengths, mean) > scanLineToleranceSamples) return false; boolean pictureChanged = false; if (lockMode || imageBuffer.line >= 0 && imageBuffer.line < imageBuffer.height) { @@ -379,7 +381,7 @@ public class Decoder { currentMode = detectMode(modes, scanLineSamples); pictureChanged = currentMode != prevMode || Math.abs(currentScanLineSamples - scanLineSamples) > scanLineToleranceSamples - || Math.abs(lastSyncPulseIndex + scanLineSamples - pulses[pulses.length - 1]) > syncPulseToleranceSamples; + || Math.abs(lastSyncPulseIndex + scanLineSamples - syncIndexes[syncIndexes.length - 1]) > syncPulseToleranceSamples; } if (pictureChanged) { drawLines(0xff000000, 10); @@ -387,23 +389,24 @@ public class Decoder { drawLines(0xff000000, 10); } float frequencyOffset = (float) frequencyOffsetMean(freqOffs); - if (pulses[0] >= scanLineSamples && pictureChanged) { - int endPulse = pulses[0]; + if (syncIndexes[0] >= scanLineSamples && pictureChanged) { + int endPulse = syncIndexes[0]; int extrapolate = endPulse / scanLineSamples; int firstPulse = endPulse - extrapolate * scanLineSamples; for (int pulseIndex = firstPulse; pulseIndex < endPulse; pulseIndex += scanLineSamples) copyLines(currentMode.decodeScanLine(pixelBuffer, scratchBuffer, scanLineBuffer, scopeBuffer.width, pulseIndex, scanLineSamples, frequencyOffset)); } - for (int i = pictureChanged ? 0 : lines.length - 1; i < lines.length; ++i) - copyLines(currentMode.decodeScanLine(pixelBuffer, scratchBuffer, scanLineBuffer, scopeBuffer.width, pulses[i], lines[i], frequencyOffset)); - lastSyncPulseIndex = pulses[pulses.length - 1]; + for (int i = pictureChanged ? 0 : lineLengths.length - 1; i < lineLengths.length; ++i) + copyLines(currentMode.decodeScanLine(pixelBuffer, scratchBuffer, scanLineBuffer, scopeBuffer.width, syncIndexes[i], lineLengths[i], frequencyOffset)); + lastSyncPulseIndex = syncIndexes[syncIndexes.length - 1]; currentScanLineSamples = scanLineSamples; lastFrequencyOffset = frequencyOffset; - shiftSamples(lastSyncPulseIndex + currentMode.getBegin()); + shiftSamples(lastSyncPulseIndex + currentMode.getFirstPixelSampleIndex()); return true; } public boolean process(float[] recordBuffer, int channelSelect) { + boolean newLinesPresent = false; boolean syncPulseDetected = demodulator.process(recordBuffer, channelSelect); int syncPulseIndex = currentSample + demodulator.syncPulseOffset; int channels = channelSelect > 0 ? 2 : 1; @@ -417,25 +420,28 @@ public class Decoder { if (syncPulseDetected) { switch (demodulator.syncPulseWidth) { case FiveMilliSeconds: - return processSyncPulse(syncPulse5msModes, last5msFrequencyOffsets, last5msSyncPulses, last5msScanLines, syncPulseIndex); + newLinesPresent = processSyncPulse(syncPulse5msModes, last5msFrequencyOffsets, last5msSyncPulses, last5msScanLines, syncPulseIndex); + break; case NineMilliSeconds: leaderBreakIndex = syncPulseIndex; - return processSyncPulse(syncPulse9msModes, last9msFrequencyOffsets, last9msSyncPulses, last9msScanLines, syncPulseIndex); + newLinesPresent = processSyncPulse(syncPulse9msModes, last9msFrequencyOffsets, last9msSyncPulses, last9msScanLines, syncPulseIndex); + break; case TwentyMilliSeconds: leaderBreakIndex = syncPulseIndex; - return processSyncPulse(syncPulse20msModes, last20msFrequencyOffsets, last20msSyncPulses, last20msScanLines, syncPulseIndex); + newLinesPresent = processSyncPulse(syncPulse20msModes, last20msFrequencyOffsets, last20msSyncPulses, last20msScanLines, syncPulseIndex); + break; default: - return false; + break; } - } - if (handleHeader()) - return true; - if (currentSample > lastSyncPulseIndex + (currentScanLineSamples * 5) / 4) { + } else if (handleHeader()) { + newLinesPresent = true; + } else if (currentSample > lastSyncPulseIndex + (currentScanLineSamples * 5) / 4) { copyLines(currentMode.decodeScanLine(pixelBuffer, scratchBuffer, scanLineBuffer, scopeBuffer.width, lastSyncPulseIndex, currentScanLineSamples, lastFrequencyOffset)); lastSyncPulseIndex += currentScanLineSamples; - return true; + newLinesPresent = true; } - return false; + + return newLinesPresent; } public void setMode(String name) { @@ -450,6 +456,8 @@ public class Decoder { mode = findMode(syncPulse9msModes, name); if (mode == null) mode = findMode(syncPulse20msModes, name); + if (mode == null && hfFaxMode.getName().equals(name)) + mode = hfFaxMode; if (mode == currentMode) { lockMode = true; return; diff --git a/app/src/main/java/xdsopl/robot36/Demodulator.java b/app/src/main/java/xdsopl/robot36/Demodulator.java index 2bf882a..b499dbf 100644 --- a/app/src/main/java/xdsopl/robot36/Demodulator.java +++ b/app/src/main/java/xdsopl/robot36/Demodulator.java @@ -13,6 +13,8 @@ public class Demodulator { private final SchmittTrigger syncPulseTrigger; private final Phasor baseBandOscillator; private final Delay syncPulseValueDelay; + private final double scanLineBandwidth; + private final double centerFrequency; private final float syncPulseFrequencyValue; private final float syncPulseFrequencyTolerance; private final int syncPulse5msMinSamples; @@ -33,10 +35,12 @@ public class Demodulator { public int syncPulseOffset; public float frequencyOffset; + public static final double syncPulseFrequency = 1200; + public static final double blackFrequency = 1500; + public static final double whiteFrequency = 2300; + Demodulator(int sampleRate) { - double blackFrequency = 1500; - double whiteFrequency = 2300; - double scanLineBandwidth = whiteFrequency - blackFrequency; + scanLineBandwidth = whiteFrequency - blackFrequency; frequencyModulation = new FrequencyModulation(scanLineBandwidth, sampleRate); double syncPulse5msSeconds = 0.005; double syncPulse9msSeconds = 0.009; @@ -63,20 +67,23 @@ public class Demodulator { Kaiser kaiser = new Kaiser(); for (int i = 0; i < baseBandLowPass.length; ++i) baseBandLowPass.taps[i] = (float) (kaiser.window(2.0, i, baseBandLowPass.length) * Filter.lowPass(cutoffFrequency, sampleRate, i, baseBandLowPass.length)); - double centerFrequency = (lowestFrequency + highestFrequency) / 2; + centerFrequency = (lowestFrequency + highestFrequency) / 2; baseBandOscillator = new Phasor(-centerFrequency, sampleRate); - double syncPulseFrequency = 1200; - syncPulseFrequencyValue = (float) ((syncPulseFrequency - centerFrequency) * 2 / scanLineBandwidth); + syncPulseFrequencyValue = (float) normalizeFrequency(syncPulseFrequency); syncPulseFrequencyTolerance = (float) (50 * 2 / scanLineBandwidth); double syncPorchFrequency = 1500; double syncHighFrequency = (syncPulseFrequency + syncPorchFrequency) / 2; double syncLowFrequency = (syncPulseFrequency + syncHighFrequency) / 2; - double syncLowValue = (syncLowFrequency - centerFrequency) * 2 / scanLineBandwidth; - double syncHighValue = (syncHighFrequency - centerFrequency) * 2 / scanLineBandwidth; + double syncLowValue = normalizeFrequency(syncLowFrequency); + double syncHighValue = normalizeFrequency(syncHighFrequency); syncPulseTrigger = new SchmittTrigger((float) syncLowValue, (float) syncHighValue); baseBand = new Complex(); } + private double normalizeFrequency(double frequency) { + return (frequency - centerFrequency) * 2 / scanLineBandwidth; + } + public boolean process(float[] buffer, int channelSelect) { boolean syncPulseDetected = false; int channels = channelSelect > 0 ? 2 : 1; diff --git a/app/src/main/java/xdsopl/robot36/HFFax.java b/app/src/main/java/xdsopl/robot36/HFFax.java new file mode 100644 index 0000000..b4dc7ac --- /dev/null +++ b/app/src/main/java/xdsopl/robot36/HFFax.java @@ -0,0 +1,137 @@ +/* +HF Fax mode + +Copyright 2025 Marek Ossowski +*/ + +package xdsopl.robot36; + +import android.graphics.Bitmap; +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.Rect; + +/** + * HF Fax, IOC 576, 120 lines per minute + */ +public class HFFax extends BaseMode { + private final ExponentialMovingAverage lowPassFilter; + private final String name; + private final int sampleRate; + private final float[] cumulated; + private int horizontalShift = 0; + + HFFax(int sampleRate) { + this.name = "HF Fax"; + lowPassFilter = new ExponentialMovingAverage(); + this.sampleRate = sampleRate; + cumulated = new float[getWidth()]; + } + + private float freqToLevel(float frequency, float offset) { + return 0.5f * (frequency - offset + 1.f); + } + + @Override + public String getName() { + return name; + } + + @Override + public int getVISCode() { + return -1; + } + + @Override + public int getWidth() { + return 640; + } + + @Override + public int getHeight() { + return 1200; + } + + @Override + public int getFirstPixelSampleIndex() { + return 0; + } + + @Override + public int getFirstSyncPulseIndex() { + return -1; + } + + @Override + public int getScanLineSamples() { + return sampleRate / 2; + } + + @Override + public void resetState() { + } + + @Override + public Bitmap postProcessScopeImage(Bitmap bmp) { + int realWidth = 1808; + int realHorizontalShift = horizontalShift * realWidth / getWidth(); + Bitmap bmpMutable = Bitmap.createBitmap(realWidth, bmp.getHeight(), Bitmap.Config.ARGB_8888); + Canvas canvas = new Canvas(bmpMutable); + if (horizontalShift > 0) { + canvas.drawBitmap( + bmp, + new Rect(0, 0, horizontalShift, bmp.getHeight()), + new Rect(realWidth - realHorizontalShift, 0, realWidth, bmp.getHeight()), + null); + } + canvas.drawBitmap( + bmp, + new Rect(horizontalShift, 0, getWidth(), bmp.getHeight()), + new Rect(0, 1, realWidth - realHorizontalShift, bmp.getHeight() + 1), + null); + + return bmpMutable; + } + + @Override + public boolean decodeScanLine(PixelBuffer pixelBuffer, float[] scratchBuffer, float[] scanLineBuffer, int scopeBufferWidth, int syncPulseIndex, int scanLineSamples, float frequencyOffset) { + if (syncPulseIndex < 0 || syncPulseIndex + scanLineSamples > scanLineBuffer.length) + return false; + int horizontalPixels = getWidth(); + lowPassFilter.cutoff(horizontalPixels, 2 * scanLineSamples, 2); + lowPassFilter.reset(); + for (int i = 0; i < scanLineSamples; ++i) + scratchBuffer[i] = lowPassFilter.avg(scanLineBuffer[i]); + lowPassFilter.reset(); + for (int i = scanLineSamples - 1; i >= 0; --i) + scratchBuffer[i] = freqToLevel(lowPassFilter.avg(scratchBuffer[i]), frequencyOffset); + for (int i = 0; i < horizontalPixels; ++i) { + int position = (i * scanLineSamples) / horizontalPixels; + int color = ColorConverter.GRAY(scratchBuffer[position]); + pixelBuffer.pixels[i] = color; + + //accumulate recent values, forget old + float decay = 0.99f; + cumulated[i] = cumulated[i] * decay + Color.luminance(color) * (1 - decay); + } + + //try to detect "sync": thick white margin + int bestIndex = 0; + float bestValue = 0; + for (int x = 0; x < getWidth(); ++x) + { + float val = cumulated[x]; + if (val > bestValue) + { + bestIndex = x; + bestValue = val; + } + } + + horizontalShift = bestIndex; + + pixelBuffer.width = horizontalPixels; + pixelBuffer.height = 1; + return true; + } +} diff --git a/app/src/main/java/xdsopl/robot36/MainActivity.java b/app/src/main/java/xdsopl/robot36/MainActivity.java index b5d50dc..1eaf491 100644 --- a/app/src/main/java/xdsopl/robot36/MainActivity.java +++ b/app/src/main/java/xdsopl/robot36/MainActivity.java @@ -618,6 +618,10 @@ public class MainActivity extends AppCompatActivity { setMode(R.string.raw_mode); return true; } + if (id == R.id.action_force_hffax_mode) { + setMode(R.string.hf_fax); + return true; + } if (id == R.id.action_force_robot36_color) { setMode(R.string.robot36_color); return true; @@ -828,7 +832,14 @@ public class MainActivity extends AppCompatActivity { int height = scopeBuffer.height / 2; int stride = scopeBuffer.width; int offset = stride * scopeBuffer.line; - storeBitmap(Bitmap.createBitmap(scopeBuffer.pixels, offset, stride, width, height, Bitmap.Config.ARGB_8888)); + Bitmap bmp = Bitmap.createBitmap(scopeBuffer.pixels, offset, stride, width, height, Bitmap.Config.ARGB_8888); + + if (decoder != null) + { + bmp = decoder.currentMode.postProcessScopeImage(bmp); + } + + storeBitmap(bmp); } private void createScope(Configuration config) { diff --git a/app/src/main/java/xdsopl/robot36/Mode.java b/app/src/main/java/xdsopl/robot36/Mode.java index 3614711..2650ee5 100644 --- a/app/src/main/java/xdsopl/robot36/Mode.java +++ b/app/src/main/java/xdsopl/robot36/Mode.java @@ -6,22 +6,30 @@ Copyright 2024 Ahmet Inan package xdsopl.robot36; +import android.graphics.Bitmap; + public interface Mode { String getName(); - int getCode(); + int getVISCode(); int getWidth(); int getHeight(); - int getBegin(); + int getFirstPixelSampleIndex(); int getFirstSyncPulseIndex(); int getScanLineSamples(); - void reset(); + Bitmap postProcessScopeImage(Bitmap bmp); + void resetState(); + + /** + * @param frequencyOffset normalized correction of frequency (expected vs actual) + * @return true if scanline was decoded + */ boolean decodeScanLine(PixelBuffer pixelBuffer, float[] scratchBuffer, float[] scanLineBuffer, int scopeBufferWidth, int syncPulseIndex, int scanLineSamples, float frequencyOffset); } diff --git a/app/src/main/java/xdsopl/robot36/PaulDon.java b/app/src/main/java/xdsopl/robot36/PaulDon.java index 8883081..ee6e3cb 100644 --- a/app/src/main/java/xdsopl/robot36/PaulDon.java +++ b/app/src/main/java/xdsopl/robot36/PaulDon.java @@ -6,7 +6,7 @@ Copyright 2024 Ahmet Inan package xdsopl.robot36; -public class PaulDon implements Mode { +public class PaulDon extends BaseMode { private final ExponentialMovingAverage lowPassFilter; private final int horizontalPixels; private final int verticalPixels; @@ -56,7 +56,7 @@ public class PaulDon implements Mode { } @Override - public int getCode() { + public int getVISCode() { return code; } @@ -71,7 +71,7 @@ public class PaulDon implements Mode { } @Override - public int getBegin() { + public int getFirstPixelSampleIndex() { return beginSamples; } @@ -86,7 +86,7 @@ public class PaulDon implements Mode { } @Override - public void reset() { + public void resetState() { } @Override diff --git a/app/src/main/java/xdsopl/robot36/RGBDecoder.java b/app/src/main/java/xdsopl/robot36/RGBDecoder.java index febef26..fa1ef00 100644 --- a/app/src/main/java/xdsopl/robot36/RGBDecoder.java +++ b/app/src/main/java/xdsopl/robot36/RGBDecoder.java @@ -6,7 +6,7 @@ Copyright 2024 Ahmet Inan package xdsopl.robot36; -public class RGBDecoder implements Mode { +public class RGBDecoder extends BaseMode { private final ExponentialMovingAverage lowPassFilter; private final int horizontalPixels; private final int verticalPixels; @@ -51,7 +51,7 @@ public class RGBDecoder implements Mode { } @Override - public int getCode() { + public int getVISCode() { return code; } @@ -66,7 +66,7 @@ public class RGBDecoder implements Mode { } @Override - public int getBegin() { + public int getFirstPixelSampleIndex() { return beginSamples; } @@ -81,7 +81,7 @@ public class RGBDecoder implements Mode { } @Override - public void reset() { + public void resetState() { } @Override diff --git a/app/src/main/java/xdsopl/robot36/RawDecoder.java b/app/src/main/java/xdsopl/robot36/RawDecoder.java index f86a63d..931144d 100644 --- a/app/src/main/java/xdsopl/robot36/RawDecoder.java +++ b/app/src/main/java/xdsopl/robot36/RawDecoder.java @@ -6,7 +6,7 @@ Copyright 2024 Ahmet Inan package xdsopl.robot36; -public class RawDecoder implements Mode { +public class RawDecoder extends BaseMode { private final ExponentialMovingAverage lowPassFilter; private final int smallPictureMaxSamples; private final int mediumPictureMaxSamples; @@ -29,7 +29,7 @@ public class RawDecoder implements Mode { } @Override - public int getCode() { + public int getVISCode() { return -1; } @@ -44,7 +44,7 @@ public class RawDecoder implements Mode { } @Override - public int getBegin() { + public int getFirstPixelSampleIndex() { return 0; } @@ -59,7 +59,7 @@ public class RawDecoder implements Mode { } @Override - public void reset() { + public void resetState() { } @Override diff --git a/app/src/main/java/xdsopl/robot36/Robot_36_Color.java b/app/src/main/java/xdsopl/robot36/Robot_36_Color.java index 8ccdcdb..baf448e 100644 --- a/app/src/main/java/xdsopl/robot36/Robot_36_Color.java +++ b/app/src/main/java/xdsopl/robot36/Robot_36_Color.java @@ -6,7 +6,7 @@ Copyright 2024 Ahmet Inan package xdsopl.robot36; -public class Robot_36_Color implements Mode { +public class Robot_36_Color extends BaseMode { private final ExponentialMovingAverage lowPassFilter; private final int horizontalPixels; private final int verticalPixels; @@ -59,7 +59,7 @@ public class Robot_36_Color implements Mode { } @Override - public int getCode() { + public int getVISCode() { return 8; } @@ -74,7 +74,7 @@ public class Robot_36_Color implements Mode { } @Override - public int getBegin() { + public int getFirstPixelSampleIndex() { return beginSamples; } @@ -89,7 +89,7 @@ public class Robot_36_Color implements Mode { } @Override - public void reset() { + public void resetState() { lastEven = false; } diff --git a/app/src/main/java/xdsopl/robot36/Robot_72_Color.java b/app/src/main/java/xdsopl/robot36/Robot_72_Color.java index 88a9786..f1bf907 100644 --- a/app/src/main/java/xdsopl/robot36/Robot_72_Color.java +++ b/app/src/main/java/xdsopl/robot36/Robot_72_Color.java @@ -6,7 +6,7 @@ Copyright 2024 Ahmet Inan package xdsopl.robot36; -public class Robot_72_Color implements Mode { +public class Robot_72_Color extends BaseMode { private final ExponentialMovingAverage lowPassFilter; private final int horizontalPixels; private final int verticalPixels; @@ -57,7 +57,7 @@ public class Robot_72_Color implements Mode { } @Override - public int getCode() { + public int getVISCode() { return 12; } @@ -72,7 +72,7 @@ public class Robot_72_Color implements Mode { } @Override - public int getBegin() { + public int getFirstPixelSampleIndex() { return beginSamples; } @@ -87,7 +87,7 @@ public class Robot_72_Color implements Mode { } @Override - public void reset() { + public void resetState() { } @Override diff --git a/app/src/main/res/menu/menu_main.xml b/app/src/main/res/menu/menu_main.xml index c06b4d5..f6b4cbc 100644 --- a/app/src/main/res/menu/menu_main.xml +++ b/app/src/main/res/menu/menu_main.xml @@ -97,6 +97,9 @@ android:title="@string/wraase_sc2_180" /> + Scottie 2 Scottie DX Wraase SC2–180 + HF Fax 8 kHz 16 kHz 32 kHz