Compare commits
31 Commits
Author | SHA1 | Date |
---|---|---|
Sebastian | e18b23446c | |
Sebastian | 5561fc32cb | |
Sebastian | 00f020430f | |
Sebastian | c944f89ed3 | |
Sebastian | d3c66b6260 | |
Sebastian | 6fbe5949e6 | |
Sebastian | d34d2ee2cc | |
Sebastian | 84e7d85137 | |
Sebastian | 166846c622 | |
Sebastian | a26ff950c1 | |
Sebastian | 1f8249dead | |
Sebastian | 81eda49f10 | |
Sebastian | b811bdc9ac | |
Sebastian | b9b971f1b0 | |
Sebastian | f44b58bb43 | |
Sebastian | dd133dfb20 | |
Sebastian | a5d7250e07 | |
Sebastian | 6417820c9c | |
Sebastian | 96a89f3091 | |
Sebastian | a07a522cf3 | |
Sebastian | 16ffa5736f | |
Sebastian | d7e4d4eb5c | |
Sebastian | b095515238 | |
Sebastian | a5c4d930ac | |
Sebastian | bb56972ec8 | |
Sebastian | 335dca9246 | |
Sebastian | 2515e2e8f5 | |
Sebastian | 0c9cf5ac24 | |
Sebastian | 70a8b41578 | |
Sebastian | cc8afc1b07 | |
Sebastian | da0c5081e9 |
|
@ -0,0 +1,18 @@
|
|||
on: push
|
||||
jobs:
|
||||
audit:
|
||||
runs-on: docker
|
||||
container:
|
||||
image: forgejo.zenerdio.de/sebastian/apt-decoder-ci:v0.2.1
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/cache@v3
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/bin/
|
||||
~/.cargo/registry/index/
|
||||
~/.cargo/registry/cache/
|
||||
~/.cargo/git/db/
|
||||
target/
|
||||
key: audit-apt-decoder
|
||||
- run: CARGO_HOME=/root/.cargo cargo audit
|
|
@ -0,0 +1,55 @@
|
|||
on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: docker
|
||||
container:
|
||||
image: forgejo.zenerdio.de/sebastian/apt-decoder-ci:v0.2.1
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/cache@v3
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/bin/
|
||||
~/.cargo/registry/index/
|
||||
~/.cargo/registry/cache/
|
||||
~/.cargo/git/db/
|
||||
target/
|
||||
key: build-apt-decoder
|
||||
restore-keys: audit-apt-decoder
|
||||
- run: CARGO_HOME=~/.cargo cargo build --release
|
||||
|
||||
build-windows:
|
||||
runs-on: docker
|
||||
container:
|
||||
image: forgejo.zenerdio.de/sebastian/apt-decoder-ci:v0.2.1
|
||||
needs: build
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/cache/restore@v3
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/bin/
|
||||
~/.cargo/registry/index/
|
||||
~/.cargo/registry/cache/
|
||||
~/.cargo/git/db/
|
||||
target/
|
||||
key: build-apt-decoder
|
||||
- run: CARGO_HOME=~/.cargo cargo build --target x86_64-pc-windows-gnu --release
|
||||
|
||||
build-appimage:
|
||||
runs-on: docker
|
||||
container:
|
||||
image: forgejo.zenerdio.de/sebastian/apt-decoder-ci:v0.2.1
|
||||
needs: build
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/cache/restore@v3
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/bin/
|
||||
~/.cargo/registry/index/
|
||||
~/.cargo/registry/cache/
|
||||
~/.cargo/git/db/
|
||||
target/
|
||||
key: build-apt-decoder
|
||||
- run: CARGO_HOME=~/.cargo PATH=$PATH:$CARGO_HOME/bin x build -r --format appimage
|
|
@ -0,0 +1,31 @@
|
|||
on:
|
||||
push:
|
||||
tags: 'v*'
|
||||
|
||||
jobs:
|
||||
publish-release:
|
||||
runs-on: docker
|
||||
container:
|
||||
image: forgejo.zenerdio.de/sebastian/apt-decoder-ci:v0.2.1
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/cache/restore@v3
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/bin/
|
||||
~/.cargo/registry/index/
|
||||
~/.cargo/registry/cache/
|
||||
~/.cargo/git/db/
|
||||
target/
|
||||
key: build-apt-decoder
|
||||
- run: CARGO_HOME=~/.cargo cargo build --target x86_64-pc-windows-gnu --release
|
||||
- run: CARGO_HOME=~/.cargo PATH=$PATH:$CARGO_HOME/bin x build -r --format appimage
|
||||
- run: mkdir -p release
|
||||
- run: cp target/x/release/linux/x64/apt-decoder.AppImage release/apt-decoder-${{ github.ref_name }}.AppImage
|
||||
- run: cp target/x86_64-pc-windows-gnu/release/apt-decoder.exe release/
|
||||
- run: cd release && zip apt-decoder-win.zip apt-decoder.exe && rm apt-decoder.exe
|
||||
- uses: actions/forgejo-release@v1
|
||||
with:
|
||||
direction: upload
|
||||
release-dir: release
|
||||
token: ${{ secrets.FORGEJO_RELEASE }}
|
|
@ -1 +1,4 @@
|
|||
target
|
||||
.vendor
|
||||
*.AppImage
|
||||
icon.png
|
||||
|
|
File diff suppressed because it is too large
Load Diff
17
Cargo.toml
17
Cargo.toml
|
@ -1,8 +1,19 @@
|
|||
[package]
|
||||
name = "apt-decoder"
|
||||
version = "0.1.0"
|
||||
version = "1.0.1"
|
||||
authors = ["Sebastian <sebastian@sebastians-site.de>"]
|
||||
|
||||
[dependencies]
|
||||
hound = "*"
|
||||
image = "*"
|
||||
clap = {version = "4.0.0", features = ["cargo"]}
|
||||
indicatif = "0.16.2"
|
||||
hound = "3.4.0"
|
||||
image = "0.24.0"
|
||||
eframe = {version = "0.18.0", optional = true}
|
||||
rfd = "0.11.4"
|
||||
thiserror = "1.0.30"
|
||||
|
||||
|
||||
[features]
|
||||
# Defines a feature named `webp` that does not enable any other features.
|
||||
default = ["ui"]
|
||||
ui = ["eframe"]
|
||||
|
|
BIN
noaa19_short.png
BIN
noaa19_short.png
Binary file not shown.
Before Width: | Height: | Size: 420 KiB After Width: | Height: | Size: 404 KiB |
35
readme.md
35
readme.md
|
@ -9,28 +9,45 @@ apt-decoder provides a lightweight, simple to use and easy to understand solutio
|
|||
|
||||
![short sample](noaa19_short.png)
|
||||
|
||||
Releases
|
||||
--------
|
||||
You can download the latest release as a standalone binary,
|
||||
for linux (appimage) and windows (just a statically linked exe file) under
|
||||
https://gitea.zenerdio.de/sebastian/apt-decoder/releases
|
||||
|
||||
Building
|
||||
--------
|
||||
1. Install the rust compiler and cargo.
|
||||
E.g. using rustup (Try installing rustup using yourpackage manager,
|
||||
don't use the stupid **curl | sh** stuff.)
|
||||
2. Run `cargo build --release`
|
||||
3. The `apt-decoder` binary is in `target/release`
|
||||
4. Done
|
||||
1. Install the development packages for `libgtk3` and `libxcb` for your distro.
|
||||
E.g. for anything Debian based:
|
||||
`sudo apt install libgtk-3-dev libxcb-shape0-dev libxcb-xfixes0-dev`
|
||||
2. Install the rust compiler and cargo.
|
||||
E.g. using rustup (Try installing rustup using your package manager,
|
||||
don't use the stupid `curl | sh` stuff.)
|
||||
3. Run `cargo build --release`
|
||||
4. The `apt-decoder` binary is in `target/release`
|
||||
5. Done
|
||||
|
||||
The default build will build a binary that contains,
|
||||
both the GUI and the CLI version of the tool.
|
||||
If you need something more lightweight (with no external dependencies),
|
||||
it is also possible to build a pure-rust CLI-only version,
|
||||
using `cargo build --release --no-default-features`.
|
||||
|
||||
Usage
|
||||
-----
|
||||
1. Save a received and FM-demodulated satellite signal as WAV-file.
|
||||
The WAV file has to be **mono**, **48kHz** and **32bit float**.
|
||||
When in doubt you can use audacity to convert your file into this format.
|
||||
2. Run `apt-decoder <your WAV file> <destination PNG file>`
|
||||
You can also run the example contained in this repo:
|
||||
`apt-decoder noaa19_short.wav noaa19_short.png`
|
||||
2. To run `apt-decoder` in GUI mode just execute the binary.
|
||||
For CLI mode use the `-n` flag:
|
||||
`apt-decoder -n <your WAV file> <destination PNG file>`
|
||||
For testing you can try the example contained in this repo:
|
||||
`apt-decoder -n noaa19_short.wav noaa19_short.png`
|
||||
3. Look at the generated PNG file, adjust the dynamic and contrast with your favorite tool.
|
||||
4. Done
|
||||
|
||||
![gui example](gui.png)
|
||||
|
||||
![long sample](example.png)
|
||||
|
||||
Theory of Operation
|
||||
|
|
|
@ -1,9 +1,12 @@
|
|||
pub struct SquaringAMDemodulator<'a> {
|
||||
iterator: Box<Iterator<Item=f32> + 'a>,
|
||||
iterator: Box<dyn Iterator<Item = f32> + 'a>,
|
||||
}
|
||||
|
||||
impl<'a> SquaringAMDemodulator<'a> {
|
||||
pub fn from<I>(iterator1: I) -> SquaringAMDemodulator<'a> where I: Iterator<Item=f32> + 'a {
|
||||
pub fn from<I>(iterator1: I) -> SquaringAMDemodulator<'a>
|
||||
where
|
||||
I: Iterator<Item = f32> + 'a,
|
||||
{
|
||||
SquaringAMDemodulator {
|
||||
iterator: Box::new(iterator1),
|
||||
}
|
||||
|
@ -16,7 +19,7 @@ impl<'a> Iterator for SquaringAMDemodulator<'a> {
|
|||
fn next(&mut self) -> Option<Self::Item> {
|
||||
match self.iterator.next() {
|
||||
Some(x) => Some((x * x).sqrt()),
|
||||
None => None
|
||||
None => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,60 +1,58 @@
|
|||
const SYNC_LENGHT : usize = 40;
|
||||
const SYNCA_SEQ : [bool; 40] = [false, false, false, false,
|
||||
true, true, false, false, // Pulse 1
|
||||
true, true, false, false, // Pulse 2
|
||||
true, true, false, false, // Pulse 3
|
||||
true, true, false, false, // Pulse 4
|
||||
true, true, false, false, // Pulse 5
|
||||
true, true, false, false, // Pulse 6
|
||||
true, true, false, false, // Pulse 7
|
||||
false, false, false, false,
|
||||
false, false, false, false,];
|
||||
|
||||
const SYNCB_SEQ : [bool; 40] = [false, false, false, false,
|
||||
true, true, true, false, false,
|
||||
true, true, true, false, false,
|
||||
true, true, true, false, false,
|
||||
true, true, true, false, false,
|
||||
true, true, true, false, false,
|
||||
true, true, true, false, false,
|
||||
true, true, true, false, false,
|
||||
false];
|
||||
const SYNC_LENGHT: usize = 40;
|
||||
const SYNCA_SEQ: [bool; 40] = [
|
||||
false, false, false, false, true, true, false, false, // Pulse 1
|
||||
true, true, false, false, // Pulse 2
|
||||
true, true, false, false, // Pulse 3
|
||||
true, true, false, false, // Pulse 4
|
||||
true, true, false, false, // Pulse 5
|
||||
true, true, false, false, // Pulse 6
|
||||
true, true, false, false, // Pulse 7
|
||||
false, false, false, false, false, false, false, false,
|
||||
];
|
||||
|
||||
const SYNCB_SEQ: [bool; 40] = [
|
||||
false, false, false, false, true, true, true, false, false, true, true, true, false, false,
|
||||
true, true, true, false, false, true, true, true, false, false, true, true, true, false, false,
|
||||
true, true, true, false, false, true, true, true, false, false, false,
|
||||
];
|
||||
|
||||
pub enum SyncedSample {
|
||||
Sample(f32),
|
||||
SyncA(f32),
|
||||
SyncB(f32)
|
||||
SyncB(f32),
|
||||
}
|
||||
|
||||
pub struct APTSyncer<'a> {
|
||||
state: [f32; SYNC_LENGHT],
|
||||
pos : usize,
|
||||
pos: usize,
|
||||
nones_read: usize,
|
||||
avg_level : f32,
|
||||
iterator: Box<Iterator<Item=f32> + 'a>
|
||||
avg_level: f32,
|
||||
iterator: Box<dyn Iterator<Item = f32> + 'a>,
|
||||
}
|
||||
|
||||
impl<'a> APTSyncer<'a> {
|
||||
pub fn from<I>(mut iterator: I) -> APTSyncer<'a> where I: Iterator<Item=f32> + 'a {
|
||||
pub fn from<I>(mut iterator: I) -> APTSyncer<'a>
|
||||
where
|
||||
I: Iterator<Item = f32> + 'a,
|
||||
{
|
||||
let mut state = [0.0; SYNC_LENGHT];
|
||||
let mut avg_level = 0.5;
|
||||
for i in 0..SYNC_LENGHT {
|
||||
match iterator.next() {
|
||||
Some(x) => {
|
||||
state[i] = x;
|
||||
avg_level = 0.25 * x + avg_level * 0.75;
|
||||
},
|
||||
None => panic!("Could not retrieve enough samples to prime syncer")
|
||||
state[i] = x;
|
||||
avg_level = 0.25 * x + avg_level * 0.75;
|
||||
}
|
||||
None => panic!("Could not retrieve enough samples to prime syncer"),
|
||||
}
|
||||
}
|
||||
|
||||
APTSyncer {
|
||||
state: state,
|
||||
state,
|
||||
pos: 0,
|
||||
nones_read: 0,
|
||||
avg_level: avg_level,
|
||||
iterator: Box::new(iterator)
|
||||
avg_level,
|
||||
iterator: Box::new(iterator),
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -87,16 +85,15 @@ impl<'a> Iterator for APTSyncer<'a> {
|
|||
type Item = SyncedSample;
|
||||
|
||||
fn next(&mut self) -> Option<Self::Item> {
|
||||
|
||||
let (is_a, is_b) = self.is_marker();
|
||||
|
||||
let sample = self.state[self.pos];
|
||||
match self.iterator.next() {
|
||||
Some(x) => {
|
||||
self.state[self.pos] = x;
|
||||
self.avg_level = 0.25 * x + self.avg_level * 0.75;
|
||||
},
|
||||
None => self.nones_read += 1
|
||||
self.state[self.pos] = x;
|
||||
self.avg_level = 0.25 * x + self.avg_level * 0.75;
|
||||
}
|
||||
None => self.nones_read += 1,
|
||||
};
|
||||
|
||||
if self.nones_read >= SYNC_LENGHT {
|
||||
|
@ -107,11 +104,9 @@ impl<'a> Iterator for APTSyncer<'a> {
|
|||
|
||||
if is_a {
|
||||
return Some(SyncedSample::SyncA(sample));
|
||||
}
|
||||
else if is_b {
|
||||
} else if is_b {
|
||||
return Some(SyncedSample::SyncB(sample));
|
||||
}
|
||||
else {
|
||||
} else {
|
||||
return Some(SyncedSample::Sample(sample));
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,26 @@
|
|||
use indicatif::{ProgressBar, ProgressStyle};
|
||||
|
||||
use decoder;
|
||||
|
||||
const STEPS: u64 = 100;
|
||||
|
||||
pub fn decode(input_path: &str, output_path: &str) {
|
||||
println!("Decoding {} to {}", input_path, output_path);
|
||||
|
||||
let bar = ProgressBar::new(STEPS).with_style(
|
||||
ProgressStyle::default_bar()
|
||||
.template("{spinner:.green} [{wide_bar}] {percent}% ({eta})")
|
||||
.progress_chars("=> "),
|
||||
);
|
||||
let res = decoder::decode(input_path, output_path, |progress, _| {
|
||||
bar.set_position((progress * STEPS as f32) as u64);
|
||||
(true, STEPS as u32)
|
||||
});
|
||||
bar.finish();
|
||||
|
||||
if let Err(error) = res {
|
||||
println!("Unable to decode file: {}", error);
|
||||
} else {
|
||||
println!("Done!")
|
||||
}
|
||||
}
|
|
@ -0,0 +1,197 @@
|
|||
use std::path::Path;
|
||||
|
||||
use amdemod::SquaringAMDemodulator;
|
||||
use aptsyncer::{APTSyncer, SyncedSample};
|
||||
use errors::DecoderError;
|
||||
use firfilter::FIRFilter;
|
||||
use resamplers::{Downsampler, Upsampler};
|
||||
use utils::float_sample_iterator;
|
||||
|
||||
const LINES_PER_SECOND: u32 = 2;
|
||||
const PIXELS_PER_LINE: u32 = 2080;
|
||||
|
||||
const LOWPASS_COEFFS: [f32; 63] = [
|
||||
-7.383784e-03,
|
||||
-3.183046e-03,
|
||||
2.255039e-03,
|
||||
7.461166e-03,
|
||||
1.091908e-02,
|
||||
1.149109e-02,
|
||||
8.769802e-03,
|
||||
3.252932e-03,
|
||||
-3.720606e-03,
|
||||
-1.027446e-02,
|
||||
-1.447403e-02,
|
||||
-1.486427e-02,
|
||||
-1.092423e-02,
|
||||
-3.307958e-03,
|
||||
6.212477e-03,
|
||||
1.511364e-02,
|
||||
2.072873e-02,
|
||||
2.096037e-02,
|
||||
1.492345e-02,
|
||||
3.347624e-03,
|
||||
-1.138407e-02,
|
||||
-2.560252e-02,
|
||||
-3.507114e-02,
|
||||
-3.591225e-02,
|
||||
-2.553830e-02,
|
||||
-3.371569e-03,
|
||||
2.882645e-02,
|
||||
6.711368e-02,
|
||||
1.060042e-01,
|
||||
1.394643e-01,
|
||||
1.620650e-01,
|
||||
1.700462e-01,
|
||||
1.620650e-01,
|
||||
1.394643e-01,
|
||||
1.060042e-01,
|
||||
6.711368e-02,
|
||||
2.882645e-02,
|
||||
-3.371569e-03,
|
||||
-2.553830e-02,
|
||||
-3.591225e-02,
|
||||
-3.507114e-02,
|
||||
-2.560252e-02,
|
||||
-1.138407e-02,
|
||||
3.347624e-03,
|
||||
1.492345e-02,
|
||||
2.096037e-02,
|
||||
2.072873e-02,
|
||||
1.511364e-02,
|
||||
6.212477e-03,
|
||||
-3.307958e-03,
|
||||
-1.092423e-02,
|
||||
-1.486427e-02,
|
||||
-1.447403e-02,
|
||||
-1.027446e-02,
|
||||
-3.720606e-03,
|
||||
3.252932e-03,
|
||||
8.769802e-03,
|
||||
1.149109e-02,
|
||||
1.091908e-02,
|
||||
7.461166e-03,
|
||||
2.255039e-03,
|
||||
-3.183046e-03,
|
||||
-7.383784e-03,
|
||||
];
|
||||
|
||||
pub fn decode<T>(
|
||||
input_file: &str,
|
||||
output_file: &str,
|
||||
progress_update: T,
|
||||
) -> Result<(), DecoderError>
|
||||
where
|
||||
T: Fn(f32, image::RgbaImage) -> (bool, u32),
|
||||
{
|
||||
let mut reader =
|
||||
hound::WavReader::open(input_file).map_err(|err| DecoderError::InputFileError(err))?;
|
||||
|
||||
if reader.spec().channels != 1 {
|
||||
panic!("Expected a mono file");
|
||||
}
|
||||
|
||||
let sample_rate = reader.spec().sample_rate;
|
||||
if sample_rate != 48000 {
|
||||
return Err(DecoderError::UnexpectedSamplingRate(sample_rate));
|
||||
}
|
||||
|
||||
let sample_count = reader.len();
|
||||
let seconds = (sample_count as f32) / (sample_rate as f32);
|
||||
let lines = (seconds.ceil() as u32) * LINES_PER_SECOND;
|
||||
|
||||
let mut img = image::DynamicImage::ImageLuma8(image::ImageBuffer::new(PIXELS_PER_LINE, lines));
|
||||
|
||||
let coeffs = &LOWPASS_COEFFS;
|
||||
|
||||
let samples = float_sample_iterator(&mut reader);
|
||||
|
||||
let demod = SquaringAMDemodulator::from(samples);
|
||||
let filter = FIRFilter::from(demod, coeffs);
|
||||
let upsampler = Upsampler::from(filter, 13);
|
||||
let downsampler = Downsampler::from(upsampler, 150);
|
||||
let syncer = APTSyncer::from(downsampler);
|
||||
|
||||
let mut x = 0;
|
||||
let mut y = 0;
|
||||
let mut max_level = 0.0;
|
||||
let mut has_sync = false;
|
||||
|
||||
let mut progress = 0;
|
||||
let pixel_count = sample_count * 13 / 150;
|
||||
let mut update_step = 10;
|
||||
|
||||
let mut previous_sample = 0.0;
|
||||
|
||||
for synced_sample in syncer {
|
||||
progress += 1;
|
||||
|
||||
let sample = match synced_sample {
|
||||
SyncedSample::Sample(s) => s,
|
||||
SyncedSample::SyncA(s) => {
|
||||
if !has_sync {
|
||||
max_level = 0.0;
|
||||
has_sync = true;
|
||||
}
|
||||
x = 0;
|
||||
s
|
||||
}
|
||||
SyncedSample::SyncB(s) => {
|
||||
if x < (PIXELS_PER_LINE / 2) {
|
||||
let skip_distance = (PIXELS_PER_LINE / 2) - x;
|
||||
let color = (previous_sample / max_level * 255.0) as u8;
|
||||
for i in 0..skip_distance {
|
||||
img.as_mut_luma8()
|
||||
.unwrap()
|
||||
.put_pixel(x + i, y, image::Luma([color]));
|
||||
}
|
||||
}
|
||||
if !has_sync {
|
||||
max_level = 0.0;
|
||||
has_sync = true;
|
||||
}
|
||||
x = PIXELS_PER_LINE / 2;
|
||||
s
|
||||
}
|
||||
};
|
||||
|
||||
max_level = f32::max(sample, max_level);
|
||||
let color = (sample / max_level * 255.0) as u8;
|
||||
|
||||
if y < lines {
|
||||
img.as_mut_luma8()
|
||||
.unwrap()
|
||||
.put_pixel(x, y, image::Luma([color]));
|
||||
}
|
||||
|
||||
x += 1;
|
||||
if x >= PIXELS_PER_LINE {
|
||||
x = 0;
|
||||
y += 1;
|
||||
}
|
||||
|
||||
previous_sample = sample;
|
||||
|
||||
if progress % (PIXELS_PER_LINE * update_step) == 0 {
|
||||
let (cont, update_steps) =
|
||||
progress_update((progress as f32) / (pixel_count as f32), img.to_rgba8());
|
||||
if !cont {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let line_count = pixel_count / PIXELS_PER_LINE;
|
||||
update_step = if line_count / update_steps > 4 {
|
||||
line_count / update_steps
|
||||
} else {
|
||||
4
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
progress_update(1.0, img.to_rgba8());
|
||||
|
||||
img.save_with_format(&Path::new(output_file), image::ImageFormat::Png)
|
||||
.map_err(|err| DecoderError::OutputFileError(err))?;
|
||||
|
||||
Ok(())
|
||||
}
|
|
@ -0,0 +1,15 @@
|
|||
use hound;
|
||||
use image;
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum DecoderError {
|
||||
#[error("Unable to read input file: {0}")]
|
||||
InputFileError(#[from] hound::Error),
|
||||
|
||||
#[error("Expected a sampling rate of 48000Hz not {0}Hz")]
|
||||
UnexpectedSamplingRate(u32),
|
||||
|
||||
#[error("Unable to write output file: {0}")]
|
||||
OutputFileError(#[from] image::ImageError),
|
||||
}
|
|
@ -2,21 +2,24 @@ pub struct FIRFilter<'a> {
|
|||
coeffs: &'a [f32],
|
||||
state: Vec<f32>,
|
||||
pos: usize,
|
||||
iterator: Box<Iterator<Item=f32> + 'a>
|
||||
iterator: Box<dyn Iterator<Item = f32> + 'a>,
|
||||
}
|
||||
|
||||
impl<'a> FIRFilter<'a> {
|
||||
pub fn from<I>(iterator: I, coeffs: &'a [f32]) -> FIRFilter<'a> where I: Iterator<Item=f32> + 'a {
|
||||
pub fn from<I>(iterator: I, coeffs: &'a [f32]) -> FIRFilter<'a>
|
||||
where
|
||||
I: Iterator<Item = f32> + 'a,
|
||||
{
|
||||
let mut state = Vec::new();
|
||||
for _ in 0..coeffs.len() {
|
||||
state.push(0.0);
|
||||
}
|
||||
|
||||
FIRFilter {
|
||||
coeffs: coeffs,
|
||||
state: state,
|
||||
coeffs,
|
||||
state,
|
||||
pos: 0,
|
||||
iterator: Box::new(iterator)
|
||||
iterator: Box::new(iterator),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -27,7 +30,7 @@ impl<'a> Iterator for FIRFilter<'a> {
|
|||
fn next(&mut self) -> Option<Self::Item> {
|
||||
let cur = match self.iterator.next() {
|
||||
Some(x) => x,
|
||||
None => return None
|
||||
None => return None,
|
||||
};
|
||||
|
||||
self.pos = (self.pos + 1) % self.coeffs.len();
|
||||
|
@ -37,7 +40,7 @@ impl<'a> Iterator for FIRFilter<'a> {
|
|||
for i in 0..self.coeffs.len() {
|
||||
let pos = (self.pos + self.coeffs.len() - i) % self.coeffs.len();
|
||||
result += self.state[pos] * self.coeffs[i];
|
||||
};
|
||||
}
|
||||
|
||||
Some(result)
|
||||
}
|
||||
|
|
205
src/main.rs
205
src/main.rs
|
@ -1,153 +1,76 @@
|
|||
#![windows_subsystem = "windows"]
|
||||
|
||||
extern crate clap;
|
||||
extern crate hound;
|
||||
extern crate image;
|
||||
extern crate indicatif;
|
||||
extern crate rfd;
|
||||
extern crate thiserror;
|
||||
|
||||
#[cfg(feature = "ui")]
|
||||
extern crate eframe;
|
||||
|
||||
mod utils;
|
||||
mod firfilter;
|
||||
mod resamplers;
|
||||
mod amdemod;
|
||||
mod aptsyncer;
|
||||
mod cli;
|
||||
mod decoder;
|
||||
mod errors;
|
||||
mod firfilter;
|
||||
mod resamplers;
|
||||
mod utils;
|
||||
|
||||
use std::io::prelude::*;
|
||||
use std::fs::File;
|
||||
use std::path::Path;
|
||||
|
||||
use utils::float_sample_iterator;
|
||||
use firfilter::FIRFilter;
|
||||
use amdemod::SquaringAMDemodulator;
|
||||
use resamplers::{Upsampler, Downsampler};
|
||||
use aptsyncer::{APTSyncer, SyncedSample};
|
||||
|
||||
const LINES_PER_SECOND: u32 = 2;
|
||||
const PIXELS_PER_LINE: u32 = 2080;
|
||||
|
||||
const LOWPASS_COEFFS : [f32; 63] = [ -7.383784e-03,
|
||||
-3.183046e-03, 2.255039e-03, 7.461166e-03, 1.091908e-02,
|
||||
1.149109e-02, 8.769802e-03, 3.252932e-03, -3.720606e-03,
|
||||
-1.027446e-02, -1.447403e-02, -1.486427e-02, -1.092423e-02,
|
||||
-3.307958e-03, 6.212477e-03, 1.511364e-02, 2.072873e-02,
|
||||
2.096037e-02, 1.492345e-02, 3.347624e-03, -1.138407e-02,
|
||||
-2.560252e-02, -3.507114e-02, -3.591225e-02, -2.553830e-02,
|
||||
-3.371569e-03, 2.882645e-02, 6.711368e-02, 1.060042e-01,
|
||||
1.394643e-01, 1.620650e-01, 1.700462e-01, 1.620650e-01,
|
||||
1.394643e-01, 1.060042e-01, 6.711368e-02, 2.882645e-02,
|
||||
-3.371569e-03, -2.553830e-02, -3.591225e-02, -3.507114e-02,
|
||||
-2.560252e-02, -1.138407e-02, 3.347624e-03, 1.492345e-02,
|
||||
2.096037e-02, 2.072873e-02, 1.511364e-02, 6.212477e-03,
|
||||
-3.307958e-03, -1.092423e-02, -1.486427e-02, -1.447403e-02,
|
||||
-1.027446e-02, -3.720606e-03, 3.252932e-03, 8.769802e-03,
|
||||
1.149109e-02, 1.091908e-02, 7.461166e-03, 2.255039e-03,
|
||||
-3.183046e-03, -7.383784e-03];
|
||||
use clap::{arg, command};
|
||||
|
||||
#[cfg(not(feature = "ui"))]
|
||||
fn main() {
|
||||
let args : Vec<String> = std::env::args().collect();
|
||||
let matches = command!()
|
||||
.arg(arg!([wavfile] "Input wav file with 48kHz samplingrate").required(true))
|
||||
.arg(arg!([pngfile] "Output png file").default_value("output.png"))
|
||||
.get_matches();
|
||||
|
||||
if args.len() != 3 {
|
||||
println!("Usage: {} <wav file> <output file>", args[0]);
|
||||
return;
|
||||
}
|
||||
let input_file = matches
|
||||
.get_one::<String>("wavfile")
|
||||
.expect("No input file given");
|
||||
|
||||
let mut reader = match hound::WavReader::open(&args[1]) {
|
||||
Err(e) => panic!("Could not open inputfile: {}", e),
|
||||
Ok(r) => r
|
||||
};
|
||||
let output_file = matches
|
||||
.get_one::<String>("pngfile")
|
||||
.expect("No output file given");
|
||||
|
||||
if reader.spec().channels != 1 {
|
||||
panic!("Expected a mono file");
|
||||
}
|
||||
|
||||
let sample_rate = reader.spec().sample_rate;
|
||||
println!("Samplerate: {}", sample_rate);
|
||||
if sample_rate != 48000 {
|
||||
panic!("Expected a 48kHz sample rate");
|
||||
}
|
||||
|
||||
let sample_count = reader.len();
|
||||
let seconds = (sample_count as f32) / (sample_rate as f32);
|
||||
let lines = (seconds.ceil() as u32) * LINES_PER_SECOND;
|
||||
println!("File contains {} seconds or {} lines", seconds, lines);
|
||||
|
||||
let mut img = image::ImageBuffer::new(PIXELS_PER_LINE, lines);
|
||||
|
||||
let coeffs = &LOWPASS_COEFFS;
|
||||
|
||||
let samples = float_sample_iterator(&mut reader);
|
||||
|
||||
let demod = SquaringAMDemodulator::from(samples);
|
||||
let filter = FIRFilter::from(demod, coeffs);
|
||||
let upsampler = Upsampler::from(filter, 13);
|
||||
let downsampler = Downsampler::from(upsampler, 150);
|
||||
let syncer = APTSyncer::from(downsampler);
|
||||
|
||||
let mut x = 0;
|
||||
let mut y = 0;
|
||||
let mut max_level = 0.0;
|
||||
let mut has_sync = false;
|
||||
|
||||
let mut progress = 0;
|
||||
let step = sample_count * 13 / 150 / 10;
|
||||
|
||||
let mut previous_sample = 0.0;
|
||||
|
||||
print!("0%");
|
||||
std::io::stdout().flush().unwrap();
|
||||
for synced_sample in syncer {
|
||||
progress += 1;
|
||||
|
||||
if progress % step == 0 {
|
||||
print!("...{}%", progress / step * 10);
|
||||
std::io::stdout().flush().unwrap();
|
||||
}
|
||||
|
||||
let sample = match synced_sample {
|
||||
SyncedSample::Sample(s) => s,
|
||||
SyncedSample::SyncA(s) =>{
|
||||
if !has_sync {
|
||||
max_level = 0.0;
|
||||
has_sync = true;
|
||||
}
|
||||
x = 0;
|
||||
s
|
||||
}
|
||||
SyncedSample::SyncB(s) =>{
|
||||
if x < (PIXELS_PER_LINE / 2) {
|
||||
let skip_distance = (PIXELS_PER_LINE / 2) - x;
|
||||
let color = (previous_sample / max_level * 255.0) as u8;
|
||||
for i in 0..skip_distance {
|
||||
img.put_pixel(x + i,y,image::Luma([color]));
|
||||
}
|
||||
}
|
||||
if !has_sync {
|
||||
max_level = 0.0;
|
||||
has_sync = true;
|
||||
}
|
||||
x = PIXELS_PER_LINE / 2;
|
||||
s
|
||||
}
|
||||
};
|
||||
|
||||
max_level = f32::max(sample, max_level);
|
||||
let color = (sample / max_level * 255.0) as u8;
|
||||
|
||||
if y < lines {
|
||||
img.put_pixel(x,y,image::Luma([color]));
|
||||
}
|
||||
|
||||
x += 1;
|
||||
if x >= PIXELS_PER_LINE {
|
||||
x = 0;
|
||||
y += 1;
|
||||
}
|
||||
|
||||
previous_sample = sample;
|
||||
}
|
||||
println!("");
|
||||
|
||||
let ref mut fout = match File::create(&Path::new(&args[2])) {
|
||||
Err(e) => panic!("Could not open outputfile: {}", e),
|
||||
Ok(f) => f
|
||||
};
|
||||
|
||||
image::ImageLuma8(img).save(fout, image::PNG).unwrap();
|
||||
|
||||
println!("Done !");
|
||||
cli::decode(&input_file, &output_file);
|
||||
}
|
||||
|
||||
#[cfg(feature = "ui")]
|
||||
mod ui;
|
||||
|
||||
#[cfg(feature = "ui")]
|
||||
use ui::DecoderApp;
|
||||
|
||||
#[cfg(feature = "ui")]
|
||||
fn main() {
|
||||
let matches = command!()
|
||||
.arg(arg!([wavfile] "Input wav file with 48kHz samplingrate").default_value("input.wav"))
|
||||
.arg(arg!([pngfile] "Output png file").default_value("output.png"))
|
||||
.arg(arg!(-n --nogui "Disable gui and run in command line mode"))
|
||||
.get_matches();
|
||||
|
||||
let input_file = matches
|
||||
.get_one::<String>("wavfile")
|
||||
.expect("No input file given")
|
||||
.to_string();
|
||||
let output_file = matches
|
||||
.get_one::<String>("pngfile")
|
||||
.expect("No output file given")
|
||||
.to_string();
|
||||
|
||||
if matches.get_flag("nogui") {
|
||||
cli::decode(&input_file, &output_file);
|
||||
} else {
|
||||
let native_options = eframe::NativeOptions::default();
|
||||
|
||||
eframe::run_native(
|
||||
"APT-Decoder",
|
||||
native_options,
|
||||
Box::new(move |_cc| Box::new(DecoderApp::new(&input_file, &output_file))),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,15 +1,18 @@
|
|||
pub struct Upsampler<'a> {
|
||||
factor: u16,
|
||||
state: u16,
|
||||
iterator: Box<Iterator<Item=f32> + 'a>
|
||||
iterator: Box<dyn Iterator<Item = f32> + 'a>,
|
||||
}
|
||||
|
||||
impl<'a> Upsampler<'a> {
|
||||
pub fn from<I>(iterator: I, factor: u16) -> Upsampler<'a> where I: Iterator<Item=f32> + 'a {
|
||||
pub fn from<I>(iterator: I, factor: u16) -> Upsampler<'a>
|
||||
where
|
||||
I: Iterator<Item = f32> + 'a,
|
||||
{
|
||||
Upsampler {
|
||||
factor: factor,
|
||||
state: 0,
|
||||
iterator: Box::new(iterator)
|
||||
iterator: Box::new(iterator),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -20,8 +23,7 @@ impl<'a> Iterator for Upsampler<'a> {
|
|||
fn next(&mut self) -> Option<Self::Item> {
|
||||
let result = if self.state == 0 {
|
||||
self.iterator.next()
|
||||
}
|
||||
else {
|
||||
} else {
|
||||
Some(0.0)
|
||||
};
|
||||
self.state = (self.state + 1) % self.factor;
|
||||
|
@ -30,18 +32,19 @@ impl<'a> Iterator for Upsampler<'a> {
|
|||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
pub struct Downsampler<'a> {
|
||||
factor: u16,
|
||||
iterator: Box<Iterator<Item=f32> + 'a>
|
||||
factor: u16,
|
||||
iterator: Box<dyn Iterator<Item = f32> + 'a>,
|
||||
}
|
||||
|
||||
impl<'a> Downsampler<'a> {
|
||||
pub fn from<I>(iterator: I, factor: u16) -> Downsampler<'a> where I: Iterator<Item=f32> + 'a {
|
||||
pub fn from<I>(iterator: I, factor: u16) -> Downsampler<'a>
|
||||
where
|
||||
I: Iterator<Item = f32> + 'a,
|
||||
{
|
||||
Downsampler {
|
||||
factor: factor,
|
||||
iterator: Box::new(iterator)
|
||||
iterator: Box::new(iterator),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -54,7 +57,7 @@ impl<'a> Iterator for Downsampler<'a> {
|
|||
for _ in 0..self.factor {
|
||||
match self.iterator.next() {
|
||||
Some(x) => result += x,
|
||||
None => return None
|
||||
None => return None,
|
||||
}
|
||||
}
|
||||
result /= self.factor as f32;
|
||||
|
|
|
@ -0,0 +1,193 @@
|
|||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use eframe::egui;
|
||||
use eframe::egui::text_edit::TextEdit;
|
||||
use eframe::egui::widgets::{Button, ProgressBar};
|
||||
use eframe::egui::ColorImage;
|
||||
use eframe::egui::Visuals;
|
||||
use eframe::egui::{Color32, RichText};
|
||||
|
||||
use decoder;
|
||||
use errors::DecoderError;
|
||||
|
||||
#[derive(PartialEq)]
|
||||
enum DecoderRunState {
|
||||
RUNNING,
|
||||
CANCELED,
|
||||
DONE,
|
||||
}
|
||||
|
||||
struct DecoderJobState {
|
||||
update_steps: u32,
|
||||
progress: f32,
|
||||
texture: Option<egui::TextureHandle>,
|
||||
run_state: DecoderRunState,
|
||||
error: Option<DecoderError>,
|
||||
}
|
||||
|
||||
impl DecoderJobState {
|
||||
fn is_running(&self) -> bool {
|
||||
self.run_state == DecoderRunState::RUNNING
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for DecoderJobState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
update_steps: 10,
|
||||
progress: 0.0,
|
||||
texture: None,
|
||||
run_state: DecoderRunState::DONE,
|
||||
error: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct DecoderApp {
|
||||
input_path: String,
|
||||
output_path: String,
|
||||
decoding_state: Arc<Mutex<DecoderJobState>>,
|
||||
}
|
||||
|
||||
impl DecoderApp {
|
||||
pub fn new(input_path: &str, output_path: &str) -> Self {
|
||||
Self {
|
||||
input_path: input_path.to_owned(),
|
||||
output_path: output_path.to_owned(),
|
||||
decoding_state: Arc::new(Mutex::new(DecoderJobState::default())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl eframe::App for DecoderApp {
|
||||
/// Called each time the UI needs repainting, which may be many times per second.
|
||||
/// Put your widgets into a `SidePanel`, `TopPanel`, `CentralPanel`, `Window` or `Area`.
|
||||
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
||||
let Self {
|
||||
input_path,
|
||||
output_path,
|
||||
decoding_state,
|
||||
} = self;
|
||||
|
||||
{
|
||||
let mut state = decoding_state.lock().unwrap();
|
||||
|
||||
if !ctx.input().raw.dropped_files.is_empty() && !state.is_running() {
|
||||
if let Some(path) = ctx.input().raw.dropped_files[0].clone().path {
|
||||
*input_path = path.display().to_string();
|
||||
}
|
||||
}
|
||||
|
||||
ctx.set_visuals(Visuals::dark());
|
||||
egui::CentralPanel::default().show(ctx, |ui| {
|
||||
ui.heading("APT-Decoder");
|
||||
|
||||
egui::Grid::new("form_grid").num_columns(3).show(ui, |ui| {
|
||||
ui.label("Input Wav File:");
|
||||
ui.add_sized(
|
||||
[300.0, 20.0],
|
||||
TextEdit::singleline(input_path).interactive(!state.is_running()),
|
||||
);
|
||||
|
||||
if ui
|
||||
.add_enabled(!state.is_running(), Button::new("Open"))
|
||||
.clicked()
|
||||
{
|
||||
if let Some(path) = rfd::FileDialog::new().pick_file() {
|
||||
*input_path = path.display().to_string();
|
||||
}
|
||||
};
|
||||
ui.end_row();
|
||||
|
||||
ui.label("Output PNG File:");
|
||||
ui.add_sized(
|
||||
[300.0, 20.0],
|
||||
TextEdit::singleline(output_path).interactive(!state.is_running()),
|
||||
);
|
||||
if ui
|
||||
.add_enabled(!state.is_running(), Button::new("Save"))
|
||||
.clicked()
|
||||
{
|
||||
if let Some(path) = rfd::FileDialog::new().save_file() {
|
||||
*output_path = path.display().to_string();
|
||||
}
|
||||
};
|
||||
ui.end_row();
|
||||
});
|
||||
|
||||
ui.horizontal(|ui| {
|
||||
if ui
|
||||
.add_enabled(!state.is_running(), Button::new("Decode"))
|
||||
.clicked()
|
||||
{
|
||||
let ctx = ctx.clone();
|
||||
let decoding_state = decoding_state.clone();
|
||||
let input_path = input_path.clone();
|
||||
let output_path = output_path.clone();
|
||||
|
||||
state.error = None;
|
||||
state.run_state = DecoderRunState::RUNNING;
|
||||
state.texture = None;
|
||||
|
||||
std::thread::spawn(move || {
|
||||
let decoder_res =
|
||||
decoder::decode(&input_path, &output_path, |progress, image| {
|
||||
let mut state = decoding_state.lock().unwrap();
|
||||
|
||||
state.progress = progress;
|
||||
|
||||
let size = [image.width() as _, image.height() as _];
|
||||
let color_img = ColorImage::from_rgba_unmultiplied(
|
||||
size,
|
||||
image.as_flat_samples().as_slice(),
|
||||
);
|
||||
|
||||
state.texture =
|
||||
Some(ctx.load_texture("decoded-image", color_img));
|
||||
|
||||
ctx.request_repaint();
|
||||
|
||||
return (state.is_running(), state.update_steps);
|
||||
});
|
||||
|
||||
let mut state = decoding_state.lock().unwrap();
|
||||
state.run_state = DecoderRunState::DONE;
|
||||
state.error = match decoder_res {
|
||||
Err(err) => Some(err),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
ctx.request_repaint();
|
||||
});
|
||||
}
|
||||
if ui
|
||||
.add_enabled(
|
||||
state.is_running(),
|
||||
Button::new(RichText::new("Cancel").color(Color32::RED)),
|
||||
)
|
||||
.clicked()
|
||||
{
|
||||
state.run_state = DecoderRunState::CANCELED;
|
||||
}
|
||||
});
|
||||
|
||||
let progressbar = ProgressBar::new(state.progress).show_percentage();
|
||||
ui.add(progressbar);
|
||||
ui.end_row();
|
||||
|
||||
if let Some(err) = &state.error {
|
||||
ui.label(RichText::new(err.to_string()).color(Color32::RED));
|
||||
};
|
||||
|
||||
ui.separator();
|
||||
|
||||
let image_size = ui.available_size();
|
||||
state.update_steps = image_size[1] as u32;
|
||||
|
||||
if let Some(texture) = &state.texture {
|
||||
ui.image(texture, image_size);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
27
src/utils.rs
27
src/utils.rs
|
@ -4,15 +4,28 @@ extern crate hound;
|
|||
|
||||
type FileReader = std::io::BufReader<std::fs::File>;
|
||||
|
||||
pub fn float_sample_iterator<'a>(reader: &'a mut hound::WavReader<FileReader>)
|
||||
-> Box<Iterator<Item=f32> + 'a> {
|
||||
pub fn float_sample_iterator<'a>(
|
||||
reader: &'a mut hound::WavReader<FileReader>,
|
||||
) -> Box<dyn Iterator<Item = f32> + 'a> {
|
||||
match reader.spec().sample_format {
|
||||
hound::SampleFormat::Float => Box::new(reader.samples::<f32>().map(|x| x.unwrap())),
|
||||
hound::SampleFormat::Int => match reader.spec().bits_per_sample {
|
||||
8 => Box::new(reader.samples::<i8>().map(|x| (x.unwrap() as f32) / (i16::max_value() as f32))),
|
||||
16 => Box::new(reader.samples::<i16>().map(|x| (x.unwrap() as f32) / (i16::max_value() as f32))),
|
||||
32 => Box::new(reader.samples::<i32>().map(|x| (x.unwrap() as f32) / (i32::max_value() as f32))),
|
||||
_ => panic!("Unsupported sample rate")
|
||||
}
|
||||
8 => Box::new(
|
||||
reader
|
||||
.samples::<i8>()
|
||||
.map(|x| (x.unwrap() as f32) / (i16::max_value() as f32)),
|
||||
),
|
||||
16 => Box::new(
|
||||
reader
|
||||
.samples::<i16>()
|
||||
.map(|x| (x.unwrap() as f32) / (i16::max_value() as f32)),
|
||||
),
|
||||
32 => Box::new(
|
||||
reader
|
||||
.samples::<i32>()
|
||||
.map(|x| (x.unwrap() as f32) / (i32::max_value() as f32)),
|
||||
),
|
||||
_ => panic!("Unsupported sample format"),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue