Compare commits

...

41 commits
v2.12 ... v2

Author SHA1 Message Date
Ahmet Inan 75146a5342 v2.16 2025-11-11 11:10:38 +01:00
Ahmet Inan 716fc64892 updated libs and tools 2025-11-11 10:44:28 +01:00
Ahmet Inan a39c180c44 moved hffax to contributed modes 2025-11-11 10:44:11 +01:00
Помаранча 265223f43d
Update strings.xml (#42)
* Update strings.xml

Final translation update. Sorry for not fixing it earlier.

* Update strings.xml

As you requested - I fixed it.
2025-09-11 17:14:02 +02:00
Ahmet Inan c55ae1b2ca
Merge pull request #40 from marek-o/adding-hffax2
Adding HF Fax
2025-08-23 07:53:21 +02:00
Marek Ossowski 03dc577ae8 Added HF Fax mode 2025-08-22 22:27:08 +02:00
Marek Ossowski 5fee5e0672 Copied RawDecoder.java to HFFax.java 2025-08-22 22:19:38 +02:00
Marek Ossowski 5b20da27a0 Adding BaseMode and postProcessScopeImage() 2025-08-22 22:13:54 +02:00
Marek Ossowski 1a4ffac26e Renaming and refactoring 2025-08-22 22:09:18 +02:00
Ahmet Inan 7ad54873da added frequency markers 2025-08-20 12:47:05 +02:00
Ahmet Inan 1502f20af1 updated libs and tools 2025-08-20 10:38:30 +02:00
Ahmet Inan 1a722d5289 overlooked this one 2025-04-16 07:47:08 +02:00
Ahmet Inan b105a415f3 numbering starts from tip 2025-04-15 21:37:29 +02:00
Ahmet Inan cb607c9d4e added schematic 2025-04-15 21:11:56 +02:00
Ahmet Inan 8b12ac6153 added line to mic converter 2025-04-15 20:24:48 +02:00
Ahmet Inan 040655552f integrated French translation 2025-04-07 08:30:30 +02:00
Raphaël de Courville aaf31ac4a7 Added french translations
Initial French translation
2025-04-07 07:59:27 +02:00
Ahmet Inan 54381558d1 updated libs and tools 2025-03-12 19:41:50 +01:00
Ahmet Inan ef3b251a8e v2.15 2025-02-26 08:26:29 +01:00
Ahmet Inan d61be31811 water falls downwards 2025-02-26 08:03:00 +01:00
Ahmet Inan ec302f6800 use capital letter for language 2025-02-25 20:40:09 +01:00
Ahmet Inan 913168adc3 integrated new default spectrogram 2025-02-25 19:45:17 +01:00
Ahmet Inan 771ab0fe0b we can go lower still 2025-02-25 17:22:40 +01:00
Ahmet Inan 1bd3d05782 start spectrum at 140 Hz so we can see up to 2700 Hz 2025-02-25 14:25:27 +01:00
Ahmet Inan f081bbddfd experimenting with STFT 2025-02-25 13:46:05 +01:00
Ahmet Inan 7f503ecef3 updated libs and tools 2025-02-25 12:24:35 +01:00
Ahmet Inan 8afa86d660 added Latin American Spanish translation 2025-01-21 12:40:19 +01:00
Ahmet Inan ef7e20f3a0 PD290 is now supported 2024-12-08 11:22:27 +01:00
Ahmet Inan 728c9df890 v2.14 2024-11-25 09:36:01 +01:00
Ahmet Inan c93dff432e use fixed point by default
simply too many devices have a broken floating point implementation
2024-11-24 07:43:38 +01:00
Ahmet Inan 1fbec49583 oops 2024-11-23 14:24:19 +01:00
Ahmet Inan 28ebde8e93 support full resolution saving of PD290 images
the scope image of PD290 is still cropped to 640 pixels but aspect
ratio is now correct
2024-11-22 16:34:25 +01:00
Ahmet Inan 2c3c6b1293 added experimental PD290 support
full 616 lines are supported but width is 640 pixels instead of 800 to
avoid breaking other modes. that means the aspect ratio is wrong for
PD290 at the moment.
2024-11-22 10:45:24 +01:00
Ahmet Inan 2d74f33bb6 integrated Ukrainian translation 2024-11-19 18:02:41 +01:00
Помаранча 07e2f59389 Added Ukrainian language 2024-11-19 17:59:23 +01:00
Ahmet Inan 0d1e90d613 v2.13 2024-11-14 11:00:55 +01:00
Ahmet Inan 61799452ff updated libs 2024-11-14 10:59:26 +01:00
Ahmet Inan beb6647909 added option to disable auto save 2024-11-13 11:28:43 +01:00
Ahmet Inan 418b1f0f06 updated libs and tools 2024-10-22 10:39:15 +02:00
Ahmet Inan a15b5b8104 integrated Polish translation 2024-10-22 09:43:06 +02:00
SP1IMJ a0873b2590 Polish language translation 2024-10-22 09:33:46 +02:00
33 changed files with 1301 additions and 131 deletions

View file

@ -1,9 +1,46 @@
### Robot36: The Java Cut
# Robot36 - SSTV Image Decoder
This is not a drill!
## Audio Line-Level to Microphone-Level Converter
Decoding SSTV signals is more reliable with a clean input. Using a direct cable connection instead of acoustic coupling avoids echo, distortion, and environmental noise.
Get ready to beam in a whole new Robot36, rebuilt from the ground up in pure Java! Just like that nostalgic reboot of your favorite childhood show (but hopefully better written!), things might be a little different this time around. There will be glitches, there will be bugs, but hey, that's the beauty of live transmission, right? Hold on to your spacesuits, folks, it's gonna be a bumpy ride!
Most smartphones use TRRS connectors for headsets. In these connectors, the sleeve and the second ring (next to the sleeve) serve dual roles: depending on the standard, one is the microphone input and the other is ground. The tip and first ring carry the left and right audio channels.
Stay tuned for further transmissions...
When a TRS plug is inserted into a TRRS jack, the sleeve and second ring are shorted together. This allows regular stereo headphones (without a microphone) to work correctly.
Instead of determining which pin is MIC or GND for each device, galvanic isolation can be used. This avoids compatibility issues, eliminates ground loops, protects against damage, and improves robustness.
Using a line-level output (e.g., from a radio or sound card) as a microphone input introduces several challenges:
* Line-level signals swing around 1V, while electret microphones produce signals in the millivolt range, so attenuation is needed.
* Electret microphones are biased via the TRRS connector, allowing their internal amplifiers to function. This bias must be blocked to avoid distortion.
* To make the smartphone recognize the input as a microphone, a resistor must be placed between the second ring and sleeve.
To reduce power consumption on the line-out device, a higher impedance can be achieved by inserting a resistor in series with the primary winding of a 1:1 audio transformer. If the source cannot drive high impedance, the primary can be connected directly, and attenuation applied on the secondary side. This increases power consumption and may heat the transformer.
Because the electret mic input is high-impedance and AC-coupled, the transformers secondary can resonate if left unterminated. Adding a resistor across the secondary dampens this resonance and flattens the frequency response. A value equal to the transformers impedance is typical, but a lower value can be used to both improve damping and provide additional attenuation. The ratio of the series resistor on the primary to the parallel resistor on the secondary determines the overall attenuation.
### Example Values
* Transformer: 1:1 audio transformer, 600Ω impedance, 140Ω DC resistance
* Primary side: 2.2kΩ resistor in series (any value between 1kΩ and 10kΩ is fine; a 10kΩ potentiometer allows adjustment)
* Secondary side: 100Ω resistor across the winding for damping and attenuation
* DC blocking capacitor: 2.2µF film capacitor (anything between 1µF and 100µF works; avoid values below 1µF to keep low-frequency SSTV content intact)
* Microphone sensing resistor: 2.2kΩ between the second ring and sleeve (values near 2kΩ are fine)
### Schematic
```
[Line IN] O---[R1]---+||+---+---|C1|---+---O [Ring 2]
S||S | |
T1: S||S [R2] [R3]
S||S | |
[Line GND] O----------+||+---+----------+---O [Sleeve]
```
Explanation of Symbols:
* [R1]: 2.2kΩ series resistor (primary side attenuation)
* T1: 1:1 audio transformer
* [C1]: 2.2µF capacitor (DC blocking)
* [R2]: 100Ω damping resistor (across secondary)
* [R3]: 2.2kΩ MIC detect resistor (between MIC and GND)
* [Line IN], [Line GND]: input from radio/sound card
* [Ring 2], [Sleeve]: TRRS plug connections to smartphone

View file

@ -4,14 +4,14 @@ plugins {
android {
namespace 'xdsopl.robot36'
compileSdk 34
compileSdk = 36
defaultConfig {
applicationId "xdsopl.robot36"
minSdk 24
targetSdk 34
versionCode 62
versionName "2.12"
targetSdk 36
versionCode 66
versionName "2.16"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
}

View file

@ -0,0 +1,16 @@
/*
Base class for all modes
Copyright 2025 Marek Ossowski <marek0ossowski@gmail.com>
*/
package xdsopl.robot36;
import android.graphics.Bitmap;
public abstract class BaseMode implements Mode {
@Override
public Bitmap postProcessScopeImage(Bitmap bmp) {
return bmp;
}
}

View file

@ -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<Mode> syncPulse5msModes;
private final ArrayList<Mode> syncPulse9msModes;
private final ArrayList<Mode> syncPulse20msModes;
@ -54,7 +55,7 @@ public class Decoder {
this.scopeBuffer = scopeBuffer;
this.imageBuffer = imageBuffer;
imageBuffer.line = -1;
pixelBuffer = new PixelBuffer(scopeBuffer.width, 2);
pixelBuffer = new PixelBuffer(800, 2);
demodulator = new Demodulator(sampleRate);
double pulseFilterSeconds = 0.0025;
int pulseFilterSamples = (int) Math.round(pulseFilterSeconds * sampleRate) | 1;
@ -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();
@ -115,6 +117,7 @@ public class Decoder {
syncPulse20msModes.add(new PaulDon("160", 98, 512, 400, 0.195584, sampleRate));
syncPulse20msModes.add(new PaulDon("180", 96, 640, 496, 0.18304, sampleRate));
syncPulse20msModes.add(new PaulDon("240", 97, 640, 496, 0.24448, sampleRate));
syncPulse20msModes.add(new PaulDon("290", 94, 800, 616, 0.2288, sampleRate));
}
private double scanLineMean(int[] lines) {
@ -156,7 +159,7 @@ public class Decoder {
private Mode findMode(ArrayList<Mode> modes, int code) {
for (Mode mode : modes)
if (mode.getCode() == code)
if (mode.getVISCode() == code)
return mode;
return null;
}
@ -169,10 +172,11 @@ public class Decoder {
}
private void copyUnscaled() {
int width = Math.min(scopeBuffer.width, pixelBuffer.width);
for (int row = 0; row < pixelBuffer.height; ++row) {
int line = scopeBuffer.width * scopeBuffer.line;
System.arraycopy(pixelBuffer.pixels, row * pixelBuffer.width, scopeBuffer.pixels, line, pixelBuffer.width);
Arrays.fill(scopeBuffer.pixels, line + pixelBuffer.width, line + scopeBuffer.width, 0);
System.arraycopy(pixelBuffer.pixels, row * pixelBuffer.width, scopeBuffer.pixels, line, width);
Arrays.fill(scopeBuffer.pixels, line + width, line + scopeBuffer.width, 0);
System.arraycopy(scopeBuffer.pixels, line, scopeBuffer.pixels, scopeBuffer.width * (scopeBuffer.line + scopeBuffer.height / 2), scopeBuffer.width);
scopeBuffer.line = (scopeBuffer.line + 1) % (scopeBuffer.height / 2);
}
@ -206,7 +210,7 @@ public class Decoder {
finish = imageBuffer.line == imageBuffer.height;
}
int scale = scopeBuffer.width / pixelBuffer.width;
if (scale == 1)
if (scale <= 1)
copyUnscaled();
else
copyScaled(scale);
@ -330,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;
@ -344,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<Mode> 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<Mode> 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) {
@ -377,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);
@ -385,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;
@ -415,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) {
@ -448,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;

View file

@ -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;

View file

@ -0,0 +1,353 @@
/*
Fast Fourier Transform
Copyright 2025 Ahmet Inan <xdsopl@gmail.com>
*/
package xdsopl.robot36;
public class FastFourierTransform {
private final Complex[] tf;
private final Complex tmpA, tmpB, tmpC, tmpD, tmpE, tmpF, tmpG, tmpH, tmpI, tmpJ, tmpK, tmpL, tmpM;
private final Complex tin0, tin1, tin2, tin3, tin4, tin5, tin6;
FastFourierTransform(int length) {
int rest = length;
while (rest > 1) {
if (rest % 2 == 0)
rest /= 2;
else if (rest % 3 == 0)
rest /= 3;
else if (rest % 5 == 0)
rest /= 5;
else if (rest % 7 == 0)
rest /= 7;
else
break;
}
if (rest != 1)
throw new IllegalArgumentException(
"Transform length must be a composite of 2, 3, 5 and 7, but was: "
+ length);
tf = new Complex[length];
for (int i = 0; i < length; ++i) {
double x = -(2.0 * Math.PI * i) / length;
float a = (float) Math.cos(x);
float b = (float) Math.sin(x);
tf[i] = new Complex(a, b);
}
tmpA = new Complex();
tmpB = new Complex();
tmpC = new Complex();
tmpD = new Complex();
tmpE = new Complex();
tmpF = new Complex();
tmpG = new Complex();
tmpH = new Complex();
tmpI = new Complex();
tmpJ = new Complex();
tmpK = new Complex();
tmpL = new Complex();
tmpM = new Complex();
tin0 = new Complex();
tin1 = new Complex();
tin2 = new Complex();
tin3 = new Complex();
tin4 = new Complex();
tin5 = new Complex();
tin6 = new Complex();
}
private boolean isPowerOfTwo(int n) {
return n > 0 && (n & (n - 1)) == 0;
}
private boolean isPowerOfFour(int n) {
return isPowerOfTwo(n) && (n & 0x55555555) != 0;
}
private float cos(int n, int N) {
return (float) Math.cos(n * 2.0 * Math.PI / N);
}
private float sin(int n, int N) {
return (float) Math.sin(n * 2.0 * Math.PI / N);
}
private void dft2(Complex out0, Complex out1, Complex in0, Complex in1) {
out0.set(in0).add(in1);
out1.set(in0).sub(in1);
}
private void radix2(Complex[] out, Complex[] in, int O, int I, int N, int S, boolean F) {
if (N == 2) {
dft2(out[O], out[O + 1], in[I], in[I + S]);
} else {
int Q = N / 2;
dit(out, in, O, I, Q, 2 * S, F);
dit(out, in, O + Q, I + S, Q, 2 * S, F);
for (int k0 = O, k1 = O + Q, l1 = 0; k0 < O + Q; ++k0, ++k1, l1 += S) {
tin1.set(tf[l1]);
if (!F)
tin1.conj();
tin0.set(out[k0]);
tin1.mul(out[k1]);
dft2(out[k0], out[k1], tin0, tin1);
}
}
}
private void fwd3(Complex out0, Complex out1, Complex out2, Complex in0, Complex in1, Complex in2) {
tmpA.set(in1).add(in2);
tmpB.set(in1.imag - in2.imag, in2.real - in1.real);
tmpC.set(tmpA).mul(cos(1, 3));
tmpD.set(tmpB).mul(sin(1, 3));
out0.set(in0).add(tmpA);
out1.set(in0).add(tmpC).add(tmpD);
out2.set(in0).add(tmpC).sub(tmpD);
}
private void radix3(Complex[] out, Complex[] in, int O, int I, int N, int S, boolean F) {
if (N == 3) {
if (F)
fwd3(out[O], out[O + 1], out[O + 2],
in[I], in[I + S], in[I + 2 * S]);
else
fwd3(out[O], out[O + 2], out[O + 1],
in[I], in[I + S], in[I + 2 * S]);
} else {
int Q = N / 3;
dit(out, in, O, I, Q, 3 * S, F);
dit(out, in, O + Q, I + S, Q, 3 * S, F);
dit(out, in, O + 2 * Q, I + 2 * S, Q, 3 * S, F);
for (int k0 = O, k1 = O + Q, k2 = O + 2 * Q, l1 = 0, l2 = 0;
k0 < O + Q; ++k0, ++k1, ++k2, l1 += S, l2 += 2 * S) {
tin1.set(tf[l1]);
tin2.set(tf[l2]);
if (!F) {
tin1.conj();
tin2.conj();
}
tin0.set(out[k0]);
tin1.mul(out[k1]);
tin2.mul(out[k2]);
if (F)
fwd3(out[k0], out[k1], out[k2], tin0, tin1, tin2);
else
fwd3(out[k0], out[k2], out[k1], tin0, tin1, tin2);
}
}
}
private void fwd4(Complex out0, Complex out1, Complex out2, Complex out3,
Complex in0, Complex in1, Complex in2, Complex in3) {
tmpA.set(in0).add(in2);
tmpB.set(in0).sub(in2);
tmpC.set(in1).add(in3);
tmpD.set(in1.imag - in3.imag, in3.real - in1.real);
out0.set(tmpA).add(tmpC);
out1.set(tmpB).add(tmpD);
out2.set(tmpA).sub(tmpC);
out3.set(tmpB).sub(tmpD);
}
private void radix4(Complex[] out, Complex[] in, int O, int I, int N, int S, boolean F) {
if (N == 4) {
if (F)
fwd4(out[O], out[O + 1], out[O + 2], out[O + 3],
in[I], in[I + S], in[I + 2 * S], in[I + 3 * S]);
else
fwd4(out[O], out[O + 3], out[O + 2], out[O + 1],
in[I], in[I + S], in[I + 2 * S], in[I + 3 * S]);
} else {
int Q = N / 4;
radix4(out, in, O, I, Q, 4 * S, F);
radix4(out, in, O + Q, I + S, Q, 4 * S, F);
radix4(out, in, O + 2 * Q, I + 2 * S, Q, 4 * S, F);
radix4(out, in, O + 3 * Q, I + 3 * S, Q, 4 * S, F);
for (int k0 = O, k1 = O + Q, k2 = O + 2 * Q, k3 = O + 3 * Q, l1 = 0, l2 = 0, l3 = 0;
k0 < O + Q; ++k0, ++k1, ++k2, ++k3, l1 += S, l2 += 2 * S, l3 += 3 * S) {
tin1.set(tf[l1]);
tin2.set(tf[l2]);
tin3.set(tf[l3]);
if (!F) {
tin1.conj();
tin2.conj();
tin3.conj();
}
tin0.set(out[k0]);
tin1.mul(out[k1]);
tin2.mul(out[k2]);
tin3.mul(out[k3]);
if (F)
fwd4(out[k0], out[k1], out[k2], out[k3], tin0, tin1, tin2, tin3);
else
fwd4(out[k0], out[k3], out[k2], out[k1], tin0, tin1, tin2, tin3);
}
}
}
private void fwd5(Complex out0, Complex out1, Complex out2, Complex out3, Complex out4,
Complex in0, Complex in1, Complex in2, Complex in3, Complex in4) {
tmpA.set(in1).add(in4);
tmpB.set(in2).add(in3);
tmpC.set(in1.imag - in4.imag, in4.real - in1.real);
tmpD.set(in2.imag - in3.imag, in3.real - in2.real);
tmpF.set(tmpA).mul(cos(1, 5)).add(tmpE.set(tmpB).mul(cos(2, 5)));
tmpG.set(tmpC).mul(sin(1, 5)).add(tmpE.set(tmpD).mul(sin(2, 5)));
tmpH.set(tmpA).mul(cos(2, 5)).add(tmpE.set(tmpB).mul(cos(1, 5)));
tmpI.set(tmpC).mul(sin(2, 5)).sub(tmpE.set(tmpD).mul(sin(1, 5)));
out0.set(in0).add(tmpA).add(tmpB);
out1.set(in0).add(tmpF).add(tmpG);
out2.set(in0).add(tmpH).add(tmpI);
out3.set(in0).add(tmpH).sub(tmpI);
out4.set(in0).add(tmpF).sub(tmpG);
}
private void radix5(Complex[] out, Complex[] in, int O, int I, int N, int S, boolean F) {
if (N == 5) {
if (F)
fwd5(out[O], out[O + 1], out[O + 2], out[O + 3], out[O + 4],
in[I], in[I + S], in[I + 2 * S], in[I + 3 * S], in[I + 4 * S]);
else
fwd5(out[O], out[O + 4], out[O + 3], out[O + 2], out[O + 1],
in[I], in[I + S], in[I + 2 * S], in[I + 3 * S], in[I + 4 * S]);
} else {
int Q = N / 5;
dit(out, in, O, I, Q, 5 * S, F);
dit(out, in, O + Q, I + S, Q, 5 * S, F);
dit(out, in, O + 2 * Q, I + 2 * S, Q, 5 * S, F);
dit(out, in, O + 3 * Q, I + 3 * S, Q, 5 * S, F);
dit(out, in, O + 4 * Q, I + 4 * S, Q, 5 * S, F);
for (int k0 = O, k1 = O + Q, k2 = O + 2 * Q, k3 = O + 3 * Q, k4 = O + 4 * Q, l1 = 0, l2 = 0, l3 = 0, l4 = 0;
k0 < O + Q; ++k0, ++k1, ++k2, ++k3, ++k4, l1 += S, l2 += 2 * S, l3 += 3 * S, l4 += 4 * S) {
tin1.set(tf[l1]);
tin2.set(tf[l2]);
tin3.set(tf[l3]);
tin4.set(tf[l4]);
if (!F) {
tin1.conj();
tin2.conj();
tin3.conj();
tin4.conj();
}
tin0.set(out[k0]);
tin1.mul(out[k1]);
tin2.mul(out[k2]);
tin3.mul(out[k3]);
tin4.mul(out[k4]);
if (F)
fwd5(out[k0], out[k1], out[k2], out[k3], out[k4], tin0, tin1, tin2, tin3, tin4);
else
fwd5(out[k0], out[k4], out[k3], out[k2], out[k1], tin0, tin1, tin2, tin3, tin4);
}
}
}
private void fwd7(Complex out0, Complex out1, Complex out2, Complex out3, Complex out4, Complex out5, Complex out6,
Complex in0, Complex in1, Complex in2, Complex in3, Complex in4, Complex in5, Complex in6) {
tmpA.set(in1).add(in6);
tmpB.set(in2).add(in5);
tmpC.set(in3).add(in4);
tmpD.set(in1.imag - in6.imag, in6.real - in1.real);
tmpE.set(in2.imag - in5.imag, in5.real - in2.real);
tmpF.set(in3.imag - in4.imag, in4.real - in3.real);
tmpH.set(tmpA).mul(cos(1, 7)).add(tmpG.set(tmpB).mul(cos(2, 7))).add(tmpG.set(tmpC).mul(cos(3, 7)));
tmpI.set(tmpD).mul(sin(1, 7)).add(tmpG.set(tmpE).mul(sin(2, 7))).add(tmpG.set(tmpF).mul(sin(3, 7)));
tmpJ.set(tmpA).mul(cos(2, 7)).add(tmpG.set(tmpB).mul(cos(3, 7))).add(tmpG.set(tmpC).mul(cos(1, 7)));
tmpK.set(tmpD).mul(sin(2, 7)).sub(tmpG.set(tmpE).mul(sin(3, 7))).sub(tmpG.set(tmpF).mul(sin(1, 7)));
tmpL.set(tmpA).mul(cos(3, 7)).add(tmpG.set(tmpB).mul(cos(1, 7))).add(tmpG.set(tmpC).mul(cos(2, 7)));
tmpM.set(tmpD).mul(sin(3, 7)).sub(tmpG.set(tmpE).mul(sin(1, 7))).add(tmpG.set(tmpF).mul(sin(2, 7)));
out0.set(in0).add(tmpA).add(tmpB).add(tmpC);
out1.set(in0).add(tmpH).add(tmpI);
out2.set(in0).add(tmpJ).add(tmpK);
out3.set(in0).add(tmpL).add(tmpM);
out4.set(in0).add(tmpL).sub(tmpM);
out5.set(in0).add(tmpJ).sub(tmpK);
out6.set(in0).add(tmpH).sub(tmpI);
}
private void radix7(Complex[] out, Complex[] in, int O, int I, int N, int S, boolean F) {
if (N == 7) {
if (F)
fwd7(out[O], out[O + 1], out[O + 2], out[O + 3], out[O + 4], out[O + 5], out[O + 6],
in[I], in[I + S], in[I + 2 * S], in[I + 3 * S], in[I + 4 * S], in[I + 5 * S], in[I + 6 * S]);
else
fwd7(out[O], out[O + 6], out[O + 5], out[O + 4], out[O + 3], out[O + 2], out[O + 1],
in[I], in[I + S], in[I + 2 * S], in[I + 3 * S], in[I + 4 * S], in[I + 5 * S], in[I + 6 * S]);
} else {
int Q = N / 7;
dit(out, in, O, I, Q, 7 * S, F);
dit(out, in, O + Q, I + S, Q, 7 * S, F);
dit(out, in, O + 2 * Q, I + 2 * S, Q, 7 * S, F);
dit(out, in, O + 3 * Q, I + 3 * S, Q, 7 * S, F);
dit(out, in, O + 4 * Q, I + 4 * S, Q, 7 * S, F);
dit(out, in, O + 5 * Q, I + 5 * S, Q, 7 * S, F);
dit(out, in, O + 6 * Q, I + 6 * S, Q, 7 * S, F);
for (int k0 = O, k1 = O + Q, k2 = O + 2 * Q, k3 = O + 3 * Q, k4 = O + 4 * Q, k5 = O + 5 * Q, k6 = O + 6 * Q, l1 = 0, l2 = 0, l3 = 0, l4 = 0, l5 = 0, l6 = 0;
k0 < O + Q; ++k0, ++k1, ++k2, ++k3, ++k4, ++k5, ++k6, l1 += S, l2 += 2 * S, l3 += 3 * S, l4 += 4 * S, l5 += 5 * S, l6 += 6 * S) {
tin1.set(tf[l1]);
tin2.set(tf[l2]);
tin3.set(tf[l3]);
tin4.set(tf[l4]);
tin5.set(tf[l5]);
tin6.set(tf[l6]);
if (!F) {
tin1.conj();
tin2.conj();
tin3.conj();
tin4.conj();
tin5.conj();
tin6.conj();
}
tin0.set(out[k0]);
tin1.mul(out[k1]);
tin2.mul(out[k2]);
tin3.mul(out[k3]);
tin4.mul(out[k4]);
tin5.mul(out[k5]);
tin6.mul(out[k6]);
if (F)
fwd7(out[k0], out[k1], out[k2], out[k3], out[k4], out[k5], out[k6], tin0, tin1, tin2, tin3, tin4, tin5, tin6);
else
fwd7(out[k0], out[k6], out[k5], out[k4], out[k3], out[k2], out[k1], tin0, tin1, tin2, tin3, tin4, tin5, tin6);
}
}
}
private void dit(Complex[] out, Complex[] in, int O, int I, int N, int S, boolean F) {
if (N == 1)
out[O].set(in[I]);
else if (isPowerOfFour(N))
radix4(out, in, O, I, N, S, F);
else if (N % 7 == 0)
radix7(out, in, O, I, N, S, F);
else if (N % 5 == 0)
radix5(out, in, O, I, N, S, F);
else if (N % 3 == 0)
radix3(out, in, O, I, N, S, F);
else if (N % 2 == 0)
radix2(out, in, O, I, N, S, F);
}
void forward(Complex[] out, Complex[] in) {
if (in.length != tf.length)
throw new IllegalArgumentException("Input array length (" + in.length
+ ") must be equal to Transform length (" + tf.length + ")");
if (out.length != tf.length)
throw new IllegalArgumentException("Output array length (" + out.length
+ ") must be equal to Transform length (" + tf.length + ")");
dit(out, in, 0, 0, tf.length, 1, true);
}
@SuppressWarnings("unused")
void backward(Complex[] out, Complex[] in) {
if (in.length != tf.length)
throw new IllegalArgumentException("Input array length (" + in.length
+ ") must be equal to Transform length (" + tf.length + ")");
if (out.length != tf.length)
throw new IllegalArgumentException("Output array length (" + out.length
+ ") must be equal to Transform length (" + tf.length + ")");
dit(out, in, 0, 0, tf.length, 1, false);
}
}

View file

@ -0,0 +1,137 @@
/*
HF Fax mode
Copyright 2025 Marek Ossowski <marek0ossowski@gmail.com>
*/
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;
}
}

View file

@ -0,0 +1,13 @@
/*
Hann window
Copyright 2025 Ahmet Inan <xdsopl@gmail.com>
*/
package xdsopl.robot36;
public class Hann {
static double window(int n, int N) {
return 0.5 * (1.0 - Math.cos((2.0 * Math.PI * n) / (N - 1)));
}
}

View file

@ -64,13 +64,14 @@ public class MainActivity extends AppCompatActivity {
private Bitmap scopeBitmap;
private PixelBuffer scopeBuffer;
private ImageView scopeView;
private Bitmap freqPlotBitmap;
private PixelBuffer freqPlotBuffer;
private ImageView freqPlotView;
private Bitmap waterfallPlotBitmap;
private PixelBuffer waterfallPlotBuffer;
private ImageView waterfallPlotView;
private Bitmap peakMeterBitmap;
private PixelBuffer peakMeterBuffer;
private ImageView peakMeterView;
private PixelBuffer imageBuffer;
private ShortTimeFourierTransform stft;
private short[] shortBuffer;
private float[] recordBuffer;
private AudioRecord audioRecord;
@ -78,6 +79,7 @@ public class MainActivity extends AppCompatActivity {
private Menu menu;
private String currentMode;
private String language;
private Complex input;
private int recordRate;
private int recordChannel;
private int audioSource;
@ -85,6 +87,10 @@ public class MainActivity extends AppCompatActivity {
private int fgColor;
private int thinColor;
private int tintColor;
private boolean autoSave;
private boolean showSpectrogram;
private final int binWidthHz = 10;
private final int[] freqMarkers = { 1100, 1300, 1500, 2300 };
private void setStatus(int id) {
setTitle(id);
@ -136,7 +142,10 @@ public class MainActivity extends AppCompatActivity {
recordBuffer[i] = .000030517578125f * shortBuffer[i];
}
processPeakMeter();
if (showSpectrogram)
processSpectrogram();
boolean newLines = decoder.process(recordBuffer, recordChannel);
if (!showSpectrogram)
processFreqPlot();
if (newLines) {
processScope();
@ -160,29 +169,104 @@ public class MainActivity extends AppCompatActivity {
peakMeterView.invalidate();
}
private double clamp(double x) {
return Math.min(Math.max(x, 0), 1);
}
private int argb(double a, double r, double g, double b) {
a = clamp(a);
r = clamp(r);
g = clamp(g);
b = clamp(b);
r *= a;
g *= a;
b *= a;
r = Math.sqrt(r);
g = Math.sqrt(g);
b = Math.sqrt(b);
int A = (int) Math.rint(255 * a);
int R = (int) Math.rint(255 * r);
int G = (int) Math.rint(255 * g);
int B = (int) Math.rint(255 * b);
return (A << 24) | (R << 16) | (G << 8) | B;
}
private int rainbow(double v) {
v = clamp(v);
double t = 4 * v - 2;
return argb(4 * v, t, 1 - Math.abs(t), -t);
}
private void processSpectrogram() {
boolean process = false;
int channels = recordChannel > 0 ? 2 : 1;
for (int j = 0; j < recordBuffer.length / channels; ++j) {
switch (recordChannel) {
case 1:
input.set(recordBuffer[2 * j]);
break;
case 2:
input.set(recordBuffer[2 * j + 1]);
break;
case 3:
input.set(recordBuffer[2 * j] + recordBuffer[2 * j + 1]);
break;
case 4:
input.set(recordBuffer[2 * j], recordBuffer[2 * j + 1]);
break;
default:
input.set(recordBuffer[j]);
}
if (stft.push(input)) {
process = true;
int stride = waterfallPlotBuffer.width;
waterfallPlotBuffer.line = (waterfallPlotBuffer.line + waterfallPlotBuffer.height / 2 - 1) % (waterfallPlotBuffer.height / 2);
int line = stride * waterfallPlotBuffer.line;
double lowest = Math.log(1e-9);
double highest = Math.log(1);
double range = highest - lowest;
int minFreq = 140;
int minBin = minFreq / binWidthHz;
for (int i = 0; i < stride; ++i)
waterfallPlotBuffer.pixels[line + i] = rainbow((Math.log(stft.power[i + minBin]) - lowest) / range);
for (int freq : freqMarkers)
waterfallPlotBuffer.pixels[line + (freq - minFreq) / binWidthHz] = fgColor;
System.arraycopy(waterfallPlotBuffer.pixels, line, waterfallPlotBuffer.pixels, line + stride * (waterfallPlotBuffer.height / 2), stride);
}
}
if (process) {
int width = waterfallPlotBitmap.getWidth();
int height = waterfallPlotBitmap.getHeight();
int stride = waterfallPlotBuffer.width;
int offset = stride * waterfallPlotBuffer.line;
waterfallPlotBitmap.setPixels(waterfallPlotBuffer.pixels, offset, stride, 0, 0, width, height);
waterfallPlotView.invalidate();
}
}
private void processFreqPlot() {
int width = freqPlotBitmap.getWidth();
int height = freqPlotBitmap.getHeight();
int stride = freqPlotBuffer.width;
int line = stride * freqPlotBuffer.line;
int width = waterfallPlotBitmap.getWidth();
int height = waterfallPlotBitmap.getHeight();
int stride = waterfallPlotBuffer.width;
waterfallPlotBuffer.line = (waterfallPlotBuffer.line + waterfallPlotBuffer.height / 2 - 1) % (waterfallPlotBuffer.height / 2);
int line = stride * waterfallPlotBuffer.line;
int channels = recordChannel > 0 ? 2 : 1;
int samples = recordBuffer.length / channels;
int spread = 2;
Arrays.fill(freqPlotBuffer.pixels, line, line + stride, 0);
Arrays.fill(waterfallPlotBuffer.pixels, line, line + stride, 0);
for (int i = 0; i < samples; ++i) {
int x = Math.round((recordBuffer[i] + 2.5f) * 0.25f * stride);
if (x >= spread && x < stride - spread)
for (int j = -spread; j <= spread; ++j)
freqPlotBuffer.pixels[line + x + j] += 1 + spread * spread - j * j;
waterfallPlotBuffer.pixels[line + x + j] += 1 + spread * spread - j * j;
}
int factor = 960 / samples;
for (int i = 0; i < stride; ++i)
freqPlotBuffer.pixels[line + i] = 0x00FFFFFF & fgColor | Math.min(factor * freqPlotBuffer.pixels[line + i], 255) << 24;
System.arraycopy(freqPlotBuffer.pixels, line, freqPlotBuffer.pixels, line + stride * (freqPlotBuffer.height / 2), stride);
freqPlotBuffer.line = (freqPlotBuffer.line + 1) % (freqPlotBuffer.height / 2);
int offset = stride * (freqPlotBuffer.line + freqPlotBuffer.height / 2 - height);
freqPlotBitmap.setPixels(freqPlotBuffer.pixels, offset, stride, 0, 0, width, height);
freqPlotView.invalidate();
waterfallPlotBuffer.pixels[line + i] = 0x00FFFFFF & fgColor | Math.min(factor * waterfallPlotBuffer.pixels[line + i], 255) << 24;
System.arraycopy(waterfallPlotBuffer.pixels, line, waterfallPlotBuffer.pixels, line + stride * (waterfallPlotBuffer.height / 2), stride);
int offset = stride * waterfallPlotBuffer.line;
waterfallPlotBitmap.setPixels(waterfallPlotBuffer.pixels, offset, stride, 0, 0, width, height);
waterfallPlotView.invalidate();
}
private void processScope() {
@ -198,6 +282,7 @@ public class MainActivity extends AppCompatActivity {
if (imageBuffer.line < imageBuffer.height)
return;
imageBuffer.line = -1;
if (autoSave)
storeBitmap(Bitmap.createBitmap(imageBuffer.pixels, imageBuffer.width, imageBuffer.height, Bitmap.Config.ARGB_8888));
}
@ -236,6 +321,7 @@ public class MainActivity extends AppCompatActivity {
if (rateChanged) {
decoder = new Decoder(scopeBuffer, imageBuffer, getString(R.string.raw_mode), recordRate);
decoder.setMode(currentMode);
stft = new ShortTimeFourierTransform(recordRate / binWidthHz, 3);
}
startListening();
} else {
@ -302,6 +388,34 @@ public class MainActivity extends AppCompatActivity {
initAudioRecord();
}
private void setShowSpectrogram(boolean newShowSpectrogram) {
if (showSpectrogram == newShowSpectrogram)
return;
showSpectrogram = newShowSpectrogram;
updateWaterfallPlotMenu();
}
private void updateWaterfallPlotMenu() {
if (showSpectrogram)
menu.findItem(R.id.action_show_spectrogram).setChecked(true);
else
menu.findItem(R.id.action_show_frequency_plot).setChecked(true);
}
private void setAutoSave(boolean newAutoSave) {
if (autoSave == newAutoSave)
return;
autoSave = newAutoSave;
updateAutoSaveMenu();
}
private void updateAutoSaveMenu() {
if (autoSave)
menu.findItem(R.id.action_enable_auto_save).setChecked(true);
else
menu.findItem(R.id.action_disable_auto_save).setChecked(true);
}
private void updateRecordRateMenu() {
switch (recordRate) {
case 8000:
@ -385,6 +499,8 @@ public class MainActivity extends AppCompatActivity {
state.putInt("recordChannel", recordChannel);
state.putInt("audioSource", audioSource);
state.putInt("audioFormat", audioFormat);
state.putBoolean("autoSave", autoSave);
state.putBoolean("showSpectrogram", showSpectrogram);
state.putString("language", language);
super.onSaveInstanceState(state);
}
@ -397,6 +513,8 @@ public class MainActivity extends AppCompatActivity {
edit.putInt("recordChannel", recordChannel);
edit.putInt("audioSource", audioSource);
edit.putInt("audioFormat", audioFormat);
edit.putBoolean("autoSave", autoSave);
edit.putBoolean("showSpectrogram", showSpectrogram);
edit.putString("language", language);
edit.apply();
}
@ -406,7 +524,9 @@ public class MainActivity extends AppCompatActivity {
final int defaultSampleRate = 44100;
final int defaultChannelSelect = 0;
final int defaultAudioSource = MediaRecorder.AudioSource.MIC;
final int defaultAudioFormat = AudioFormat.ENCODING_PCM_FLOAT;
final int defaultAudioFormat = AudioFormat.ENCODING_PCM_16BIT;
final boolean defaultAutoSave = true;
final boolean defaultShowSpectrogram = true;
final String defaultLanguage = "system";
if (state == null) {
SharedPreferences pref = getPreferences(Context.MODE_PRIVATE);
@ -415,6 +535,8 @@ public class MainActivity extends AppCompatActivity {
recordChannel = pref.getInt("recordChannel", defaultChannelSelect);
audioSource = pref.getInt("audioSource", defaultAudioSource);
audioFormat = pref.getInt("audioFormat", defaultAudioFormat);
autoSave = pref.getBoolean("autoSave", defaultAutoSave);
showSpectrogram = pref.getBoolean("showSpectrogram", defaultShowSpectrogram);
language = pref.getString("language", defaultLanguage);
} else {
AppCompatDelegate.setDefaultNightMode(state.getInt("nightMode", AppCompatDelegate.getDefaultNightMode()));
@ -422,6 +544,8 @@ public class MainActivity extends AppCompatActivity {
recordChannel = state.getInt("recordChannel", defaultChannelSelect);
audioSource = state.getInt("audioSource", defaultAudioSource);
audioFormat = state.getInt("audioFormat", defaultAudioFormat);
autoSave = state.getBoolean("autoSave", defaultAutoSave);
showSpectrogram = state.getBoolean("showSpectrogram", defaultShowSpectrogram);
language = state.getString("language", defaultLanguage);
}
super.onCreate(state);
@ -434,11 +558,12 @@ public class MainActivity extends AppCompatActivity {
thinColor = getColor(R.color.thin);
tintColor = getColor(R.color.tint);
scopeBuffer = new PixelBuffer(640, 2 * 1280);
freqPlotBuffer = new PixelBuffer(256, 2 * 256);
waterfallPlotBuffer = new PixelBuffer(256, 2 * 256);
peakMeterBuffer = new PixelBuffer(1, 16);
imageBuffer = new PixelBuffer(640, 496);
imageBuffer = new PixelBuffer(800, 616);
input = new Complex();
createScope(config);
createFreqPlot(config);
createWaterfallPlot(config);
createPeakMeter();
List<String> permissions = new ArrayList<>();
if (ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO) != PackageManager.PERMISSION_GRANTED) {
@ -469,6 +594,8 @@ public class MainActivity extends AppCompatActivity {
updateRecordChannelMenu();
updateAudioSourceMenu();
updateAudioFormatMenu();
updateWaterfallPlotMenu();
updateAutoSaveMenu();
return true;
}
@ -491,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;
@ -523,6 +654,10 @@ public class MainActivity extends AppCompatActivity {
setMode(R.string.pd240);
return true;
}
if (id == R.id.action_force_pd290) {
setMode(R.string.pd290);
return true;
}
if (id == R.id.action_force_martin1) {
setMode(R.string.martin1);
return true;
@ -615,6 +750,22 @@ public class MainActivity extends AppCompatActivity {
setAudioFormat(AudioFormat.ENCODING_PCM_16BIT);
return true;
}
if (id == R.id.action_show_spectrogram) {
setShowSpectrogram(true);
return true;
}
if (id == R.id.action_show_frequency_plot) {
setShowSpectrogram(false);
return true;
}
if (id == R.id.action_enable_auto_save) {
setAutoSave(true);
return true;
}
if (id == R.id.action_disable_auto_save) {
setAutoSave(false);
return true;
}
if (id == R.id.action_enable_night_mode) {
AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES);
return true;
@ -651,6 +802,22 @@ public class MainActivity extends AppCompatActivity {
setLanguage("pt-BR");
return true;
}
if (id == R.id.action_polish) {
setLanguage("pl");
return true;
}
if (id == R.id.action_ukrainian) {
setLanguage("uk");
return true;
}
if (id == R.id.action_latin_american_spanish) {
setLanguage("es-r419");
return true;
}
if (id == R.id.action_french) {
setLanguage("fr");
return true;
}
return super.onOptionsItemSelected(item);
}
@ -665,17 +832,24 @@ 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) {
int screenWidthDp = config.screenWidthDp;
int screenHeightDp = config.screenHeightDp;
int freqPlotHeightDp = 64;
int waterfallPlotHeightDp = 64;
if (config.orientation == Configuration.ORIENTATION_LANDSCAPE)
screenWidthDp /= 2;
else
screenHeightDp -= freqPlotHeightDp;
screenHeightDp -= waterfallPlotHeightDp;
int actionBarHeightDp = 64;
screenHeightDp -= actionBarHeightDp;
int width = scopeBuffer.width;
@ -689,18 +863,18 @@ public class MainActivity extends AppCompatActivity {
scopeView.setImageBitmap(scopeBitmap);
}
private void createFreqPlot(Configuration config) {
int width = freqPlotBuffer.width;
int height = freqPlotBuffer.height / 2;
private void createWaterfallPlot(Configuration config) {
int width = waterfallPlotBuffer.width;
int height = waterfallPlotBuffer.height / 2;
if (config.orientation != Configuration.ORIENTATION_LANDSCAPE)
height /= 4;
freqPlotBitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888);
int stride = freqPlotBuffer.width;
int offset = stride * (freqPlotBuffer.line + freqPlotBuffer.height / 2 - height);
freqPlotBitmap.setPixels(freqPlotBuffer.pixels, offset, stride, 0, 0, width, height);
freqPlotView = findViewById(R.id.freq_plot);
freqPlotView.setScaleType(ImageView.ScaleType.FIT_XY);
freqPlotView.setImageBitmap(freqPlotBitmap);
waterfallPlotBitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888);
int stride = waterfallPlotBuffer.width;
int offset = stride * waterfallPlotBuffer.line;
waterfallPlotBitmap.setPixels(waterfallPlotBuffer.pixels, offset, stride, 0, 0, width, height);
waterfallPlotView = findViewById(R.id.waterfall_plot);
waterfallPlotView.setScaleType(ImageView.ScaleType.FIT_XY);
waterfallPlotView.setImageBitmap(waterfallPlotBitmap);
}
private void createPeakMeter() {
@ -717,7 +891,7 @@ public class MainActivity extends AppCompatActivity {
setContentView(config.orientation == Configuration.ORIENTATION_LANDSCAPE ? R.layout.activity_main_land : R.layout.activity_main);
handleInsets();
createScope(config);
createFreqPlot(config);
createWaterfallPlot(config);
createPeakMeter();
}

View file

@ -6,22 +6,30 @@ Copyright 2024 Ahmet Inan <xdsopl@gmail.com>
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);
}

View file

@ -6,7 +6,7 @@ Copyright 2024 Ahmet Inan <xdsopl@gmail.com>
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

View file

@ -6,7 +6,7 @@ Copyright 2024 Ahmet Inan <xdsopl@gmail.com>
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

View file

@ -6,7 +6,7 @@ Copyright 2024 Ahmet Inan <xdsopl@gmail.com>
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

View file

@ -6,7 +6,7 @@ Copyright 2024 Ahmet Inan <xdsopl@gmail.com>
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;
}

View file

@ -6,7 +6,7 @@ Copyright 2024 Ahmet Inan <xdsopl@gmail.com>
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

View file

@ -0,0 +1,50 @@
/*
Short Time Fourier Transform
Copyright 2025 Ahmet Inan <xdsopl@gmail.com>
*/
package xdsopl.robot36;
public class ShortTimeFourierTransform {
private final FastFourierTransform fft;
private final Complex[] prev, fold, freq;
private final float[] weight;
private final Complex temp;
private int index;
public final float[] power;
ShortTimeFourierTransform(int length, int overlap) {
fft = new FastFourierTransform(length);
prev = new Complex[length * overlap];
for (int i = 0; i < length * overlap; ++i)
prev[i] = new Complex();
fold = new Complex[length];
for (int i = 0; i < length; ++i)
fold[i] = new Complex();
freq = new Complex[length];
for (int i = 0; i < length; ++i)
freq[i] = new Complex();
temp = new Complex();
power = new float[length];
weight = new float[length * overlap];
for (int i = 0; i < length * overlap; ++i)
weight[i] = (float)(Filter.lowPass(1, length, i, length * overlap) * Hann.window(i, length * overlap));
}
boolean push(Complex input) {
prev[index].set(input);
index = (index + 1) % prev.length;
if (index % fold.length != 0)
return false;
for (int i = 0; i < fold.length; ++i, index = (index + 1) % prev.length)
fold[i].set(prev[index]).mul(weight[i]);
for (int i = fold.length; i < prev.length; ++i, index = (index + 1) % prev.length)
fold[i % fold.length].add(temp.set(prev[index]).mul(weight[i]));
fft.forward(freq, fold);
for (int i = 0; i < power.length; ++i)
power[i] = freq[i].norm();
return true;
}
}

View file

@ -0,0 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#000000" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
<path android:fillColor="@android:color/white" android:pathData="M21.98,14H22H21.98zM5.35,13c1.19,0 1.42,1 3.33,1c1.95,0 2.09,-1 3.33,-1c1.19,0 1.42,1 3.33,1c1.95,0 2.09,-1 3.33,-1c1.19,0 1.4,0.98 3.31,1v-2c-1.19,0 -1.42,-1 -3.33,-1c-1.95,0 -2.09,1 -3.33,1c-1.19,0 -1.42,-1 -3.33,-1c-1.95,0 -2.09,1 -3.33,1c-1.19,0 -1.42,-1 -3.33,-1C3.38,11 3.24,12 2,12v2C3.9,14 4.17,13 5.35,13zM18.67,15c-1.95,0 -2.09,1 -3.33,1c-1.19,0 -1.42,-1 -3.33,-1c-1.95,0 -2.1,1 -3.34,1c-1.24,0 -1.38,-1 -3.33,-1c-1.95,0 -2.1,1 -3.34,1v2c1.95,0 2.11,-1 3.34,-1c1.24,0 1.38,1 3.33,1c1.95,0 2.1,-1 3.34,-1c1.19,0 1.42,1 3.33,1c1.94,0 2.09,-1 3.33,-1c1.19,0 1.42,1 3.33,1v-2C20.76,16 20.62,15 18.67,15zM5.35,9c1.19,0 1.42,1 3.33,1c1.95,0 2.09,-1 3.33,-1c1.19,0 1.42,1 3.33,1c1.95,0 2.09,-1 3.33,-1c1.19,0 1.4,0.98 3.31,1V8c-1.19,0 -1.42,-1 -3.33,-1c-1.95,0 -2.09,1 -3.33,1c-1.19,0 -1.42,-1 -3.33,-1C10.04,7 9.9,8 8.66,8C7.47,8 7.24,7 5.33,7C3.38,7 3.24,8 2,8v2C3.9,10 4.17,9 5.35,9z"/>
</vector>

View file

@ -0,0 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#000000" android:viewportHeight="960" android:viewportWidth="960" android:width="24dp">
<path android:fillColor="@android:color/white" android:pathData="M240,160L240,160L240,360L240,360L240,160L240,360L240,360L240,800Q240,800 240,800Q240,800 240,800Q240,800 240,800Q240,800 240,800Q240,800 240,790.5Q240,781 240,800L240,800Q240,800 240,800Q240,800 240,800L240,160Q240,160 240,160Q240,160 240,160ZM240,880Q207,880 183.5,856.5Q160,833 160,800L160,160Q160,127 183.5,103.5Q207,80 240,80L560,80L800,320L800,494Q781,487 761,483.5Q741,480 720,480L720,360L520,360L520,160L240,160Q240,160 240,160Q240,160 240,160L240,800Q240,800 240,800Q240,800 240,800L494,800Q502,823 514,843Q526,863 542,880L240,880ZM636,860L580,804L664,720L580,636L636,580L720,664L804,580L860,636L777,720L860,804L804,860L720,777L636,860Z"/>
</vector>

View file

@ -13,16 +13,16 @@
android:layout_width="0dp"
android:layout_height="0dp"
android:contentDescription="@string/scope_description"
app:layout_constraintBottom_toTopOf="@+id/freq_plot"
app:layout_constraintBottom_toTopOf="@+id/waterfall_plot"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<ImageView
android:id="@+id/freq_plot"
android:id="@+id/waterfall_plot"
android:layout_width="0dp"
android:layout_height="64dp"
android:contentDescription="@string/freq_plot_description"
android:contentDescription="@string/waterfall_plot"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@+id/peak_meter"
app:layout_constraintStart_toStartOf="parent"
@ -35,6 +35,6 @@
android:contentDescription="@string/peak_meter_description"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@+id/freq_plot"
app:layout_constraintStart_toEndOf="@+id/waterfall_plot"
app:layout_constraintTop_toBottomOf="@+id/scope" />
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -14,15 +14,15 @@
android:layout_height="0dp"
android:contentDescription="@string/scope_description"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@+id/freq_plot"
app:layout_constraintEnd_toStartOf="@+id/waterfall_plot"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<ImageView
android:id="@+id/freq_plot"
android:id="@+id/waterfall_plot"
android:layout_width="0dp"
android:layout_height="0dp"
android:contentDescription="@string/freq_plot_description"
android:contentDescription="@string/waterfall_plot"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@+id/peak_meter"
app:layout_constraintStart_toEndOf="@+id/scope"
@ -35,6 +35,6 @@
android:contentDescription="@string/peak_meter_description"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@+id/freq_plot"
app:layout_constraintStart_toEndOf="@+id/waterfall_plot"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -62,6 +62,9 @@
<item
android:id="@+id/action_force_pd240"
android:title="@string/pd240" />
<item
android:id="@+id/action_force_pd290"
android:title="@string/pd290" />
</menu>
</item>
<item android:title="@string/martin_modes">
@ -94,6 +97,13 @@
android:title="@string/wraase_sc2_180" />
</menu>
</item>
<item android:title="@string/contributed_modes">
<menu>
<item
android:id="@+id/action_force_hffax_mode"
android:title="@string/hf_fax" />
</menu>
</item>
<item
android:id="@+id/action_force_raw_mode"
android:icon="@drawable/baseline_image_not_supported_24"
@ -205,6 +215,42 @@
app:iconTint="@color/tint" />
</menu>
</item>
<item
android:icon="@drawable/baseline_water_24"
android:title="@string/waterfall_plot"
app:iconTint="@color/tint">
<menu>
<group android:checkableBehavior="single">
<item
android:id="@+id/action_show_spectrogram"
android:title="@string/spectrogram"
app:iconTint="@color/tint" />
<item
android:id="@+id/action_show_frequency_plot"
android:title="@string/frequency_plot"
app:iconTint="@color/tint" />
</group>
</menu>
</item>
<item
android:icon="@drawable/baseline_auto_mode_24"
android:title="@string/auto_save"
app:iconTint="@color/tint">
<menu>
<group android:checkableBehavior="single">
<item
android:id="@+id/action_enable_auto_save"
android:icon="@drawable/baseline_save_alt_24"
android:title="@string/enable"
app:iconTint="@color/tint" />
<item
android:id="@+id/action_disable_auto_save"
android:icon="@drawable/outline_scan_delete_24"
android:title="@string/disable"
app:iconTint="@color/tint" />
</group>
</menu>
</item>
<item
android:icon="@drawable/baseline_language_24"
android:title="@string/language"
@ -225,6 +271,18 @@
<item
android:id="@+id/action_brazilian_portuguese"
android:title="@string/brazilian_portuguese" />
<item
android:id="@+id/action_polish"
android:title="@string/polish" />
<item
android:id="@+id/action_ukrainian"
android:title="@string/ukrainian" />
<item
android:id="@+id/action_latin_american_spanish"
android:title="@string/latin_american_spanish" />
<item
android:id="@+id/action_french"
android:title="@string/french" />
</menu>
</item>
<item

View file

@ -34,8 +34,11 @@
<string name="creating_picture_file_failed">Erstellen der Bilddatei fehlgeschlagen</string>
<string name="storing_picture_failed">Speichern des Bildes fehlgeschlagen</string>
<string name="scope_description">Dekodiertes SSTV-Bild</string>
<string name="freq_plot_description">Frequenzdiagramm</string>
<string name="peak_meter_description">Spitzenpegel des Audiosignals</string>
<string name="waterfall_plot">Wasserfalldiagramm</string>
<string name="frequency_plot">Frequenzdiagramm</string>
<string name="spectrogram">Spektrogramm</string>
<string name="auto_save">Automatisches Speichern</string>
<string name="night_mode">Nachtmodus</string>
<string name="enable">Aktivieren</string>
<string name="disable">Deaktivieren</string>

View file

@ -0,0 +1,68 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="language">Idioma</string>
<string name="share">Compartir</string>
<string name="more_options">Mas opciones</string>
<string name="store_scope">Guardar imagen</string>
<string name="toggle_mode">Alternar modo</string>
<string name="auto_mode">Modo automático</string>
<string name="lock_mode">Modo de bloqueo</string>
<string name="raw_mode">Modo Raw</string>
<string name="listening">Escuchando</string>
<string name="audio_settings">Configuraciones de audio</string>
<string name="sample_rate">Tasa de muestreo</string>
<string name="channel_select">Seleccionar canal</string>
<string name="channel_default">Por defecto</string>
<string name="channel_first">Primer canal</string>
<string name="channel_second">Segundo canal</string>
<string name="channel_summation">Suma de canales</string>
<string name="channel_analytic">Canal analítico</string>
<string name="audio_source">Fuente de audio</string>
<string name="source_default">Por defecto</string>
<string name="source_microphone">Microfono</string>
<string name="source_camcorder">Grabadora</string>
<string name="source_voice_recognition">Reconocimiento de voz</string>
<string name="source_unprocessed">Sin procesar</string>
<string name="audio_format">Formato de audio</string>
<string name="fixed_point">Punto Fijo</string>
<string name="floating_point">Punto Flotante</string>
<string name="audio_init_failed">Fallo en iniciar audio</string>
<string name="audio_setup_failed">Fallo en configuración de audio</string>
<string name="audio_permission_denied">Permiso de audio denegado</string>
<string name="audio_recording_error">Error de grabación de audio</string>
<string name="creating_picture_directory_failed">Fallo en crear directorio de imagen</string>
<string name="creating_picture_file_failed">Fallo en crear imagen</string>
<string name="storing_picture_failed">Fallo en guardar imagen</string>
<string name="scope_description">Imagen SSTV decodificada</string>
<string name="peak_meter_description">Nivel de señal de audio máximo</string>
<string name="waterfall_plot">Gráfico de cascada</string>
<string name="frequency_plot">Gráfico de frecuencia</string>
<string name="spectrogram">Espectrograma</string>
<string name="auto_save">Guardado automático</string>
<string name="night_mode">Modo nocturno</string>
<string name="enable">Habilitar</string>
<string name="disable">Deshabilitar</string>
<string name="close">Cerrar</string>
<string name="privacy_policy">Política de privacidad</string>
<string name="privacy_policy_text"><![CDATA[
<p><h1>Política de privacidad</h1></p>
<p><h5>Acceso a micrófono</h5>
Esta aplicación requiere acceso al microfono de su dispositivo para decodificar señales (SSTV) o el micrófono captura el audio que contiene la transmisión SSTV
</p>
<p><h5>Manipulacion de datos</h5>
La aplicación utiliza un pequeño búfer temporal en la memoria para procesar datos de audio en tiempo real.
Este búfer se sobrescribe constantemente con nuevos datos a medida que avanza la decodificación.
La aplicación no almacena el audio sin procesar capturado por el micrófono.
Sólo las imágenes decodificadas resultantes del proceso SSTV se guardan en el almacenamiento de su dispositivo.
</p>
]]></string>
<string name="about">Sobre Robot36</string>
<string name="about_text"><![CDATA[
<p><h1>Robot36 %1$s</h1>Copyright 2024 Ahmet Inan</p>
<p>Por favor lea el AVISO al final de esta página.</p>
<p><h5>Descripción</h5>Decodifica imágenes de televisión de escaneo lento a partir del audio</p>
<p><h5>Código fuente</h5><a href="https://github.com/xdsopl/robot36">Robot36 en GitHub</a><br />BSD Zero Clause License</p>
<p><h5>Especificaciones técnicas</h5><a href="http://www.barberdsp.com/downloads/Dayton%%20Paper.pdf">Dayton Paper</a><br />por JL Barber - 2000</p>
<p><h5>AVISO</h5>%2$s</p>
]]></string>
</resources>

View file

@ -0,0 +1,69 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="language">Langue</string>
<string name="share">Partager</string>
<string name="more_options">Plus d\'options</string>
<string name="store_scope">Enregistrer écran scope</string>
<string name="toggle_mode">Changer de mode</string>
<string name="auto_mode">Mode automatique</string>
<string name="lock_mode">Mode verrouillé</string>
<string name="raw_mode">Mode brut</string>
<string name="listening">Écoute</string>
<string name="audio_settings">Paramètres audio</string>
<string name="sample_rate">Fréquence d\'échantillonnage</string>
<string name="channel_select">Sélection du canal</string>
<string name="channel_default">Par défaut</string>
<string name="channel_first">Premier</string>
<string name="channel_second">Deuxième</string>
<string name="channel_summation">Somme</string>
<string name="channel_analytic">Analytique</string>
<string name="audio_source">Source audio</string>
<string name="source_default">Par défaut</string>
<string name="source_microphone">Microphone</string>
<string name="source_camcorder">Caméscope</string>
<string name="source_voice_recognition">Reconnaissance vocale</string>
<string name="source_unprocessed">Non traité</string>
<string name="audio_format">Format audio</string>
<string name="fixed_point">Virgule fixe</string>
<string name="floating_point">Virgule flottante</string>
<string name="audio_init_failed">Échec de l\'initialisation audio</string>
<string name="audio_setup_failed">Échec de la configuration audio</string>
<string name="audio_permission_denied">Permission audio refusée</string>
<string name="audio_recording_error">Erreur d\'enregistrement audio</string>
<string name="creating_picture_directory_failed">Échec de la création du dossier d\'images</string>
<string name="creating_picture_file_failed">Échec de la création du fichier image</string>
<string name="storing_picture_failed">Échec de l\'enregistrement de l\'image</string>
<string name="scope_description">Image SSTV décodée</string>
<string name="peak_meter_description">Niveau de signal audio de crête</string>
<string name="waterfall_plot">Graphique en cascade</string>
<string name="frequency_plot">Graphique de fréquence</string>
<string name="spectrogram">Spectrogramme</string>
<string name="auto_save">Sauvegarde automatique</string>
<string name="night_mode">Mode nuit</string>
<string name="enable">Activer</string>
<string name="disable">Désactiver</string>
<string name="close">Fermer</string>
<string name="privacy_policy">Politique de confidentialité</string>
<string name="privacy_policy_text"><![CDATA[
<p><h1>Politique de confidentialité</h1></p>
<p><h5>Accès au microphone</h5>
Cette application nécessite l\'accès au microphone de votre appareil pour décoder les signaux de télévision à balayage lent (SSTV).
Le microphone capture l\'audio contenant la transmission SSTV.
</p>
<p><h5>Gestion des données</h5>
L\'application utilise un petit tampon temporaire en mémoire pour traiter les données audio en temps réel.
Ce tampon est constamment réécrit avec de nouvelles données au fur et à mesure que le décodage progresse.
L\'application ne stocke pas les données audio brutes capturées par le microphone.
Seules les images décodées résultant du processus SSTV sont enregistrées sur le votre appareil.
</p>
]]></string>
<string name="about">À propos de Robot36</string>
<string name="about_text"><![CDATA[
<p><h1>Robot36 %1$s</h1>Copyright 2024 Ahmet Inan</p>
<p>Veuillez lire la CLAUSE DE NON-RESPONSABILITÉ en bas de cette page</p>
<p><h5>Description</h5>Décode les images de télévision à balayage lent à partir de l\'audio</p>
<p><h5>Implémentation</h5><a href="https://github.com/xdsopl/robot36">Robot36 sur GitHub</a><br />Licence BSD Zero Clause</p>
<p><h5>Spécifications des modes</h5><a href="http://www.barberdsp.com/downloads/Dayton%%20Paper.pdf">Dayton Paper</a><br />par JL Barber - 2000</p>
<p><h5>CLAUSE DE NON-RESPONSABILITÉ</h5>%2$s</p>
]]></string>
</resources>

View file

@ -0,0 +1,69 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="language">Język</string>
<string name="share">Udostępnij</string>
<string name="more_options">Więcej opcji</string>
<string name="store_scope">Zapisz ekran</string>
<string name="toggle_mode">Tryb przełączania</string>
<string name="auto_mode">Tryb automatyczny</string>
<string name="lock_mode">Zablokuj tryb</string>
<string name="raw_mode">Tryb surowy (RAW)</string>
<string name="listening">Słuchanie</string>
<string name="audio_settings">Ustawienia dźwięku</string>
<string name="sample_rate">Częstotliwość próbkowania</string>
<string name="channel_select">Wybór kanału</string>
<string name="channel_default">Domyślny</string>
<string name="channel_first">Pierwszy</string>
<string name="channel_second">Drugi</string>
<string name="channel_summation">Sumarycznie</string>
<string name="channel_analytic">Analitycznie</string>
<string name="audio_source">Źródło dźwięku</string>
<string name="source_default">Domyślne</string>
<string name="source_microphone">Mikrofon</string>
<string name="source_camcorder">Kamera</string>
<string name="source_voice_recognition">Rozpoznawanie głosu</string>
<string name="source_unprocessed">Nieprzetworzone</string>
<string name="audio_format">Format dźwięku</string>
<string name="fixed_point">Stałoprzecinkowy</string>
<string name="floating_point">Zmiennoprzecinkowy</string>
<string name="audio_init_failed">Inicjowanie dźwięku nie powiodło się</string>
<string name="audio_setup_failed">Konfiguracja dźwięku nie powiodła się</string>
<string name="audio_permission_denied">Odmowa dostępu do dźwięku</string>
<string name="audio_recording_error">Błąd nagrywania dźwięku</string>
<string name="creating_picture_directory_failed">Tworzenie katalogu obrazów nie powiodło się</string>
<string name="creating_picture_file_failed">Tworzenie pliku obrazu nie powiodło się</string>
<string name="storing_picture_failed">Zapisywanie obrazu nie powiodło się</string>
<string name="scope_description">Zdekodowano obraz SSTV</string>
<string name="peak_meter_description">Szczytowy poziom sygnału audio</string>
<string name="waterfall_plot">Wykres wodospadowy</string>
<string name="frequency_plot">Wykres częstotliwości</string>
<string name="spectrogram">Spektrogram</string>
<string name="auto_save">Automatyczne zapisywanie</string>
<string name="night_mode">Tryb nocny</string>
<string name="enable">Włącz</string>
<string name="disable">Wyłącz</string>
<string name="close">Zamknij</string>
<string name="privacy_policy">Polityka prywatności</string>
<string name="privacy_policy_text"><![CDATA[
<p><h1>Polityka prywatności</h1></p>
<p><h5>Dostęp do mikrofonu</h5>
Ta aplikacja wymaga dostępu do mikrofonu urządzenia, aby dekodować sygnały telewizji o wolnym skanowaniu (SSTV).
Mikrofon przechwytuje dźwięk zawierający transmisję SSTV.
</p>
<p><h5>Obchodzenie się z danymi</h5>
Aplikacja wykorzystuje mały tymczasowy bufor w pamięci do przetwarzania danych audio w czasie rzeczywistym.
W miarę postępu dekodowania bufor ten jest stale nadpisywany nowymi danymi.
Aplikacja nie przechowuje nieprzetworzonego dźwięku przechwyconego z mikrofonu.
Tylko zdekodowane obrazy powstałe w procesie dekodowania SSTV są zapisywane do pamięci twojego urządzenia.
</p>
]]></string>
<string name="about">O Robot36</string>
<string name="about_text"><![CDATA[
<p><h1>Robot36 %1$s</h1>Prawa autorskie 2024 Ahmet Inan</p>
<p>Przeczytaj ZASTRZEŻENIA na dole tej strony</p>
<p><h5>Opis</h5>Dekodowanie sygnałów telewizji o wolnym skanowaniu (SSTV) z dźwięku</p>
<p><h5>Implementacja</h5><a href="https://github.com/xdsopl/robot36">Robot36 na GitHub</a><br />BSD Zero Clause License</p>
<p><h5>Specyfikacje trybów</h5><a href="http://www.barberdsp.com/downloads/Dayton%%20Paper.pdf">Dayton Paper</a><br />by JL Barber - 2000</p>
<p><h5>ZASTRZEŻENIA</h5>%2$s</p>
]]></string>
</resources>

View file

@ -34,8 +34,11 @@
<string name="creating_picture_file_failed">Falha ao criar imagem</string>
<string name="storing_picture_failed">Falha ao salvar imagem</string>
<string name="scope_description">Imagem SSTV decodificada</string>
<string name="freq_plot_description">Gráfico de frequência</string>
<string name="peak_meter_description">Nível de sinal de áudio máximo</string>
<string name="waterfall_plot">Gráfico de cascata</string>
<string name="frequency_plot">Gráfico de frequência</string>
<string name="spectrogram">Espectrograma</string>
<string name="auto_save">Salvamento automático</string>
<string name="night_mode">Modo noturno</string>
<string name="enable">Habilitar</string>
<string name="disable">Desabilitar</string>

View file

@ -34,8 +34,11 @@
<string name="creating_picture_file_failed">Ошибка создания файла изображения</string>
<string name="storing_picture_failed">Ошибка сохранения изображения</string>
<string name="scope_description">Декодированное изображение SSTV</string>
<string name="freq_plot_description">График частот</string>
<string name="peak_meter_description">Пиковый уровень аудиосигнала</string>
<string name="waterfall_plot">Водопадный график</string>
<string name="frequency_plot">График частот</string>
<string name="spectrogram">Спектрограмма</string>
<string name="auto_save">Автосохранение</string>
<string name="night_mode">Ночной режим</string>
<string name="enable">Включить</string>
<string name="disable">Выключить</string>

View file

@ -0,0 +1,69 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="language">Мова</string>
<string name="share">Поділитись</string>
<string name="more_options">Більше</string>
<string name="store_scope">Зберегти екран</string>
<string name="toggle_mode">Зафіксувати режим</string>
<string name="auto_mode">Автоматичний</string>
<string name="lock_mode">Вибір режиму</string>
<string name="raw_mode">Без обробок</string>
<string name="listening">Слухаю</string>
<string name="audio_settings">Параметри аудіо</string>
<string name="sample_rate">Частота дискретизації</string>
<string name="channel_select">Вибір каналу</string>
<string name="channel_default">Стандартний</string>
<string name="channel_first">Перший</string>
<string name="channel_second">Другий</string>
<string name="channel_summation">Спільний</string>
<string name="channel_analytic">Аналіз</string>
<string name="audio_source">Джерело аудіо</string>
<string name="source_default">Стандартний</string>
<string name="source_microphone">Мікрофон</string>
<string name="source_camcorder">Камера</string>
<string name="source_voice_recognition">Розпізнавання голосу</string>
<string name="source_unprocessed">Необроблений</string>
<string name="audio_format">Формат аудіо</string>
<string name="fixed_point">Фіксована точка</string>
<string name="floating_point">Плаваюча точка</string>
<string name="audio_init_failed">Помилка ініціалізації аудіо</string>
<string name="audio_setup_failed">Помилка налаштування аудіо</string>
<string name="audio_permission_denied">Аудіо - відмовлено у доступі</string>
<string name="audio_recording_error">Помилка запису аудіо</string>
<string name="creating_picture_directory_failed">Помилка створення каталогу для зображень</string>
<string name="creating_picture_file_failed">Помилка створення файлу зображення</string>
<string name="storing_picture_failed">Помилка збереження зображення</string>
<string name="scope_description">Декодоване зображення SSTV</string>
<string name="peak_meter_description">Піковий рівень аудіосигналу</string>
<string name="waterfall_plot">Графік водоспаду</string>
<string name="frequency_plot">Графік частот</string>
<string name="spectrogram">Спектрограма</string>
<string name="auto_save">Автозбереження</string>
<string name="night_mode">Нічний режим</string>
<string name="enable">Увімкнути</string>
<string name="disable">Вимкнути</string>
<string name="close">Закрити</string>
<string name="privacy_policy">Політика приватності</string>
<string name="privacy_policy_text"><![CDATA[
<p><h1>Політика приватності</h1></p>
<p><h5>Доступ до мікрофону</h5>
Ця програма вимагає доступу до мікрофону вашого пристрою для декодування сигналів телебачення з повільною розгорткою (SSTV).
Мікрофон записує аудіо, що містить передачу SSTV.
</p>
<p><h5>Обробка даних</h5>
Додаток використовує невеликий тимчасовий буфер у пам\'яті для обробки аудіоданих у реальному часі.
Цей буфер постійно перезаписується новими даними у міру просування декодування.
Програма не зберігає необроблене аудіо, записане з мікрофона.
На пристрої зберігаються лише зображення, отримані внаслідок декодування сигналу SSTV.
</p>
]]></string>
<string name="about">Про застосунок Robot36</string>
<string name="about_text"><![CDATA[
<p><h1>Robot36 %1$s</h1>Авторські права 2024 Ahmet Inan</p>
<p>Будь ласка, ознайомтеся з ВІДМОВОЮ ВІД ВІДПОВІДАЛЬНОСТІ внизу цієї сторінки</p>
<p><h5>Опис</h5>Декодує зображення телебачення з повільною розгорткою з аудіосигналу</p>
<p><h5>Реалізація</h5><a href="https://github.com/xdsopl/robot36">Robot36 на Ґітхаб</a><br />Ліцензія BSD Zero Clause</p>
<p><h5>Специфікації режимів</h5><a href="http://www.barberdsp.com/downloads/Dayton%%20Paper.pdf">Dayton Paper</a><br />автор JL Barber - 2000</p>
<p><h5>ВІДМОВА ВІД ВІДПОВІДАЛЬНОСТІ</h5>%2$s</p>
]]></string>
</resources>

View file

@ -34,8 +34,11 @@
<string name="creating_picture_file_failed">创建图片文件出错</string>
<string name="storing_picture_failed">写入图像数据失败</string>
<string name="scope_description">解码的SSTV图像</string>
<string name="freq_plot_description">频率图</string>
<string name="peak_meter_description">音频峰值信号水平</string>
<string name="waterfall_plot">瀑布图</string>
<string name="frequency_plot">频率图</string>
<string name="spectrogram">频谱图</string>
<string name="auto_save">自动保存</string>
<string name="night_mode">夜间模式</string>
<string name="enable">开启</string>
<string name="disable">禁用</string>

View file

@ -6,6 +6,7 @@
<string name="martin_modes" translatable="false">Martin</string>
<string name="scottie_modes" translatable="false">Scottie</string>
<string name="wraase_modes" translatable="false">Wraase</string>
<string name="contributed_modes" translatable="false">Contrib</string>
<string name="robot36_color" translatable="false">Robot 36 Color</string>
<string name="robot72_color" translatable="false">Robot 72 Color</string>
<string name="pd50" translatable="false">PD 50</string>
@ -14,12 +15,14 @@
<string name="pd160" translatable="false">PD 160</string>
<string name="pd180" translatable="false">PD 180</string>
<string name="pd240" translatable="false">PD 240</string>
<string name="pd290" translatable="false">PD 290</string>
<string name="martin1" translatable="false">Martin 1</string>
<string name="martin2" translatable="false">Martin 2</string>
<string name="scottie1" translatable="false">Scottie 1</string>
<string name="scottie2" translatable="false">Scottie 2</string>
<string name="scottie_dx" translatable="false">Scottie DX</string>
<string name="wraase_sc2_180" translatable="false">Wraase SC2180</string>
<string name="hf_fax" translatable="false">HF Fax</string>
<string name="rate_8000" translatable="false">8 kHz</string>
<string name="rate_16000" translatable="false">16 kHz</string>
<string name="rate_32000" translatable="false">32 kHz</string>
@ -30,6 +33,10 @@
<string name="russian" translatable="false">Русский</string>
<string name="german" translatable="false">Deutsch</string>
<string name="brazilian_portuguese" translatable="false">Português brasileiro</string>
<string name="polish" translatable="false">Polski</string>
<string name="ukrainian" translatable="false">Українська</string>
<string name="latin_american_spanish" translatable="false">Español de América Latina</string>
<string name="french" translatable="false">Français</string>
<string name="disclaimer" translatable="false">THE SOFTWARE IS PROVIDED \"AS IS\" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.</string>
<string name="language">Language</string>
<string name="share">Share</string>
@ -65,8 +72,11 @@
<string name="creating_picture_file_failed">Creating picture file failed</string>
<string name="storing_picture_failed">Storing picture failed</string>
<string name="scope_description">Decoded SSTV picture</string>
<string name="freq_plot_description">Frequency plot</string>
<string name="peak_meter_description">Peak audio signal level</string>
<string name="waterfall_plot">Waterfall plot</string>
<string name="frequency_plot">Frequency plot</string>
<string name="spectrogram">Spectrogram</string>
<string name="auto_save">Auto Save</string>
<string name="night_mode">Night Mode</string>
<string name="enable">Enable</string>
<string name="disable">Disable</string>

View file

@ -1,7 +1,7 @@
Following SSTV modes are supported:
<ul><li>Robot Modes: 36 & 72</li>
<li>PD Modes: 50, 90, 120, 160, 180 & 240</li>
<li>PD Modes: 50, 90, 120, 160, 180, 240 & 290</li>
<li>Martin Modes: 1 & 2</li>
<li>Scottie Modes: 1, 2 & DX</li>
<li>Wraase Mode: SC2-180</li></ul>

View file

@ -1,12 +1,12 @@
[versions]
agp = "8.5.2"
agp = "8.13.1"
junit = "4.13.2"
junitVersion = "1.2.1"
espressoCore = "3.6.1"
appcompat = "1.7.0"
material = "1.12.0"
activity = "1.9.1"
constraintlayout = "2.1.4"
junitVersion = "1.3.0"
espressoCore = "3.7.0"
appcompat = "1.7.1"
material = "1.13.0"
activity = "1.11.0"
constraintlayout = "2.2.1"
[libraries]
junit = { group = "junit", name = "junit", version.ref = "junit" }

View file

@ -1,6 +1,6 @@
#Fri Apr 12 11:35:07 CEST 2024
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists