Compare commits

...

4 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
9 changed files with 990 additions and 239 deletions

6
.gitignore vendored
View File

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

718
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -7,5 +7,6 @@ edition = "2024"
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"

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}/'");
}

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)),
// }
// }
// }
// }

View File

@ -1,65 +1,244 @@
use crate::fm_demod::Sample;
use crate::iq_reader::FileSource;
use crate::pipeline::{AfterDemodPiplineExt, DspPipelineExt};
use egui_plot::{Line, Plot};
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() -> eframe::Result {
let source = FileSource::new("test.iq", 32769).unwrap();
let taps = [0.5; 64];
const SAMPLE_RATE: f32 = 20_000_000.0;
const SYNC_THRESHOLD: f32 = -0.4;
const LOWPASS_ALPHA: f32 = 0.7;
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 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)
// .fir::<64>(taps, 1)
.demodulate()
.dc_block(0.995)
.dc_block(0.997)
.lowpass(0.75)
.filter_map(|r| r.ok())
.collect();
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<_>>(),
))
})
});
})
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<_>>(),
// ))
// })
// });
// });
}
// fn main() -> Result<(), Box<dyn Error>> {
// let source = FileSource::new("test.iq", 32769)?;
//
// // Fir coefs
// let taps = [0.5; 64];
//
// let pipeline = source
// .agc(20_000_000.0, 1.0, 0.001, 100.0)
// .fir::<64>(taps, 4)
// .demodulate()
// .dc_block(0.995)
// .lowpass(0.75);
//
// for s_r in pipeline {
// let s = s_r?;
// println!("phase : {}", s);
// }
//
// 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

@ -3,7 +3,9 @@ use crate::dc_blocker::DcBlocker;
use crate::fir::Fir;
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> {
@ -29,6 +31,17 @@ pub trait AfterDemodPiplineExt<E>: Iterator<Item = Result<Sample, E>> + Sized {
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.