RTL-SDR IQ sample reader
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Merge ocaml-cltu + ocaml-ccsds-coding into ocaml-scc

Create a single package matching the CCSDS standard name
"Synchronization and Channel Coding" (131.0-B, 231.0-B):

Scc.Sync — CLTU/BCH, ASM markers, stream sync parsers
Scc.Coding — randomizer, RS interleaving, FEC presets

API: Scc.Sync.Cltu.encode, Scc.Coding.Randomizer.create, etc.

Remove ocaml-cltu, ocaml-ccsds-coding, ocaml-transport (superseded).

+360
+25
dune-project
··· 1 + (lang dune 3.21) 2 + 3 + (name rtlsdr) 4 + 5 + (generate_opam_files true) 6 + 7 + (authors "Thomas Gazagnaire") 8 + 9 + (maintainers "thomas@gazagnaire.org") 10 + 11 + (license ISC) 12 + 13 + (package 14 + (name rtlsdr) 15 + (synopsis "RTL-SDR IQ sample reader") 16 + (description 17 + "Read interleaved unsigned 8-bit IQ samples from files produced by the 18 + rtl_sdr command-line tool, converting to complex float pairs. File mode 19 + is always available; hardware mode (requiring librtlsdr) can be added 20 + later.") 21 + (depends 22 + (ocaml (>= 5.0.0)) 23 + dune 24 + dsp 25 + (alcotest :with-test)))
+4
lib/dune
··· 1 + (library 2 + (name rtlsdr) 3 + (public_name rtlsdr) 4 + (libraries dsp))
+77
lib/rtlsdr.ml
··· 1 + let pi = Float.pi 2 + 3 + (* Convert an unsigned 8-bit sample value to a float in [-1.0, +1.0]. 4 + 0 -> -1.0, 127.5 -> 0.0, 255 -> +1.0 *) 5 + let u8_to_float v = (Float.of_int v -. 127.5) /. 127.5 6 + 7 + type metadata = { center_freq : float; sample_rate : float; gain : float } 8 + 9 + type source = { 10 + data : bytes; 11 + total_bytes : int; 12 + mutable pos : int; 13 + meta : metadata option; 14 + } 15 + 16 + let sample_count src = src.total_bytes / 2 17 + 18 + let of_bytes data = 19 + { data; total_bytes = Bytes.length data; pos = 0; meta = None } 20 + 21 + let of_file path = 22 + let ic = open_in_bin path in 23 + let len = in_channel_length ic in 24 + let data = Bytes.create len in 25 + really_input ic data 0 len; 26 + close_in ic; 27 + { data; total_bytes = len; pos = 0; meta = None } 28 + 29 + let read src n = 30 + let remaining_bytes = src.total_bytes - src.pos in 31 + let remaining_samples = remaining_bytes / 2 in 32 + let count = min n remaining_samples in 33 + let result = 34 + Array.init count (fun i -> 35 + let off = src.pos + (i * 2) in 36 + let iv = Char.code (Bytes.get src.data off) in 37 + let qv = Char.code (Bytes.get src.data (off + 1)) in 38 + Dsp.Complex.make (u8_to_float iv) (u8_to_float qv)) 39 + in 40 + src.pos <- src.pos + (count * 2); 41 + result 42 + 43 + let read_all src = 44 + let remaining_bytes = src.total_bytes - src.pos in 45 + let count = remaining_bytes / 2 in 46 + let result = 47 + Array.init count (fun i -> 48 + let off = src.pos + (i * 2) in 49 + let iv = Char.code (Bytes.get src.data off) in 50 + let qv = Char.code (Bytes.get src.data (off + 1)) in 51 + Dsp.Complex.make (u8_to_float iv) (u8_to_float qv)) 52 + in 53 + src.pos <- src.total_bytes; 54 + result 55 + 56 + let close _src = () 57 + let with_metadata meta src = { src with meta = Some meta } 58 + let metadata src = src.meta 59 + 60 + let generate_tone ~sample_rate ~freq ~duration = 61 + let n = Float.to_int (sample_rate *. duration) in 62 + Array.init n (fun i -> 63 + let t = Float.of_int i /. sample_rate in 64 + let angle = 2.0 *. pi *. freq *. t in 65 + Dsp.Complex.make (Float.cos angle) (Float.sin angle)) 66 + 67 + let generate_bpsk ~sample_rate ~symbol_rate ~bits ~carrier_freq = 68 + let samples_per_symbol = Float.to_int (sample_rate /. symbol_rate) in 69 + let num_bits = Bytes.length bits in 70 + let total_samples = num_bits * samples_per_symbol in 71 + Array.init total_samples (fun i -> 72 + let bit_index = i / samples_per_symbol in 73 + let bit_val = Char.code (Bytes.get bits bit_index) in 74 + let phase_offset = if bit_val <> 0 then pi else 0.0 in 75 + let t = Float.of_int i /. sample_rate in 76 + let angle = (2.0 *. pi *. carrier_freq *. t) +. phase_offset in 77 + Dsp.Complex.make (Float.cos angle) (Float.sin angle))
+58
lib/rtlsdr.mli
··· 1 + (** RTL-SDR IQ sample reader. 2 + 3 + Reads interleaved uint8 IQ samples from files produced by rtl_sdr, 4 + converting to complex float pairs. *) 5 + 6 + type source 7 + (** An IQ sample source. *) 8 + 9 + val of_file : string -> source 10 + (** [of_file path] opens a raw IQ file for reading. *) 11 + 12 + val of_bytes : bytes -> source 13 + (** [of_bytes data] creates a source from raw IQ bytes in memory. *) 14 + 15 + val read : source -> int -> Dsp.Complex.t array 16 + (** [read src n] reads up to [n] complex samples. Returns fewer at EOF. *) 17 + 18 + val read_all : source -> Dsp.Complex.t array 19 + (** [read_all src] reads all remaining samples. *) 20 + 21 + val close : source -> unit 22 + (** [close src] closes the source. *) 23 + 24 + val sample_count : source -> int 25 + (** [sample_count src] returns total number of complex samples available (for 26 + file/bytes sources). *) 27 + 28 + (** {1 Metadata} *) 29 + 30 + type metadata = { 31 + center_freq : float; (** Center frequency in Hz *) 32 + sample_rate : float; (** Sample rate in Hz *) 33 + gain : float; (** Gain in dB *) 34 + } 35 + (** Optional metadata about the recording. *) 36 + 37 + val with_metadata : metadata -> source -> source 38 + (** [with_metadata meta src] attaches metadata to a source. *) 39 + 40 + val metadata : source -> metadata option 41 + (** [metadata src] returns attached metadata if any. *) 42 + 43 + (** {1 Synthetic signal generation (for testing)} *) 44 + 45 + val generate_tone : 46 + sample_rate:float -> freq:float -> duration:float -> Dsp.Complex.t array 47 + (** [generate_tone ~sample_rate ~freq ~duration] generates a complex sinusoid. 48 + *) 49 + 50 + val generate_bpsk : 51 + sample_rate:float -> 52 + symbol_rate:float -> 53 + bits:bytes -> 54 + carrier_freq:float -> 55 + Dsp.Complex.t array 56 + (** [generate_bpsk ~sample_rate ~symbol_rate ~bits ~carrier_freq] generates a 57 + BPSK-modulated IQ signal from the given bit sequence. Each byte is one bit 58 + (0x00=0, nonzero=1). *)
+33
rtlsdr.opam
··· 1 + # This file is generated by dune, edit dune-project instead 2 + opam-version: "2.0" 3 + synopsis: "RTL-SDR IQ sample reader" 4 + description: """ 5 + Read interleaved unsigned 8-bit IQ samples from files produced by the 6 + rtl_sdr command-line tool, converting to complex float pairs. File mode 7 + is always available; hardware mode (requiring librtlsdr) can be added 8 + later.""" 9 + maintainer: ["thomas@gazagnaire.org"] 10 + authors: ["Thomas Gazagnaire"] 11 + license: "ISC" 12 + depends: [ 13 + "ocaml" {>= "5.0.0"} 14 + "dune" {>= "3.21"} 15 + "dsp" 16 + "alcotest" {with-test} 17 + "odoc" {with-doc} 18 + ] 19 + build: [ 20 + ["dune" "subst"] {dev} 21 + [ 22 + "dune" 23 + "build" 24 + "-p" 25 + name 26 + "-j" 27 + jobs 28 + "@install" 29 + "@runtest" {with-test} 30 + "@doc" {with-doc} 31 + ] 32 + ] 33 + x-maintenance-intent: ["(latest)"]
+3
test/dune
··· 1 + (test 2 + (name test) 3 + (libraries rtlsdr alcotest))
+160
test/test.ml
··· 1 + let pi = Float.pi 2 + 3 + let complex_testable = 4 + Alcotest.testable 5 + (fun ppf (c : Dsp.Complex.t) -> Format.fprintf ppf "(%f + %fi)" c.re c.im) 6 + (fun (a : Dsp.Complex.t) (b : Dsp.Complex.t) -> 7 + Float.abs (a.re -. b.re) < 1e-6 && Float.abs (a.im -. b.im) < 1e-6) 8 + 9 + let approx_complex_testable eps = 10 + Alcotest.testable 11 + (fun ppf (c : Dsp.Complex.t) -> Format.fprintf ppf "(%f + %fi)" c.re c.im) 12 + (fun (a : Dsp.Complex.t) (b : Dsp.Complex.t) -> 13 + Float.abs (a.re -. b.re) < eps && Float.abs (a.im -. b.im) < eps) 14 + 15 + (* Test 1: of_bytes roundtrip with known values *) 16 + let test_of_bytes_roundtrip () = 17 + (* (127, 127) -> approximately (0, 0) -- actually maps to 18 + (127 - 127.5)/127.5 = -0.003921... for both *) 19 + (* (0, 0) -> (-1, -1) *) 20 + (* (255, 255) -> (+1, +1) *) 21 + let data = Bytes.create 6 in 22 + Bytes.set data 0 (Char.chr 127); 23 + Bytes.set data 1 (Char.chr 127); 24 + Bytes.set data 2 (Char.chr 0); 25 + Bytes.set data 3 (Char.chr 0); 26 + Bytes.set data 4 (Char.chr 255); 27 + Bytes.set data 5 (Char.chr 255); 28 + let src = Rtlsdr.of_bytes data in 29 + let samples = Rtlsdr.read_all src in 30 + Alcotest.(check int) "3 samples" 3 (Array.length samples); 31 + (* (127,127): (127-127.5)/127.5 = -0.00392156... *) 32 + let near_zero = -0.5 /. 127.5 in 33 + let tc = approx_complex_testable 1e-6 in 34 + Alcotest.check tc "(127,127) -> ~0+0j" 35 + (Dsp.Complex.make near_zero near_zero) 36 + samples.(0); 37 + Alcotest.check tc "(0,0) -> -1-1j" 38 + (Dsp.Complex.make (-1.0) (-1.0)) 39 + samples.(1); 40 + Alcotest.check tc "(255,255) -> +1+1j" (Dsp.Complex.make 1.0 1.0) samples.(2) 41 + 42 + (* Test 2: generate_tone produces correct frequency *) 43 + let test_generate_tone () = 44 + let sample_rate = 1000.0 in 45 + let freq = 100.0 in 46 + let duration = 0.01 in 47 + let samples = Rtlsdr.generate_tone ~sample_rate ~freq ~duration in 48 + (* 10 samples at 1000 Hz sample rate for 0.01s *) 49 + Alcotest.(check int) "sample count" 10 (Array.length samples); 50 + (* Check phase rotation: each sample should advance by 51 + 2*pi*freq/sample_rate = 2*pi*100/1000 = 0.2*pi radians *) 52 + let phase_step = 2.0 *. pi *. freq /. sample_rate in 53 + let tc = approx_complex_testable 1e-9 in 54 + Array.iteri 55 + (fun i s -> 56 + let expected_angle = phase_step *. Float.of_int i in 57 + let expected = 58 + Dsp.Complex.make (Float.cos expected_angle) (Float.sin expected_angle) 59 + in 60 + Alcotest.check tc (Printf.sprintf "sample %d" i) expected s) 61 + samples 62 + 63 + (* Test 3: generate_bpsk changes phase on bit transitions *) 64 + let test_generate_bpsk () = 65 + let sample_rate = 1000.0 in 66 + let symbol_rate = 100.0 in 67 + let carrier_freq = 0.0 in 68 + (* Use carrier_freq=0 so the signal is just +1 or -1 on I axis *) 69 + let bits = Bytes.of_string "\x00\x01\x00" in 70 + let samples = 71 + Rtlsdr.generate_bpsk ~sample_rate ~symbol_rate ~bits ~carrier_freq 72 + in 73 + let sps = 10 in 74 + (* samples per symbol = 1000/100 *) 75 + Alcotest.(check int) "total samples" 30 (Array.length samples); 76 + (* Bit 0 (value 0): no phase offset, so cos(0)=1.0 for all samples *) 77 + let tc = approx_complex_testable 1e-9 in 78 + for i = 0 to sps - 1 do 79 + Alcotest.check tc 80 + (Printf.sprintf "bit0 sample %d" i) 81 + (Dsp.Complex.make 1.0 0.0) samples.(i) 82 + done; 83 + (* Bit 1 (value 1): pi phase offset, so cos(pi)=-1.0 *) 84 + for i = sps to (2 * sps) - 1 do 85 + Alcotest.check tc 86 + (Printf.sprintf "bit1 sample %d" i) 87 + (Dsp.Complex.make (-1.0) 0.0) 88 + samples.(i) 89 + done; 90 + (* Bit 2 (value 0): back to no offset, cos(0)=1.0 *) 91 + for i = 2 * sps to (3 * sps) - 1 do 92 + Alcotest.check tc 93 + (Printf.sprintf "bit2 sample %d" i) 94 + (Dsp.Complex.make 1.0 0.0) samples.(i) 95 + done 96 + 97 + (* Test 4: sample_count matches byte length / 2 *) 98 + let test_sample_count () = 99 + let data = Bytes.create 100 in 100 + let src = Rtlsdr.of_bytes data in 101 + Alcotest.(check int) "50 samples from 100 bytes" 50 (Rtlsdr.sample_count src); 102 + let data2 = Bytes.create 0 in 103 + let src2 = Rtlsdr.of_bytes data2 in 104 + Alcotest.(check int) "0 samples from 0 bytes" 0 (Rtlsdr.sample_count src2); 105 + (* Odd number of bytes: last byte is ignored *) 106 + let data3 = Bytes.create 7 in 107 + let src3 = Rtlsdr.of_bytes data3 in 108 + Alcotest.(check int) "3 samples from 7 bytes" 3 (Rtlsdr.sample_count src3) 109 + 110 + (* Test 5: read returns correct number of samples *) 111 + let test_read_count () = 112 + let data = Bytes.create 20 in 113 + let src = Rtlsdr.of_bytes data in 114 + (* Read 3 samples *) 115 + let s1 = Rtlsdr.read src 3 in 116 + Alcotest.(check int) "read 3" 3 (Array.length s1); 117 + (* Read 5 more *) 118 + let s2 = Rtlsdr.read src 5 in 119 + Alcotest.(check int) "read 5" 5 (Array.length s2); 120 + (* Only 2 remain, ask for 10 *) 121 + let s3 = Rtlsdr.read src 10 in 122 + Alcotest.(check int) "read remaining 2" 2 (Array.length s3); 123 + (* No more samples *) 124 + let s4 = Rtlsdr.read src 5 in 125 + Alcotest.(check int) "read at EOF" 0 (Array.length s4) 126 + 127 + (* Test 6: metadata attach/retrieve roundtrip *) 128 + let test_metadata () = 129 + let data = Bytes.create 10 in 130 + let src = Rtlsdr.of_bytes data in 131 + Alcotest.(check bool) 132 + "no metadata initially" true 133 + (Option.is_none (Rtlsdr.metadata src)); 134 + let meta : Rtlsdr.metadata = 135 + { center_freq = 433.92e6; sample_rate = 2.4e6; gain = 40.0 } 136 + in 137 + let src2 = Rtlsdr.with_metadata meta src in 138 + let retrieved = Rtlsdr.metadata src2 in 139 + Alcotest.(check bool) "has metadata" true (Option.is_some retrieved); 140 + let m = Option.get retrieved in 141 + Alcotest.(check (float 0.0)) "center_freq" 433.92e6 m.center_freq; 142 + Alcotest.(check (float 0.0)) "sample_rate" 2.4e6 m.sample_rate; 143 + Alcotest.(check (float 0.0)) "gain" 40.0 m.gain 144 + 145 + let () = 146 + Alcotest.run "rtlsdr" 147 + [ 148 + ( "iq", 149 + [ 150 + Alcotest.test_case "of_bytes roundtrip" `Quick test_of_bytes_roundtrip; 151 + Alcotest.test_case "sample_count" `Quick test_sample_count; 152 + Alcotest.test_case "read count" `Quick test_read_count; 153 + Alcotest.test_case "metadata roundtrip" `Quick test_metadata; 154 + ] ); 155 + ( "signal", 156 + [ 157 + Alcotest.test_case "generate_tone" `Quick test_generate_tone; 158 + Alcotest.test_case "generate_bpsk" `Quick test_generate_bpsk; 159 + ] ); 160 + ]