Compare commits

...

6 Commits

Author SHA1 Message Date
497cc3b61c sync 2026-06-20 10:44:43 +02:00
7b3bd7e2e5 sync 2026-06-20 10:29:07 +02:00
3d42a7b61b sync 2026-06-18 20:48:54 +02:00
8efb9c031e sync + image 2026-06-16 10:26:16 +02:00
2d49da5cee egui 2026-06-15 22:31:07 +02:00
1513fd694d dc block + lowpass 2026-06-15 21:41:18 +02:00
12 changed files with 5398 additions and 17 deletions

6
.gitignore vendored
View File

@ -1,2 +1,6 @@
/target
test.iq
# test.iq
test.py
/frames
*.png
*.mp4

4812
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -4,4 +4,9 @@ version = "0.1.0"
edition = "2024"
[dependencies]
eframe = "0.34.3"
egui = "0.34.3"
egui_plot = "0.35.0"
image = "0.25.10"
indicatif = "0.18.4"
num-complex = "0.4.6"

42
src/dc_blocker.rs Normal file
View File

@ -0,0 +1,42 @@
use crate::fm_demod::Sample;
pub struct DcBlocker<I> {
pub inner: I,
pub x_prev: Sample,
pub y_prev: Sample,
pub alpha: f32,
}
impl<I> DcBlocker<I> {
pub fn new(inner: I, alpha: f32) -> Self {
Self {
inner,
x_prev: 0.0,
y_prev: 0.0,
alpha,
}
}
pub fn process_sample(&mut self, x_n: Sample) -> Sample {
let y_n = x_n - self.x_prev + self.alpha * self.y_prev;
self.x_prev = x_n;
self.y_prev = y_n;
y_n
}
}
impl<I, E> Iterator for DcBlocker<I>
where
I: Iterator<Item = Result<Sample, E>>,
{
type Item = Result<Sample, E>;
fn next(&mut self) -> Option<Self::Item> {
match self.inner.next()? {
Ok(sample) => Some(Ok(self.process_sample(sample))),
Err(e) => Some(Err(e)),
}
}
}

44
src/frame.rs Normal file
View File

@ -0,0 +1,44 @@
use image::{GrayImage, Luma};
use std::fs;
use std::path::Path;
use crate::line_assembler::VideoFrame;
// PAL: 1280 samples per line
const WIDTH: u32 = 1280;
const HEIGHT: u32 = 480; // NTSC (576 for PAL)
pub fn save_frames(frames: &[VideoFrame], output_dir: &str, sig_min: f32, sig_max: f32) {
fs::create_dir_all(output_dir).expect("Could not create output directory");
let range = (sig_max - sig_min).max(1e-6);
let mut saved = 0;
for (i, frame) in frames.iter().enumerate() {
if frame.lines.is_empty() {
continue;
}
let mut img = GrayImage::new(WIDTH, HEIGHT);
for y in 0..HEIGHT as usize {
let line = frame.lines.get(y);
for x in 0..WIDTH as usize {
let s = line
.and_then(|l| l.samples.get(x))
.copied()
.unwrap_or(sig_min);
let luma = ((s - sig_min) / range * 255.0).clamp(0.0, 255.0) as u8;
img.put_pixel(x as u32, y as u32, Luma([luma]));
}
}
let path = Path::new(output_dir).join(format!("frame_{:04}.png", i));
img.save(&path).expect("Could not save image");
saved += 1;
}
println!("{saved} image(s) saved to '{output_dir}/'");
}

View File

@ -28,12 +28,14 @@ pub struct FileSource {
pub chunk_samples_size: usize,
// raw_buffer position (in samples)
pub cursor: usize,
// The length of the file in samples
pub len: u64,
}
impl FileSource {
pub fn new(file_path: &str, chunk_samples_size: usize) -> Result<Self, Box<dyn Error>> {
let file = File::open(file_path)?;
let len = file.metadata().unwrap().len() / 2;
// Init buffer with size 16 Mo
let reader = BufReader::with_capacity(16 * 1024 * 1024, file);
@ -45,13 +47,20 @@ impl FileSource {
raw_buffer: raw_reader,
chunk_samples_size,
cursor: chunk_samples_size,
len,
})
}
}
impl ExactSizeIterator for FileSource {}
impl Iterator for FileSource {
type Item = Result<IqSample, Box<dyn Error>>;
fn size_hint(&self) -> (usize, Option<usize>) {
(self.len as usize, Some(self.len as usize))
}
fn next(&mut self) -> Option<Self::Item> {
if self.cursor >= self.chunk_samples_size {
// Buffer read chunk

95
src/line_assembler.rs Normal file
View File

@ -0,0 +1,95 @@
use crate::{fm_demod::Sample, sync::SyncEvent};
pub struct VideoLine {
pub samples: Vec<Sample>,
}
pub struct VideoFrame {
pub lines: Vec<VideoLine>,
}
const LINES_PER_FRAME: usize = 480; // NTSC (576 for PAL)
pub struct LineAssembler<I> {
pub inner: I,
pub current_line: Vec<Sample>,
pub current_frame: Vec<VideoLine>,
}
impl<I> LineAssembler<I> {
pub fn new(inner: I) -> Self {
Self {
inner,
current_line: Vec::new(),
current_frame: Vec::new(),
}
}
}
impl<I, E> Iterator for LineAssembler<I>
where
I: Iterator<Item = Result<SyncEvent, E>>,
{
type Item = Result<VideoFrame, E>;
fn next(&mut self) -> Option<Self::Item> {
loop {
match self.inner.next()? {
Ok(SyncEvent::VideoSample(s)) => {
self.current_line.push(s);
}
Ok(SyncEvent::HSync) => {
let line = VideoLine {
samples: std::mem::take(&mut self.current_line),
};
self.current_frame.push(line);
if self.current_frame.len() >= LINES_PER_FRAME {
let frame = VideoFrame {
lines: std::mem::take(&mut self.current_frame),
};
return Some(Ok(frame));
}
}
Ok(SyncEvent::VSync) => {
// test without vsync
}
Err(e) => return Some(Err(e)),
}
}
}
}
// impl<I, E> Iterator for LineAssembler<I>
// where
// I: Iterator<Item = Result<SyncEvent, E>>,
// {
// type Item = Result<VideoFrame, E>;
//
// fn next(&mut self) -> Option<Self::Item> {
// loop {
// match self.inner.next()? {
// Ok(SyncEvent::VideoSample(s)) => {
// self.current_line.push(s);
// }
// Ok(SyncEvent::HSync) => {
// // Save the finished line
// let line = VideoLine {
// samples: std::mem::take(&mut self.current_line),
// };
//
// self.current_frame.push(line);
// }
// Ok(SyncEvent::VSync) => {
// // Frame finished
// let frame = VideoFrame {
// lines: std::mem::take(&mut self.current_frame),
// };
// self.current_line.clear();
// return Some(Ok(frame));
// }
// Err(e) => return Some(Err(e)),
// }
// }
// }
// }

39
src/low_pass.rs Normal file
View File

@ -0,0 +1,39 @@
use crate::fm_demod::Sample;
pub struct LowPass<I> {
inner: I,
y_prev: Sample,
alpha: f32,
}
impl<I> LowPass<I> {
pub fn new(inner: I, alpha: f32) -> Self {
Self {
inner,
y_prev: 0.0,
alpha,
}
}
pub fn process_sample(&mut self, x_n: Sample) -> Sample {
let y_n = self.alpha * x_n + (1.0 - self.alpha) * self.y_prev;
self.y_prev = y_n;
y_n
}
}
impl<I, E> Iterator for LowPass<I>
where
I: Iterator<Item = Result<Sample, E>>,
{
type Item = Result<Sample, E>;
fn next(&mut self) -> Option<Self::Item> {
match self.inner.next()? {
Ok(sample) => Some(Ok(self.process_sample(sample))),
Err(e) => Some(Err(e)),
}
}
}

View File

@ -1,29 +1,244 @@
use crate::fm_demod::Sample;
use crate::iq_reader::FileSource;
use crate::pipeline::DspPipelineExt;
use std::error::Error;
use crate::pipeline::{AfterDemodPiplineExt, DspPipelineExt, SyncPipelineExt};
use egui_plot::{HLine, Line, Plot, VLine};
use indicatif::ProgressIterator;
mod agc;
mod dc_blocker;
mod fir;
mod fm_demod;
mod frame;
mod iq_reader;
mod line_assembler;
mod low_pass;
mod pipeline;
mod sync;
mod utils;
fn main() -> Result<(), Box<dyn Error>> {
let source = FileSource::new("test.iq", 32769)?;
const SAMPLE_RATE: f32 = 20_000_000.0;
const SYNC_THRESHOLD: f32 = -0.4;
const LOWPASS_ALPHA: f32 = 0.7;
// Fir coefs
let taps = [0.5; 64];
fn main() {
// fn main() {
let taps: [f32; 64] = [
-0.00015813,
-0.00047836,
-0.00080166,
-0.00110309,
-0.00132172,
-0.00136203,
-0.00111203,
-0.00047680,
0.00057973,
0.00199526,
0.00358456,
0.00504093,
0.00597041,
0.00595859,
0.00466189,
0.00190795,
-0.00221520,
-0.00730261,
-0.01262639,
-0.01719077,
-0.01984753,
-0.01945971,
-0.01509023,
-0.00618502,
0.00728221,
0.02473253,
0.04499119,
0.06638976,
0.08695132,
0.10463402,
0.11759778,
0.12445312,
0.12445312,
0.11759778,
0.10463402,
0.08695132,
0.06638976,
0.04499119,
0.02473253,
0.00728221,
-0.00618502,
-0.01509023,
-0.01945971,
-0.01984753,
-0.01719077,
-0.01262639,
-0.00730261,
-0.00221520,
0.00190795,
0.00466189,
0.00595859,
0.00597041,
0.00504093,
0.00358456,
0.00199526,
0.00057973,
-0.00047680,
-0.00111203,
-0.00136203,
-0.00132172,
-0.00110309,
-0.00080166,
-0.00047836,
-0.00015813,
];
let pipeline = source
let source = FileSource::new("test.iq", 32769).unwrap();
let samples: Vec<f32> = source
.skip(10_000)
.take(10_000)
.agc(SAMPLE_RATE, 1.0, 0.001, 100.0)
// .fir(taps, 1)
.demodulate()
.dc_block(0.997)
.lowpass(LOWPASS_ALPHA)
.filter_map(|r| r.ok())
.collect();
let min = samples.iter().cloned().fold(f32::INFINITY, f32::min);
let max = samples.iter().cloned().fold(f32::NEG_INFINITY, f32::max);
let mean: f32 = samples.iter().sum::<f32>() / samples.len() as f32;
println!("min={:.3} max={:.3} mean={:.3}", min, max, mean);
let source = FileSource::new("test.iq", 32769).unwrap();
let frames: Vec<_> = source
.skip(10_000)
.take(5_000_000)
.progress()
.agc(SAMPLE_RATE, 1.0, 0.001, 100.0)
// .fir(taps, 1)
.demodulate()
.dc_block(0.997)
.lowpass(LOWPASS_ALPHA)
.sync_separate(SAMPLE_RATE, SYNC_THRESHOLD)
.assemble_frames()
.filter_map(|r| r.ok())
.collect();
println!("{} frames decoded", frames.len());
frame::save_frames(&frames, "frames", min, max);
let source = FileSource::new("test.iq", 32769).unwrap();
let samples: Vec<Sample> = source
.skip(40_000)
.take(1_000_000)
.progress()
.agc(20_000_000.0, 1.0, 0.001, 100.0)
.fir::<64>(taps, 4)
.demodulate();
// .fir::<64>(taps, 1)
.demodulate()
.dc_block(0.997)
.lowpass(0.75)
.filter_map(|r| r.ok())
.collect();
for phase_r in pipeline {
let phase = phase_r?;
// println!("phase : {}", phase);
let source = FileSource::new("test.iq", 32769).unwrap();
let sync_events: Vec<(usize, &str)> = {
use crate::sync::SyncEvent;
let mut events = Vec::new();
let source = FileSource::new("test.iq", 32769).unwrap();
let mut separator = source
.progress()
.skip(40_000)
.take(1_000_000)
.agc(SAMPLE_RATE, 1.0, 0.001, 100.0)
.demodulate()
.dc_block(0.997)
.lowpass(LOWPASS_ALPHA)
.sync_separate(SAMPLE_RATE, SYNC_THRESHOLD);
while let Some(r) = separator.next() {
if let Ok(evt) = r {
let pos = separator.samples_consumed;
match evt {
SyncEvent::HSync => events.push((pos, "h")),
SyncEvent::VSync => {
events.push((pos, "v"));
println!("vsync\n")
}
SyncEvent::VideoSample(_) => {}
}
}
}
events
};
let _ = eframe::run_native(
"DSP debug",
Default::default(),
Box::new(move |_cc| {
Ok(Box::new(DebugApp {
samples,
sync_events,
}))
}),
);
// let _ = eframe::run_ui_native("DSP", Default::default(), move |ui, _frame| {
// egui::CentralPanel::default().show(ui, |ui| {
// Plot::new("plot").show(ui, |plot_ui| {
// plot_ui.line(Line::new(
// "Signal",
// samples
// .iter()
// .take(10000)
// .enumerate()
// .map(|(i, x)| [i as f64, *x as f64])
// .collect::<Vec<_>>(),
// ))
// })
// });
// });
}
Ok(())
struct DebugApp {
samples: Vec<Sample>,
sync_events: Vec<(usize, &'static str)>,
}
impl eframe::App for DebugApp {
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
Plot::new("plot").show(ui, |plot_ui| {
plot_ui.line(Line::new(
"Signal",
self.samples
.iter()
.enumerate()
.map(|(i, x)| [i as f64, *x as f64])
.collect::<Vec<_>>(),
));
plot_ui.hline(
HLine::new("Threshold", SYNC_THRESHOLD as f64)
.color(egui::Color32::YELLOW)
.width(4.0),
);
for (pos, kind) in &self.sync_events {
let x = *pos as f64;
match *kind {
"h" => plot_ui.vline(
VLine::new("HSync", x)
.color(egui::Color32::from_rgb(80, 160, 255))
.width(4.0),
),
"v" => plot_ui.vline(
VLine::new("VSync", x)
.color(egui::Color32::BROWN)
.width(10.0),
),
_ => {}
}
}
});
}
}

View File

@ -1,7 +1,11 @@
use crate::agc::Agc;
use crate::dc_blocker::DcBlocker;
use crate::fir::Fir;
use crate::fm_demod::FmDemod;
use crate::fm_demod::{FmDemod, Sample};
use crate::iq_reader::IqSample;
use crate::line_assembler::LineAssembler;
use crate::low_pass::LowPass;
use crate::sync::{SyncEvent, SyncSeparator};
pub trait DspPipelineExt<E>: Iterator<Item = Result<IqSample, E>> + Sized {
fn agc(self, sample_rate: f32, target_power: f32, min_gain: f32, max_gain: f32) -> Agc<Self> {
@ -18,3 +22,26 @@ pub trait DspPipelineExt<E>: Iterator<Item = Result<IqSample, E>> + Sized {
}
impl<I, E> DspPipelineExt<E> for I where I: Iterator<Item = Result<IqSample, E>> {}
pub trait AfterDemodPiplineExt<E>: Iterator<Item = Result<Sample, E>> + Sized {
fn dc_block(self, alpha: f32) -> DcBlocker<Self> {
DcBlocker::new(self, alpha)
}
fn lowpass(self, alpha: f32) -> LowPass<Self> {
LowPass::new(self, alpha)
}
fn sync_separate(self, sample_rate: f32, sync_threshold: f32) -> SyncSeparator<Self> {
SyncSeparator::new(self, sample_rate, sync_threshold)
}
}
impl<I, E> AfterDemodPiplineExt<E> for I where I: Iterator<Item = Result<Sample, E>> {}
pub trait SyncPipelineExt<E>: Iterator<Item = Result<SyncEvent, E>> + Sized {
fn assemble_frames(self) -> LineAssembler<Self> {
LineAssembler::new(self)
}
}
impl<I, E> SyncPipelineExt<E> for I where I: Iterator<Item = Result<SyncEvent, E>> {}

89
src/sync.rs Normal file
View File

@ -0,0 +1,89 @@
use crate::fm_demod::Sample;
pub enum SyncState {
Active,
InPulse { count: usize },
}
pub enum SyncEvent {
HSync,
VSync,
VideoSample(Sample),
}
pub struct SyncSeparator<I> {
pub inner: I,
pub state: SyncState,
// Threshold for considering a pulse
pub sync_threshold: f32,
pub hsync_min: usize,
pub hsync_max: usize,
pub vsync_min: usize,
pub samples_consumed: usize,
}
impl<I> SyncSeparator<I> {
pub fn new(inner: I, sample_rate: f32, sync_threshold: f32) -> Self {
// µs -> samples
let us = |micros: f32| (micros * sample_rate / 1_000_000.0) as usize;
Self {
inner,
state: SyncState::Active,
sync_threshold,
hsync_min: us(2.0),
hsync_max: us(10.0),
vsync_min: us(25.0),
samples_consumed: 0,
}
}
pub fn process_sample(&mut self, sample: Sample) -> Option<SyncEvent> {
self.samples_consumed += 1;
match self.state {
SyncState::Active => {
if sample < self.sync_threshold {
self.state = SyncState::InPulse { count: 1 };
None
} else {
Some(SyncEvent::VideoSample(sample))
}
}
SyncState::InPulse { ref mut count } => {
if sample < self.sync_threshold {
*count += 1;
None
} else {
let pulse_len = *count;
self.state = SyncState::Active;
if pulse_len >= self.vsync_min {
Some(SyncEvent::VSync)
} else if pulse_len >= self.hsync_min && pulse_len <= self.hsync_max {
Some(SyncEvent::HSync)
} else {
Some(SyncEvent::VideoSample(sample))
}
}
}
}
}
}
impl<I, E> Iterator for SyncSeparator<I>
where
I: Iterator<Item = Result<Sample, E>>,
{
type Item = Result<SyncEvent, E>;
fn next(&mut self) -> Option<Self::Item> {
loop {
match self.inner.next()? {
Ok(sample) => {
if let Some(event) = self.process_sample(sample) {
return Some(Ok(event));
}
}
Err(e) => return Some(Err(e)),
}
}
}
}

BIN
test.iq Normal file

Binary file not shown.