├── .github └── FUNDING.yml ├── hacktvlive ├── video │ ├── standard.go │ ├── testpattern.go │ ├── ntsc.go │ └── pal.go ├── config │ └── config.go ├── go.mod ├── main.go ├── sdr │ └── transmitter.go ├── source │ └── capture.go └── go.sum ├── rtl_tv ├── config │ └── config.go ├── video │ └── ffplay.go ├── sdr │ └── rtlsdr.go ├── main.go └── decoder │ └── decoder.go └── README.md /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | patreon: SarahRoseLives 4 | -------------------------------------------------------------------------------- /hacktvlive/video/standard.go: -------------------------------------------------------------------------------- 1 | package video 2 | 3 | // Video source resolution we will ask FFmpeg to produce 4 | const ( 5 | FrameWidth = 540 6 | FrameHeight = 480 7 | ) 8 | 9 | // Standard defines the interface for a video signal standard like NTSC or PAL. 10 | type Standard interface { 11 | GenerateFullFrame() 12 | FillTestPattern() 13 | IreToAmplitude(float64) float64 14 | // Mutex for the final, generated frame (NTSC/PAL signal) 15 | LockFrame() 16 | UnlockFrame() 17 | RLockFrame() 18 | RUnlockFrame() 19 | // Mutex for the raw RGB frame from FFmpeg 20 | LockRaw() 21 | UnlockRaw() 22 | // Buffer accessors 23 | FrameBuffer() []float64 24 | RawFrameBuffer() []byte 25 | } -------------------------------------------------------------------------------- /hacktvlive/video/testpattern.go: -------------------------------------------------------------------------------- 1 | package video 2 | 3 | // FillColorBars fills the rawFrameBuffer with a standard SMPTE color bars pattern. 4 | func FillColorBars(buf []byte) { 5 | // SMPTE color bars: 7 vertical stripes 6 | barColors := [7][3]uint8{ 7 | {192, 192, 192}, // Gray 8 | {192, 192, 0}, // Yellow 9 | {0, 192, 192}, // Cyan 10 | {0, 192, 0}, // Green 11 | {192, 0, 192}, // Magenta 12 | {192, 0, 0}, // Red 13 | {0, 0, 192}, // Blue 14 | } 15 | barWidth := FrameWidth / 7 16 | for y := 0; y < FrameHeight; y++ { 17 | for x := 0; x < FrameWidth; x++ { 18 | barIdx := x / barWidth 19 | if barIdx >= 7 { 20 | barIdx = 6 21 | } 22 | i := (y*FrameWidth + x) * 3 23 | buf[i] = barColors[barIdx][0] 24 | buf[i+1] = barColors[barIdx][1] 25 | buf[i+2] = barColors[barIdx][2] 26 | } 27 | } 28 | } -------------------------------------------------------------------------------- /hacktvlive/config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import "flag" 4 | 5 | // FixedSampleRate is the constant sample rate for the HackRF, set to 8 Msps. 6 | const FixedSampleRate = 8_000_000.0 7 | 8 | // Config holds all application configuration values. 9 | type Config struct { 10 | Frequency float64 11 | Bandwidth float64 12 | Gain int 13 | Device string 14 | Callsign string 15 | Test bool 16 | PAL bool 17 | } 18 | 19 | // New creates and returns a new Config struct populated from command-line flags. 20 | func New() *Config { 21 | cfg := &Config{} 22 | flag.Float64Var(&cfg.Frequency, "freq", 1280, "Transmit frequency in MHz") 23 | flag.Float64Var(&cfg.Bandwidth, "bw", 1.5, "Channel bandwidth in MHz for filtering") 24 | flag.IntVar(&cfg.Gain, "gain", 30, "TX VGA gain (0-47)") 25 | flag.StringVar(&cfg.Device, "device", "", "Video device name or index (OS-dependent)") 26 | flag.StringVar(&cfg.Callsign, "callsign", "NOCALL", "Callsign to overlay on the video") 27 | flag.BoolVar(&cfg.Test, "test", false, "Show SMPTE colorbar test screen instead of webcam") 28 | flag.BoolVar(&cfg.PAL, "pal", false, "Use PAL standard instead of NTSC") 29 | flag.Parse() 30 | 31 | return cfg 32 | } -------------------------------------------------------------------------------- /rtl_tv/config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import "flag" 4 | 5 | // Video constants define the NTSC frame structure. 6 | const ( 7 | FrameWidth = 540 8 | FrameHeight = 480 9 | FrameRate = 30000.0 / 1001.0 10 | LinesPerFrame = 525 11 | ) 12 | 13 | // Timing constants for the NTSC signal in microseconds. 14 | const ( 15 | HsyncDurationMicroseconds = 4.7 16 | FrontPorchMicroseconds = 1.5 17 | ActiveVideoMicroseconds = 52.6 18 | ) 19 | 20 | // SDRConfig holds settings for the RTL-SDR device. 21 | type SDRConfig struct { 22 | FrequencyHz int 23 | SampleRateHz int 24 | Gain int 25 | } 26 | 27 | // AppConfig holds the application's entire configuration. 28 | type AppConfig struct { 29 | SDR SDRConfig 30 | } 31 | 32 | // ParseFlags parses command-line flags and returns an AppConfig. 33 | func ParseFlags() *AppConfig { 34 | bw := flag.Float64("bw", 2.4, "SDR sample rate (bandwidth) in MHz") 35 | freq := flag.Float64("freq", 1280, "SDR center frequency in MHz") 36 | gain := flag.Int("gain", 300, "SDR tuner gain in tenths of a dB (e.g., 496 for 49.6 dB)") 37 | flag.Parse() 38 | 39 | return &AppConfig{ 40 | SDR: SDRConfig{ 41 | FrequencyHz: int(*freq * 1_000_000), 42 | SampleRateHz: int(*bw * 1_000_000), 43 | Gain: *gain, 44 | }, 45 | } 46 | } -------------------------------------------------------------------------------- /hacktvlive/go.mod: -------------------------------------------------------------------------------- 1 | module hacktvlive 2 | 3 | go 1.24.0 4 | 5 | require ( 6 | github.com/charmbracelet/bubbletea v1.3.6 7 | github.com/charmbracelet/lipgloss v1.1.0 8 | github.com/samuel/go-hackrf v0.0.0-20171108215759-68a81b40b34d 9 | ) 10 | 11 | require ( 12 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect 13 | github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect 14 | github.com/charmbracelet/x/ansi v0.9.3 // indirect 15 | github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect 16 | github.com/charmbracelet/x/term v0.2.1 // indirect 17 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect 18 | github.com/lucasb-eyer/go-colorful v1.2.0 // indirect 19 | github.com/mattn/go-isatty v0.0.20 // indirect 20 | github.com/mattn/go-localereader v0.0.1 // indirect 21 | github.com/mattn/go-runewidth v0.0.16 // indirect 22 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect 23 | github.com/muesli/cancelreader v0.2.2 // indirect 24 | github.com/muesli/termenv v0.16.0 // indirect 25 | github.com/rivo/uniseg v0.4.7 // indirect 26 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect 27 | golang.org/x/sync v0.15.0 // indirect 28 | golang.org/x/sys v0.33.0 // indirect 29 | golang.org/x/text v0.3.8 // indirect 30 | ) 31 | -------------------------------------------------------------------------------- /rtl_tv/video/ffplay.go: -------------------------------------------------------------------------------- 1 | package video 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "log" 7 | "os" 8 | "os/exec" 9 | "rtltv/config" // Import our new config package 10 | ) 11 | 12 | // FFplay represents the FFplay video player process and its input pipe. 13 | type FFplay struct { 14 | Pipe io.WriteCloser 15 | Cmd *exec.Cmd 16 | } 17 | 18 | // Start launches the FFplay process configured for our raw video stream. 19 | func Start() (*FFplay, error) { 20 | ffplayPath, err := exec.LookPath("ffplay") 21 | if err != nil { 22 | return nil, fmt.Errorf("ffplay not found in your PATH") 23 | } 24 | 25 | args := []string{ 26 | "-f", "rawvideo", 27 | "-pixel_format", "rgb24", 28 | "-video_size", fmt.Sprintf("%dx%d", config.FrameWidth, config.FrameHeight), 29 | "-framerate", fmt.Sprintf("%f", config.FrameRate), 30 | "-i", "-", // Read from stdin 31 | "-window_title", "NTSC Receiver", 32 | "-x", "720", "-y", "480", 33 | "-fflags", "nobuffer", 34 | "-flags", "low_delay", 35 | } 36 | 37 | cmd := exec.Command(ffplayPath, args...) 38 | stdinPipe, err := cmd.StdinPipe() 39 | if err != nil { 40 | return nil, err 41 | } 42 | cmd.Stderr = os.Stderr // Show ffplay errors in our console 43 | 44 | if err := cmd.Start(); err != nil { 45 | return nil, err 46 | } 47 | 48 | log.Println("FFplay process started. Video output should appear in a new window.") 49 | return &FFplay{Pipe: stdinPipe, Cmd: cmd}, nil 50 | } 51 | 52 | // Stop safely terminates the FFplay process. 53 | func (f *FFplay) Stop() { 54 | f.Pipe.Close() 55 | f.Cmd.Process.Kill() 56 | } -------------------------------------------------------------------------------- /rtl_tv/sdr/rtlsdr.go: -------------------------------------------------------------------------------- 1 | package sdr 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "rtltv/config" 7 | rtl "github.com/jpoirier/gortlsdr" 8 | ) 9 | 10 | // SetupDevice initializes and configures the RTL-SDR device. 11 | // This version matches the library you provided. 12 | func SetupDevice(cfg *config.SDRConfig) (*rtl.Context, error) { 13 | devCount := rtl.GetDeviceCount() 14 | if devCount == 0 { 15 | return nil, fmt.Errorf("no RTL-SDR devices found") 16 | } 17 | log.Printf("Found %d RTL-SDR device(s). Using device 0.", devCount) 18 | 19 | // Use the correct Open() function which returns a *Context 20 | dongle, err := rtl.Open(0) 21 | if err != nil { 22 | return nil, fmt.Errorf("error opening RTL-SDR device: %w", err) 23 | } 24 | 25 | // Configure device 26 | if err := dongle.SetCenterFreq(cfg.FrequencyHz); err != nil { 27 | dongle.Close() 28 | return nil, fmt.Errorf("SetCenterFreq failed: %w", err) 29 | } 30 | log.Printf("Tuned to frequency: %.3f MHz", float64(cfg.FrequencyHz)/1e6) 31 | 32 | if err := dongle.SetSampleRate(cfg.SampleRateHz); err != nil { 33 | dongle.Close() 34 | return nil, fmt.Errorf("SetSampleRate failed: %w", err) 35 | } 36 | log.Printf("Sample rate set to: %.3f MHz", float64(cfg.SampleRateHz)/1e6) 37 | 38 | if err := dongle.SetTunerGainMode(true); err != nil { 39 | dongle.Close() 40 | return nil, fmt.Errorf("SetTunerGainMode failed: %w", err) 41 | } 42 | if err := dongle.SetTunerGain(cfg.Gain); err != nil { 43 | dongle.Close() 44 | return nil, fmt.Errorf("SetTunerGain failed: %w", err) 45 | } 46 | log.Printf("Tuner gain set to MANUAL: %.1f dB", float64(cfg.Gain)/10.0) 47 | 48 | if err := dongle.ResetBuffer(); err != nil { 49 | dongle.Close() 50 | return nil, fmt.Errorf("ResetBuffer failed: %w", err) 51 | } 52 | 53 | return dongle, nil 54 | } -------------------------------------------------------------------------------- /rtl_tv/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "log" 5 | "os" 6 | "os/signal" 7 | "syscall" 8 | "time" 9 | 10 | "rtltv/config" 11 | "rtltv/decoder" 12 | "rtltv/sdr" 13 | "rtltv/video" 14 | 15 | rtl "github.com/jpoirier/gortlsdr" 16 | ) 17 | 18 | func main() { 19 | // 1. Configuration 20 | cfg := config.ParseFlags() 21 | log.Println("Starting RTL-SDR NTSC receiver...") 22 | 23 | // 2. Setup SDR Device 24 | dongle, err := sdr.SetupDevice(&cfg.SDR) 25 | if err != nil { 26 | log.Fatalf("SDR setup failed: %v", err) 27 | } 28 | defer dongle.Close() 29 | 30 | // 3. Setup Video Output 31 | ffplay, err := video.Start() 32 | if err != nil { 33 | log.Fatalf("Failed to start FFplay: %v", err) 34 | } 35 | defer ffplay.Stop() 36 | 37 | // 4. Initialize Decoder 38 | dec := decoder.New(float64(cfg.SDR.SampleRateHz)) 39 | log.Println("Receiver started. Looking for NTSC sync pulses...") 40 | log.Printf("IMPORTANT: Transmitter must be running with matching -bw %.1f flag!", float64(cfg.SDR.SampleRateHz)/1e6) 41 | 42 | // 5. Start SDR Read Loop (in a separate goroutine) 43 | go func() { 44 | readBuffer := make([]byte, rtl.DefaultBufLength*2) 45 | for { 46 | bytesRead, err := dongle.ReadSync(readBuffer, len(readBuffer)) 47 | if err != nil { 48 | log.Printf("SDR read loop stopped: %v", err) 49 | return 50 | } 51 | if bytesRead > 0 { 52 | dec.ProcessIQ(readBuffer[:bytesRead]) 53 | } 54 | } 55 | }() 56 | 57 | // 6. Setup display ticker and graceful shutdown channel 58 | frameTicker := time.NewTicker(time.Second * 1001 / 30000) // Ticks at NTSC frame rate 59 | defer frameTicker.Stop() 60 | shutdown := make(chan os.Signal, 1) 61 | signal.Notify(shutdown, syscall.SIGINT, syscall.SIGTERM) 62 | log.Println("Application is running. Press Ctrl+C to exit.") 63 | 64 | // 7. Main loop to display frames and listen for shutdown 65 | for { 66 | select { 67 | case <-frameTicker.C: 68 | frame := dec.GetDisplayFrame() 69 | if _, err := ffplay.Pipe.Write(frame); err != nil { 70 | log.Println("Error writing to FFplay pipe, exiting. (Window was likely closed).") 71 | return 72 | } 73 | case <-shutdown: 74 | log.Println("Shutdown signal received, cleaning up...") 75 | return // Exit loop, allowing defers to run 76 | } 77 | } 78 | } -------------------------------------------------------------------------------- /hacktvlive/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "log" 5 | "os" 6 | "os/signal" 7 | "syscall" 8 | "time" 9 | 10 | "github.com/samuel/go-hackrf/hackrf" 11 | "hacktvlive/config" 12 | "hacktvlive/sdr" 13 | "hacktvlive/source" 14 | "hacktvlive/video" 15 | ) 16 | 17 | func main() { 18 | cfg := config.New() 19 | 20 | // 1. Initialize HackRF and open device (Lifecycle is managed by main) 21 | if err := hackrf.Init(); err != nil { 22 | log.Fatalf("hackrf.Init() failed: %v", err) 23 | } 24 | defer hackrf.Exit() 25 | 26 | dev, err := hackrf.Open() 27 | if err != nil { 28 | log.Fatalf("hackrf.Open() failed: %v", err) 29 | } 30 | defer dev.Close() 31 | 32 | // 2. Select the video standard (NTSC or PAL) using the fixed sample rate 33 | var videoStandard video.Standard 34 | var frameTick time.Duration 35 | if cfg.PAL { 36 | videoStandard = video.NewPAL(config.FixedSampleRate) 37 | frameTick = time.Second / 25 38 | } else { 39 | videoStandard = video.NewNTSC(config.FixedSampleRate) 40 | frameTick = time.Second * 1001 / 30000 41 | } 42 | 43 | // 3. Set up the video source (test pattern or FFmpeg) 44 | if cfg.Test { 45 | log.Println("Test mode: SMPTE color bars will be transmitted.") 46 | videoStandard.FillTestPattern() 47 | go func() { 48 | ticker := time.NewTicker(frameTick) 49 | defer ticker.Stop() 50 | for { 51 | <-ticker.C 52 | videoStandard.LockFrame() 53 | videoStandard.GenerateFullFrame() 54 | videoStandard.UnlockFrame() 55 | } 56 | }() 57 | } else { 58 | ffmpegCmd, err := source.StartFFmpegCapture(cfg, videoStandard) 59 | if err != nil { 60 | log.Fatalf("Failed to start video source: %v", err) 61 | } 62 | defer func() { 63 | if ffmpegCmd.Process != nil { 64 | _ = ffmpegCmd.Process.Kill() 65 | } 66 | }() 67 | } 68 | 69 | log.Println("Generating initial frame...") 70 | videoStandard.GenerateFullFrame() 71 | 72 | // 4. Start the SDR transmission using the opened device 73 | if err := sdr.Transmit(dev, cfg, videoStandard); err != nil { 74 | log.Fatalf("Transmission failed: %v", err) 75 | } 76 | 77 | // 5. Wait for a stop signal (Ctrl+C) to gracefully exit 78 | log.Println("Transmission is live. Press Ctrl+C to stop.") 79 | sigChan := make(chan os.Signal, 1) 80 | signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM) 81 | <-sigChan 82 | 83 | log.Println("Shutting down...") 84 | } -------------------------------------------------------------------------------- /hacktvlive/sdr/transmitter.go: -------------------------------------------------------------------------------- 1 | package sdr 2 | 3 | import ( 4 | "log" 5 | "math" 6 | "sync" 7 | 8 | "github.com/samuel/go-hackrf/hackrf" 9 | "hacktvlive/config" 10 | "hacktvlive/video" 11 | ) 12 | 13 | // NewLowPassFilterTaps creates the coefficients (taps) for a FIR low-pass filter. 14 | // A Blackman window is used for good performance. 15 | func NewLowPassFilterTaps(numTaps int, bandwidth, sampleRate float64) []float64 { 16 | taps := make([]float64, numTaps) 17 | cutoffFreq := bandwidth / 2.0 18 | normalizedCutoff := cutoffFreq / sampleRate 19 | 20 | M := float64(numTaps - 1) 21 | var sum float64 22 | for i := 0; i < numTaps; i++ { 23 | n := float64(i) 24 | window := 0.42 - 0.5*math.Cos(2*math.Pi*n/M) + 0.08*math.Cos(4*math.Pi*n/M) 25 | 26 | var sinc float64 27 | if i == int(M/2) { 28 | sinc = 2 * math.Pi * normalizedCutoff 29 | } else { 30 | sinc = math.Sin(2*math.Pi*normalizedCutoff*(n-M/2)) / (n - M/2) 31 | } 32 | 33 | taps[i] = sinc * window 34 | sum += taps[i] 35 | } 36 | 37 | // Normalize the taps to have a gain of 1 at DC (0 Hz) 38 | for i := range taps { 39 | taps[i] /= sum 40 | } 41 | return taps 42 | } 43 | 44 | var debugLogOnce sync.Once 45 | 46 | // Transmit configures an open HackRF device and starts the transmission stream. 47 | func Transmit(dev *hackrf.Device, cfg *config.Config, v video.Standard) error { 48 | txFrequencyHz := uint64(cfg.Frequency * 1_000_000) 49 | 50 | if err := dev.SetFreq(txFrequencyHz); err != nil { 51 | return err 52 | } 53 | // Use the fixed sample rate from the config package 54 | if err := dev.SetSampleRate(config.FixedSampleRate); err != nil { 55 | return err 56 | } 57 | if err := dev.SetTXVGAGain(cfg.Gain); err != nil { 58 | return err 59 | } 60 | if err := dev.SetAmpEnable(false); err != nil { 61 | return err 62 | } 63 | 64 | log.Printf("Starting transmission on %.3f MHz with a %.2f MHz filter bandwidth (Sample Rate: %.1f Msps)...", 65 | float64(txFrequencyHz)/1e6, cfg.Bandwidth, config.FixedSampleRate/1e6) 66 | 67 | var sampleCounter int = 0 68 | // StartTX is non-blocking and returns immediately. 69 | // The callback is now simple again, only sending pre-filtered samples. 70 | return dev.StartTX(func(buf []byte) error { 71 | samplesToWrite := len(buf) / 2 72 | 73 | v.RLockFrame() 74 | defer v.RUnlockFrame() 75 | 76 | frameBuf := v.FrameBuffer() 77 | 78 | for i := 0; i < samplesToWrite; i++ { 79 | ire := frameBuf[sampleCounter] 80 | amplitude := v.IreToAmplitude(ire) 81 | 82 | iSample := int8(amplitude * 127.0) 83 | qSample := int8(0) 84 | 85 | buf[i*2] = byte(iSample) 86 | buf[i*2+1] = byte(qSample) 87 | 88 | sampleCounter++ 89 | if sampleCounter >= len(frameBuf) { 90 | sampleCounter = 0 91 | } 92 | } 93 | return nil 94 | }) 95 | } -------------------------------------------------------------------------------- /hacktvlive/source/capture.go: -------------------------------------------------------------------------------- 1 | package source 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "log" 7 | "os/exec" 8 | "runtime" 9 | 10 | "hacktvlive/config" 11 | "hacktvlive/video" 12 | ) 13 | 14 | // StartFFmpegCapture starts an FFmpeg process to capture video. 15 | func StartFFmpegCapture(cfg *config.Config, v video.Standard) (*exec.Cmd, error) { 16 | var ffmpegArgs []string 17 | 18 | switch runtime.GOOS { 19 | case "linux": 20 | dev := cfg.Device 21 | if dev == "" { 22 | dev = "/dev/video0" 23 | } 24 | ffmpegArgs = []string{"-f", "v4l2", "-i", dev} 25 | case "darwin": 26 | dev := cfg.Device 27 | if dev == "" { 28 | dev = "0" 29 | } 30 | ffmpegArgs = []string{"-f", "avfoundation", "-i", dev} 31 | case "windows": 32 | dev := cfg.Device 33 | if dev == "" { 34 | dev = "Integrated Webcam" 35 | } 36 | ffmpegArgs = []string{"-f", "dshow", "-i", "video=" + dev} 37 | default: 38 | return nil, fmt.Errorf("unsupported OS: %s", runtime.GOOS) 39 | } 40 | 41 | fpsVal := "30000/1001" 42 | if cfg.PAL { 43 | fpsVal = "25" 44 | } 45 | 46 | var vfArg string 47 | if cfg.Callsign != "" { 48 | vfArg = fmt.Sprintf("scale=%d:%d,fps=%s,drawbox=x=0:y=ih-40:w=iw:h=40:color=black@0.6:t=fill,drawtext=fontfile=/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf:text='%s':x=10:y=h-35:fontcolor=white:fontsize=32:borderw=2:bordercolor=black", video.FrameWidth, video.FrameHeight, fpsVal, cfg.Callsign) 49 | } else { 50 | vfArg = fmt.Sprintf("scale=%d:%d,fps=%s", video.FrameWidth, video.FrameHeight, fpsVal) 51 | } 52 | 53 | commonArgs := []string{ 54 | "-hide_banner", "-loglevel", "error", 55 | "-fflags", "nobuffer", "-flags", "low_delay", 56 | "-probesize", "32", "-analyzeduration", "0", 57 | "-threads", "1", "-f", "rawvideo", 58 | "-pix_fmt", "rgb24", "-vf", vfArg, "-", 59 | } 60 | 61 | ffmpegArgs = append(ffmpegArgs, commonArgs...) 62 | ffmpegCmd := exec.Command("ffmpeg", ffmpegArgs...) 63 | 64 | ffmpegStdout, err := ffmpegCmd.StdoutPipe() 65 | if err != nil { 66 | return nil, fmt.Errorf("failed to get FFmpeg stdout pipe: %w", err) 67 | } 68 | if err := ffmpegCmd.Start(); err != nil { 69 | return nil, fmt.Errorf("failed to start FFmpeg: %w", err) 70 | } 71 | log.Println("FFmpeg process started to capture webcam...") 72 | 73 | go func() { 74 | for { 75 | // Lock the raw buffer before writing to prevent a data race. 76 | v.LockRaw() 77 | _, err := io.ReadFull(ffmpegStdout, v.RawFrameBuffer()) 78 | v.UnlockRaw() // Always unlock, even after an error. 79 | 80 | if err != nil { 81 | if err != io.EOF { 82 | log.Printf("Error reading from FFmpeg: %v", err) 83 | } 84 | break 85 | } 86 | 87 | // Generate the full analog signal frame from the new raw data. 88 | v.LockFrame() 89 | v.GenerateFullFrame() 90 | v.UnlockFrame() 91 | } 92 | }() 93 | 94 | return ffmpegCmd, nil 95 | } -------------------------------------------------------------------------------- /hacktvlive/go.sum: -------------------------------------------------------------------------------- 1 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= 2 | github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= 3 | github.com/charmbracelet/bubbletea v1.3.6 h1:VkHIxPJQeDt0aFJIsVxw8BQdh/F/L2KKZGsK6et5taU= 4 | github.com/charmbracelet/bubbletea v1.3.6/go.mod h1:oQD9VCRQFF8KplacJLo28/jofOI2ToOfGYeFgBBxHOc= 5 | github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= 6 | github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= 7 | github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= 8 | github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= 9 | github.com/charmbracelet/x/ansi v0.9.3 h1:BXt5DHS/MKF+LjuK4huWrC6NCvHtexww7dMayh6GXd0= 10 | github.com/charmbracelet/x/ansi v0.9.3/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= 11 | github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= 12 | github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= 13 | github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= 14 | github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= 15 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= 16 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= 17 | github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= 18 | github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= 19 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 20 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 21 | github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= 22 | github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= 23 | github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= 24 | github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 25 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= 26 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= 27 | github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= 28 | github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= 29 | github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= 30 | github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= 31 | github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 32 | github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= 33 | github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 34 | github.com/samuel/go-hackrf v0.0.0-20171108215759-68a81b40b34d h1:krarXX6jj5JSXpVN9XKuGBWKpj16a6/15F5eHcPUfao= 35 | github.com/samuel/go-hackrf v0.0.0-20171108215759-68a81b40b34d/go.mod h1:Ic/cHK689ENkRnl+Z0Ic2BnVzk2CYpBNucaYq64ofUQ= 36 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= 37 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= 38 | golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= 39 | golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= 40 | golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8= 41 | golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= 42 | golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 43 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 44 | golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= 45 | golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 46 | golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= 47 | golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= 48 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # HackTVLive 2 | 3 | HackTVLive is a Go application that captures live video from your webcam and transmits it as an NTSC analog television signal using a HackRF SDR device. It leverages FFmpeg for video capture and processing, and the [`go-hackrf`](https://github.com/samuel/go-hackrf) library for interfacing with HackRF hardware. 4 | 5 | ## Features 6 | 7 | - **Live Webcam Capture**: Uses FFmpeg to grab video from your webcam and scale it to NTSC resolution. 8 | - **NTSC Signal Generation**: Converts RGB video frames into NTSC color video with proper sync, blanking, and color burst. 9 | - **SDR Transmission**: Transmits the NTSC signal over the air using a HackRF device at your specified frequency. 10 | - **Cross-Platform**: Works on Linux, macOS, and Windows (with platform-specific FFmpeg input options). 11 | - **Callsign Overlay**: Optionally overlays your callsign on the video for identification. 12 | - **Experimental Parameters**: Easily adjust transmission parameters for experimentation. 13 | 14 | ## Command-Line Flags 15 | 16 | You can experiment with the following flags to customize your transmission: 17 | 18 | - `-freq`: **Transmit frequency in MHz** 19 | *Type:* `float` 20 | *Default:* `427.25` 21 | *Example:* `-freq 439.25` 22 | *Description:* The center frequency for HackRF transmission. Common amateur TV frequencies include 427.25, 439.25, etc. 23 | 24 | - `-bw`: **Channel bandwidth in MHz** 25 | *Type:* `float` 26 | *Default:* `8.0` 27 | *Example:* `-bw 6` 28 | *Description:* Sets both the sample rate and the channel width for NTSC. Standard NTSC channels are 6 MHz wide (use `-bw 6`). You may experiment with other values for signal shape and robustness. 29 | 30 | - `-gain`: **TX VGA gain (0-47)** 31 | *Type:* `int` 32 | *Default:* `40` 33 | *Example:* `-gain 47` 34 | *Description:* Controls the HackRF transmit amplifier gain. Higher numbers mean more output power, but can cause distortion if set too high. 35 | 36 | - `-device`: **Video device name or index** 37 | *Type:* `string` 38 | *Default:* `""` (auto-detects platform default) 39 | *Example (Linux):* `-device /dev/video0` 40 | *Example (macOS):* `-device 0` 41 | *Example (Windows):* `-device "Integrated Webcam"` 42 | *Description:* Selects the webcam to use. See below for how to list available devices. 43 | 44 | - `-callsign`: **Callsign to overlay on the video** 45 | *Type:* `string` 46 | *Default:* `"NOCALL"` 47 | *Example:* `-callsign N7XYZ` 48 | *Description:* Overlays your callsign at the bottom left of the transmitted video for identification. 49 | 50 | ## Example Usage 51 | 52 | Linux: 53 | ```sh 54 | ./HackTVLive -freq 427.25 -bw 6 -gain 40 -device /dev/video0 -callsign N0CALL 55 | ``` 56 | 57 | ## Experimentation 58 | 59 | HackTVLive is designed for experimentation: 60 | - Try different `-freq` and `-bw` values to match local channel plans or test signal robustness. 61 | - Adjust `-gain` for best power and minimum distortion. 62 | - Change `-callsign` for identification or fun overlays. 63 | - Use different video devices by specifying the `-device` flag. 64 | 65 | If you omit `-device`, the application will try to use a reasonable default for your platform. 66 | 67 | ## How It Works 68 | 69 | 1. **Video Capture**: FFmpeg grabs raw RGB video frames from your webcam and optionally overlays your callsign. 70 | 2. **NTSC Generation**: The Go code converts the video frames into NTSC signal format, including all sync pulses and color encoding. 71 | 3. **RF Transmission**: The NTSC signal is sent to the HackRF, which transmits it at the specified frequency and bandwidth. 72 | 73 | ## Finding Your Video Device 74 | 75 | To list available webcams: 76 | - **Linux**: `v4l2-ctl --list-devices` or look in `/dev/video*` 77 | - **macOS**: Devices are usually indexed (0, 1, etc.) 78 | - **Windows**: Use the full device name as shown in device manager or FFmpeg logs. 79 | 80 | ## Safety & Legal Notice 81 | 82 | **Transmitting on TV frequencies may be illegal in your country without a license.** 83 | HackTVLive is intended for educational and experimental use only. Always operate within your local laws and regulations. 84 | 85 | ## Troubleshooting 86 | 87 | - If the HackRF TX LED does not light up or the program exits immediately, check your device permissions and wiring. 88 | - Stopping the application or transmission (Ctrl+C or "Stop Transmission" in GUI) will also turn off the HackRF TX LED. 89 | - For best results, use a direct USB connection and avoid running other heavy processes while transmitting. 90 | 91 | ## Contributions 92 | 93 | Pull requests and issues are welcome! See [`go-hackrf`](https://github.com/samuel/go-hackrf) for hardware support. 94 | 95 | --- -------------------------------------------------------------------------------- /hacktvlive/video/ntsc.go: -------------------------------------------------------------------------------- 1 | package video 2 | 3 | import ( 4 | "math" 5 | "sync" 6 | ) 7 | 8 | // NTSC struct holds all constants and state for generating the NTSC signal. 9 | type NTSC struct { 10 | sampleRate float64 11 | frameRate float64 12 | linesPerFrame int 13 | activeVideoLines int 14 | lineSamples int 15 | hSyncSamples int 16 | vSyncPulseSamples int 17 | eqPulseSamples int 18 | burstStartSamples int 19 | burstEndSamples int 20 | activeStartSamples int 21 | activeSamples int 22 | fsc float64 23 | levelSync float64 24 | levelBlanking float64 25 | levelBlack float64 26 | levelWhite float64 27 | burstAmplitude float64 28 | rawFrameBuffer []byte 29 | rawFrameMutex sync.RWMutex 30 | ntscFrameBuffer []float64 31 | ntscFrameMutex sync.RWMutex 32 | } 33 | 34 | // NewNTSC creates a new NTSC standard object. 35 | func NewNTSC(sampleRate float64) *NTSC { 36 | n := &NTSC{ 37 | sampleRate: sampleRate, 38 | frameRate: 30000.0 / 1001.0, 39 | linesPerFrame: 525, 40 | activeVideoLines: 480, 41 | fsc: 3579545.4545, 42 | levelSync: -40.0, 43 | levelBlanking: 0.0, 44 | levelBlack: 7.5, 45 | levelWhite: 100.0, 46 | burstAmplitude: 20.0, 47 | } 48 | lineDuration := 1.0 / (n.frameRate * float64(n.linesPerFrame)) 49 | n.lineSamples = int(lineDuration * n.sampleRate) 50 | n.hSyncSamples = int(4.7e-6 * n.sampleRate) 51 | n.vSyncPulseSamples = int(27.1e-6 * n.sampleRate) 52 | n.eqPulseSamples = int(2.3e-6 * n.sampleRate) 53 | n.burstStartSamples = int(5.6e-6 * n.sampleRate) 54 | n.burstEndSamples = n.burstStartSamples + int(2.5e-6*n.sampleRate) 55 | n.activeStartSamples = int(10.7e-6 * n.sampleRate) 56 | n.activeSamples = int(52.6e-6 * n.sampleRate) 57 | n.rawFrameBuffer = make([]byte, FrameWidth*FrameHeight*3) 58 | n.ntscFrameBuffer = make([]float64, n.lineSamples*n.linesPerFrame) 59 | return n 60 | } 61 | 62 | // GenerateFullFrame creates a complete NTSC frame from the raw pixel data. 63 | func (n *NTSC) GenerateFullFrame() { 64 | var subcarrierPhase float64 = 0.0 65 | phaseIncrement := 2.0 * math.Pi * n.fsc / n.sampleRate 66 | for line := 1; line <= n.linesPerFrame; line++ { 67 | lineBuffer := n.generateLumaLine(line) 68 | isVBI := (line >= 1 && line <= 21) || (line >= 264 && line <= 284) 69 | if !isVBI { 70 | for s := 0; s < n.lineSamples; s++ { 71 | if s >= n.burstStartSamples && s < n.burstEndSamples { 72 | lineBuffer[s] += n.burstAmplitude * math.Sin(subcarrierPhase+math.Pi) 73 | } else if s >= n.activeStartSamples && s < (n.activeStartSamples+n.activeSamples) { 74 | _, i, q := n.getPixelYIQ(line, s) 75 | lineBuffer[s] += i*math.Cos(subcarrierPhase) + q*math.Sin(subcarrierPhase) 76 | } 77 | subcarrierPhase += phaseIncrement 78 | } 79 | } else { 80 | subcarrierPhase += phaseIncrement * float64(n.lineSamples) 81 | } 82 | offset := (line - 1) * n.lineSamples 83 | copy(n.ntscFrameBuffer[offset:], lineBuffer) 84 | } 85 | } 86 | 87 | func (n *NTSC) getPixelYIQ(currentLine, sampleInLine int) (y, i, q float64) { 88 | videoLine := 0 89 | if currentLine >= 22 && currentLine <= 263 { 90 | videoLine = (currentLine - 22) * 2 91 | } else if currentLine >= 285 && currentLine <= 525 { 92 | videoLine = (currentLine - 285) * 2 + 1 93 | } 94 | sampleInActiveVideo := sampleInLine - n.activeStartSamples 95 | pixelX := int(float64(sampleInActiveVideo) / float64(n.activeSamples) * FrameWidth) 96 | if videoLine < 0 || videoLine >= FrameHeight || pixelX < 0 || pixelX >= FrameWidth { 97 | return n.levelBlack, 0, 0 98 | } 99 | 100 | n.rawFrameMutex.RLock() 101 | pixelIndex := (videoLine*FrameWidth + pixelX) * 3 102 | r := float64(n.rawFrameBuffer[pixelIndex]) 103 | g := float64(n.rawFrameBuffer[pixelIndex+1]) 104 | b := float64(n.rawFrameBuffer[pixelIndex+2]) 105 | n.rawFrameMutex.RUnlock() 106 | 107 | yVal := 0.299*r + 0.587*g + 0.114*b 108 | iVal := 0.596*r - 0.274*g - 0.322*b 109 | qVal := 0.211*r - 0.523*g + 0.312*b 110 | y = n.levelBlack + yVal/255.0*(n.levelWhite-n.levelBlack) 111 | i = iVal / 255.0 * (n.levelWhite - n.levelBlack) 112 | q = qVal / 255.0 * (n.levelWhite - n.levelBlack) 113 | return 114 | } 115 | 116 | func (n *NTSC) generateLumaLine(currentLine int) []float64 { 117 | lineBuffer := make([]float64, n.lineSamples) 118 | for s := 0; s < n.lineSamples; s++ { 119 | lineBuffer[s] = n.levelBlanking 120 | } 121 | lineInField := currentLine 122 | if currentLine > n.linesPerFrame/2 { 123 | lineInField = currentLine - (n.linesPerFrame / 2) 124 | } 125 | isVBI := lineInField <= 21 126 | halfLine := n.lineSamples / 2 127 | switch { 128 | case lineInField >= 1 && lineInField <= 3, lineInField >= 7 && lineInField <= 9: 129 | for s := 0; s < n.eqPulseSamples; s++ { 130 | lineBuffer[s], lineBuffer[halfLine+s] = n.levelSync, n.levelSync 131 | } 132 | return lineBuffer 133 | case lineInField >= 4 && lineInField <= 6: 134 | for s := 0; s < n.vSyncPulseSamples; s++ { 135 | lineBuffer[s], lineBuffer[halfLine+s] = n.levelSync, n.levelSync 136 | } 137 | return lineBuffer 138 | } 139 | for s := 0; s < n.hSyncSamples; s++ { 140 | lineBuffer[s] = n.levelSync 141 | } 142 | if !isVBI { 143 | for s := 0; s < n.activeSamples; s++ { 144 | y, _, _ := n.getPixelYIQ(currentLine, n.activeStartSamples+s) 145 | lineBuffer[n.activeStartSamples+s] = y 146 | } 147 | } 148 | return lineBuffer 149 | } 150 | 151 | func (n *NTSC) IreToAmplitude(ire float64) float64 { 152 | return ((ire - 100.0) / -140.0) * (1.0 - 0.125) + 0.125 153 | } 154 | 155 | func (n *NTSC) FillTestPattern() { 156 | FillColorBars(n.rawFrameBuffer) 157 | } 158 | 159 | func (n *NTSC) LockFrame() { n.ntscFrameMutex.Lock() } 160 | func (n *NTSC) UnlockFrame() { n.ntscFrameMutex.Unlock() } 161 | func (n *NTSC) RLockFrame() { n.ntscFrameMutex.RLock() } 162 | func (n *NTSC) RUnlockFrame() { n.ntscFrameMutex.RUnlock() } 163 | func (n *NTSC) LockRaw() { n.rawFrameMutex.Lock() } 164 | func (n *NTSC) UnlockRaw() { n.rawFrameMutex.Unlock() } 165 | func (n *NTSC) FrameBuffer() []float64 { return n.ntscFrameBuffer } 166 | func (n *NTSC) RawFrameBuffer() []byte { return n.rawFrameBuffer } -------------------------------------------------------------------------------- /hacktvlive/video/pal.go: -------------------------------------------------------------------------------- 1 | package video 2 | 3 | import ( 4 | "math" 5 | "sync" 6 | ) 7 | 8 | // PAL struct holds all constants and state for generating the PAL signal. 9 | type PAL struct { 10 | sampleRate float64 11 | frameRate float64 12 | linesPerFrame int 13 | activeVideoLines int 14 | lineSamples int 15 | hSyncSamples int 16 | vSyncPulseSamples int 17 | eqPulseSamples int 18 | burstStartSamples int 19 | burstEndSamples int 20 | activeStartSamples int 21 | activeSamples int 22 | fsc float64 23 | levelSync float64 24 | levelBlanking float64 25 | levelBlack float64 26 | levelWhite float64 27 | burstAmplitude float64 28 | rawFrameBuffer []byte 29 | rawFrameMutex sync.RWMutex 30 | palFrameBuffer []float64 31 | palFrameMutex sync.RWMutex 32 | } 33 | 34 | // NewPAL creates a new PAL standard object. 35 | func NewPAL(sampleRate float64) *PAL { 36 | p := &PAL{ 37 | sampleRate: sampleRate, 38 | frameRate: 25.0, 39 | linesPerFrame: 625, 40 | activeVideoLines: 576, 41 | fsc: 4433618.75, 42 | levelSync: -40.0, 43 | levelBlanking: 0.0, 44 | levelBlack: 0.0, 45 | levelWhite: 100.0, 46 | burstAmplitude: 20.0, 47 | } 48 | lineDuration := 1.0 / (p.frameRate * float64(p.linesPerFrame)) 49 | p.lineSamples = int(lineDuration * p.sampleRate) 50 | p.hSyncSamples = int(4.7e-6 * p.sampleRate) 51 | p.vSyncPulseSamples = int(27.3e-6 * p.sampleRate) 52 | p.eqPulseSamples = int(2.35e-6 * p.sampleRate) 53 | p.burstStartSamples = int(5.6e-6 * p.sampleRate) 54 | p.burstEndSamples = p.burstStartSamples + int(2.25e-6*p.sampleRate) 55 | p.activeStartSamples = int(10.5e-6 * p.sampleRate) 56 | p.activeSamples = int(52.0e-6 * p.sampleRate) 57 | p.rawFrameBuffer = make([]byte, FrameWidth*FrameHeight*3) 58 | p.palFrameBuffer = make([]float64, p.lineSamples*p.linesPerFrame) 59 | return p 60 | } 61 | 62 | // GenerateFullFrame creates a complete PAL frame from the raw pixel data. 63 | func (p *PAL) GenerateFullFrame() { 64 | var subcarrierPhase float64 = 0.0 65 | phaseIncrement := 2.0 * math.Pi * p.fsc / p.sampleRate 66 | vToggle := 1.0 67 | 68 | for line := 1; line <= p.linesPerFrame; line++ { 69 | lineBuffer := p.generateLumaLine(line) 70 | isVBI := (line >= 624 || line <= 23) || (line >= 311 && line <= 336) 71 | 72 | if !isVBI { 73 | p.rawFrameMutex.RLock() 74 | for s := 0; s < p.lineSamples; s++ { 75 | burstPhaseOffset := 135.0 * (math.Pi / 180.0) 76 | if line%2 == 0 { 77 | burstPhaseOffset = -135.0 * (math.Pi / 180.0) 78 | } 79 | 80 | if s >= p.burstStartSamples && s < p.burstEndSamples { 81 | lineBuffer[s] += p.burstAmplitude * math.Sin(subcarrierPhase+burstPhaseOffset) 82 | } else if s >= p.activeStartSamples && s < (p.activeStartSamples+p.activeSamples) { 83 | _, u, v := p.getPixelYUV(line, s) 84 | lineBuffer[s] += u*math.Sin(subcarrierPhase) + (v*vToggle)*math.Cos(subcarrierPhase) 85 | } 86 | subcarrierPhase += phaseIncrement 87 | } 88 | p.rawFrameMutex.RUnlock() 89 | } else { 90 | subcarrierPhase += phaseIncrement * float64(p.lineSamples) 91 | } 92 | 93 | offset := (line - 1) * p.lineSamples 94 | copy(p.palFrameBuffer[offset:], lineBuffer) 95 | vToggle *= -1.0 96 | } 97 | } 98 | 99 | func (p *PAL) getPixelYUV(currentLine, sampleInLine int) (y, u, v float64) { 100 | var videoLine int 101 | if currentLine >= 24 && currentLine <= 310 { 102 | videoLine = currentLine - 24 103 | } else if currentLine >= 337 && currentLine <= 623 { 104 | videoLine = currentLine - 337 + p.activeVideoLines/2 105 | } else { 106 | return p.levelBlack, 0, 0 107 | } 108 | 109 | sampleInActiveVideo := sampleInLine - p.activeStartSamples 110 | pixelX := int(float64(sampleInActiveVideo) / float64(p.activeSamples) * FrameWidth) 111 | if videoLine < 0 || videoLine >= FrameHeight || pixelX < 0 || pixelX >= FrameWidth { 112 | return p.levelBlack, 0, 0 113 | } 114 | 115 | pixelIndex := (videoLine*FrameWidth + pixelX) * 3 116 | r := float64(p.rawFrameBuffer[pixelIndex]) 117 | g := float64(p.rawFrameBuffer[pixelIndex+1]) 118 | b := float64(p.rawFrameBuffer[pixelIndex+2]) 119 | 120 | yVal := 0.299*r + 0.587*g + 0.114*b 121 | uVal := -0.147*r - 0.289*g + 0.436*b 122 | vVal := 0.615*r - 0.515*g - 0.100*b 123 | y = p.levelBlack + yVal/255.0*(p.levelWhite-p.levelBlack) 124 | u = uVal / 255.0 * (p.levelWhite - p.levelBlack) * 0.493 125 | v = vVal / 255.0 * (p.levelWhite - p.levelBlack) * 0.877 126 | return 127 | } 128 | 129 | func (p *PAL) generateLumaLine(currentLine int) []float64 { 130 | lineBuffer := make([]float64, p.lineSamples) 131 | for s := 0; s < p.lineSamples; s++ { 132 | lineBuffer[s] = p.levelBlanking 133 | } 134 | 135 | for s := 0; s < p.hSyncSamples; s++ { 136 | lineBuffer[s] = p.levelSync 137 | } 138 | 139 | if (currentLine >= 1 && currentLine <= 2) || (currentLine >= 313 && currentLine <= 314) { 140 | for s := p.lineSamples / 2; s < p.lineSamples/2+p.hSyncSamples; s++ { 141 | lineBuffer[s] = p.levelSync 142 | } 143 | } 144 | 145 | isVBI := (currentLine >= 624 || currentLine <= 23) || (currentLine >= 311 && currentLine <= 336) 146 | if !isVBI { 147 | p.rawFrameMutex.RLock() 148 | for s := 0; s < p.activeSamples; s++ { 149 | y, _, _ := p.getPixelYUV(currentLine, p.activeStartSamples+s) 150 | lineBuffer[p.activeStartSamples+s] = y 151 | } 152 | p.rawFrameMutex.RUnlock() 153 | } 154 | return lineBuffer 155 | } 156 | 157 | func (p *PAL) IreToAmplitude(ire float64) float64 { 158 | return ((ire - 100.0) / -140.0) * (1.0 - 0.125) + 0.125 159 | } 160 | 161 | func (p *PAL) FillTestPattern() { 162 | FillColorBars(p.rawFrameBuffer) 163 | } 164 | 165 | func (p *PAL) LockFrame() { p.palFrameMutex.Lock() } 166 | func (p *PAL) UnlockFrame() { p.palFrameMutex.Unlock() } 167 | func (p *PAL) RLockFrame() { p.palFrameMutex.RLock() } 168 | func (p *PAL) RUnlockFrame() { p.palFrameMutex.RUnlock() } 169 | func (p *PAL) LockRaw() { p.rawFrameMutex.Lock() } 170 | func (p *PAL) UnlockRaw() { p.rawFrameMutex.Unlock() } 171 | func (p *PAL) FrameBuffer() []float64 { return p.palFrameBuffer } 172 | func (p *PAL) RawFrameBuffer() []byte { return p.rawFrameBuffer } -------------------------------------------------------------------------------- /rtl_tv/decoder/decoder.go: -------------------------------------------------------------------------------- 1 | package decoder 2 | 3 | import ( 4 | "log" 5 | "math" 6 | "sync" 7 | "rtltv/config" // Import our config package 8 | ) 9 | 10 | // VSyncState defines the states for the vertical sync detection state machine. 11 | type VSyncState int 12 | 13 | const ( 14 | // StateSearchVSync is the default state, looking for a V-sync sequence to start. 15 | StateSearchVSync VSyncState = iota 16 | // StateInVSync is active when the decoder has detected one or more V-sync 17 | // serration pulses and is expecting more to follow. 18 | StateInVSync 19 | ) 20 | 21 | // Decoder processes I/Q samples into video frames. 22 | type Decoder struct { 23 | frameBuffer []byte 24 | displayBuffer []byte 25 | frameMutex sync.Mutex 26 | 27 | // Decoder state 28 | x, y int 29 | pixelCounter int 30 | smoothedMax float64 31 | smoothedMin float64 32 | hSyncPulseWidth int 33 | syncSearchWindow int 34 | lineStartActiveVideo int 35 | lineEndActiveVideo int 36 | 37 | // --- Fields for advanced sync --- 38 | sampleRate float64 // SDR sample rate, needed for PLL resets 39 | initialSamplesPerLine float64 // The ideal number of samples per line, for reference 40 | samplesPerLine float64 // The PLL's current estimate of samples per line 41 | hSyncErrorAccumulator float64 // The integrated error for the H-sync PLL (the "I" in PI) 42 | vSyncState VSyncState // Current state of the V-sync state machine 43 | vSyncSerrationCounter int // Counts consecutive V-sync serration pulses 44 | } 45 | 46 | // New creates and initializes a new Decoder. 47 | func New(sampleRate float64) *Decoder { 48 | d := &Decoder{} 49 | d.sampleRate = sampleRate 50 | 51 | lineDuration := 1.0 / (config.FrameRate * float64(config.LinesPerFrame)) 52 | d.initialSamplesPerLine = lineDuration * sampleRate 53 | d.samplesPerLine = d.initialSamplesPerLine // Start with the ideal value 54 | 55 | d.hSyncPulseWidth = int(config.HsyncDurationMicroseconds * 1e-6 * sampleRate) 56 | d.syncSearchWindow = int(d.samplesPerLine * 0.20) // Search in first 20% of line 57 | 58 | activeVideoStartUs := config.HsyncDurationMicroseconds + config.FrontPorchMicroseconds 59 | d.lineStartActiveVideo = int(activeVideoStartUs * 1e-6 * sampleRate) 60 | d.lineEndActiveVideo = d.lineStartActiveVideo + int(config.ActiveVideoMicroseconds*1e-6*sampleRate) 61 | 62 | d.frameBuffer = make([]byte, config.FrameWidth*config.FrameHeight*3) 63 | d.displayBuffer = make([]byte, config.FrameWidth*config.FrameHeight*3) 64 | 65 | d.smoothedMax = 128.0 // Initial AGC values 66 | d.smoothedMin = 0.0 67 | 68 | // Initialize sync state 69 | d.vSyncState = StateSearchVSync 70 | d.hSyncErrorAccumulator = 0.0 71 | 72 | log.Printf("Decoder initialized: %.1f samples/line, hSync width ~%d samples", d.samplesPerLine, d.hSyncPulseWidth) 73 | log.Printf("Active Video: from sample %d to %d", d.lineStartActiveVideo, d.lineEndActiveVideo) 74 | 75 | return d 76 | } 77 | 78 | // ProcessIQ demodulates and decodes a chunk of I/Q data. 79 | func (d *Decoder) ProcessIQ(iq []byte) { 80 | // AM Demodulation & AGC update 81 | amSignal := make([]float64, len(iq)/2) 82 | localMax, localMin := 0.0, 255.0 83 | for i := range amSignal { 84 | iqI := float64(int(iq[i*2]) - 127) 85 | iqQ := float64(int(iq[i*2+1]) - 127) 86 | mag := math.Sqrt(iqI*iqI + iqQ*iqQ) 87 | amSignal[i] = mag 88 | if mag > localMax { 89 | localMax = mag 90 | } 91 | if mag < localMin { 92 | localMin = mag 93 | } 94 | } 95 | d.smoothedMax = d.smoothedMax*0.95 + localMax*0.05 96 | d.smoothedMin = d.smoothedMin*0.95 + localMin*0.05 97 | 98 | // Define signal levels based on smoothed AGC 99 | syncTipLevel := d.smoothedMax 100 | peakWhiteLevel := d.smoothedMin 101 | syncThreshold := syncTipLevel * 0.75 102 | blackLevel := syncTipLevel * 0.65 103 | levelCoeff := 255.0 / (blackLevel - peakWhiteLevel + 1e-6) 104 | 105 | for _, mag := range amSignal { 106 | // --- Sync Detection --- 107 | if d.x < d.syncSearchWindow { 108 | if mag >= syncThreshold { 109 | d.pixelCounter++ 110 | } else { 111 | if d.pixelCounter > d.hSyncPulseWidth/2 { // Found a pulse 112 | 113 | // --- V-Sync State Machine & H-Sync PLL --- 114 | isLongPulse := d.pixelCounter > d.hSyncPulseWidth*2 115 | 116 | switch d.vSyncState { 117 | case StateSearchVSync: 118 | // Look for the start of a V-sync sequence near the frame end 119 | if d.y > (config.FrameHeight-20) && isLongPulse { 120 | d.vSyncState = StateInVSync 121 | d.vSyncSerrationCounter = 1 122 | } else { 123 | // --- H-Sync PLL Logic --- 124 | // 1. Calculate error: how far was the pulse from where we expected it? 125 | error := float64(d.x) - d.samplesPerLine 126 | 127 | // 2. PI Controller: adjust our line length estimate 128 | // Reduced gains for stability 129 | const Kp = 0.002 // Proportional gain: immediate reaction to the error 130 | const Ki = 0.0001 // Integral gain: corrects for long-term drift 131 | d.hSyncErrorAccumulator += error * Ki 132 | correction := (error * Kp) + d.hSyncErrorAccumulator 133 | 134 | // *** THIS IS THE FIX: Change from -= to += *** 135 | // If pulse is late (error > 0), we need to INCREASE our line length estimate. 136 | d.samplesPerLine += correction 137 | 138 | // 3. Clamp the adjustment to prevent wild swings from noise 139 | if d.samplesPerLine < d.initialSamplesPerLine*0.95 { 140 | d.samplesPerLine = d.initialSamplesPerLine * 0.95 141 | } 142 | if d.samplesPerLine > d.initialSamplesPerLine*1.05 { 143 | d.samplesPerLine = d.initialSamplesPerLine * 1.05 144 | } 145 | 146 | // 4. Advance to next line 147 | d.y++ 148 | d.x = 0 149 | } 150 | 151 | case StateInVSync: 152 | if isLongPulse && d.vSyncSerrationCounter < 6 { 153 | d.vSyncSerrationCounter++ // It's another pulse in the sequence 154 | } else { 155 | // The sequence ended. Check if it was a valid V-sync. 156 | if d.vSyncSerrationCounter >= 3 { 157 | // *** V-SYNC CONFIRMED *** 158 | d.y = 0 159 | d.x = 0 160 | // Reset the H-sync PLL to its ideal state 161 | d.samplesPerLine = d.initialSamplesPerLine 162 | d.hSyncErrorAccumulator = 0.0 163 | } 164 | // If not, it was a false alarm. The next pulse will be handled as H-sync. 165 | d.vSyncState = StateSearchVSync 166 | d.vSyncSerrationCounter = 0 167 | } 168 | } 169 | 170 | d.pixelCounter = 0 171 | continue // CRUCIAL: Skip to next sample after handling sync 172 | } 173 | d.pixelCounter = 0 174 | } 175 | } 176 | 177 | // --- Video Drawing --- 178 | if d.y >= 0 && d.y < config.FrameHeight && d.x >= d.lineStartActiveVideo && d.x < d.lineEndActiveVideo { 179 | samplesInActiveVideo := float64(d.lineEndActiveVideo - d.lineStartActiveVideo) 180 | relativeSample := float64(d.x - d.lineStartActiveVideo) 181 | pixelX := int(relativeSample / samplesInActiveVideo * float64(config.FrameWidth)) 182 | 183 | if pixelX >= 0 && pixelX < config.FrameWidth { 184 | brightness := (blackLevel - mag) * levelCoeff 185 | if brightness < 0 { 186 | brightness = 0 187 | } 188 | if brightness > 255 { 189 | brightness = 255 190 | } 191 | pixelValue := byte(brightness) 192 | 193 | pixelIndex := (d.y*config.FrameWidth + pixelX) * 3 194 | d.frameBuffer[pixelIndex] = pixelValue 195 | d.frameBuffer[pixelIndex+1] = pixelValue 196 | d.frameBuffer[pixelIndex+2] = pixelValue 197 | } 198 | } 199 | 200 | d.x++ 201 | 202 | // --- Flywheel & Frame Completion --- 203 | if d.x >= int(d.samplesPerLine) { 204 | d.x, d.y = 0, d.y+1 // Flywheel for coasting through complete signal loss 205 | } 206 | if d.y >= config.FrameHeight { 207 | d.y = 0 208 | d.frameMutex.Lock() 209 | copy(d.displayBuffer, d.frameBuffer) 210 | d.frameMutex.Unlock() 211 | } 212 | } 213 | } 214 | 215 | // GetDisplayFrame returns a thread-safe copy of the latest completed frame. 216 | func (d *Decoder) GetDisplayFrame() []byte { 217 | d.frameMutex.Lock() 218 | defer d.frameMutex.Unlock() 219 | frameCopy := make([]byte, len(d.displayBuffer)) 220 | copy(frameCopy, d.displayBuffer) 221 | return frameCopy 222 | } --------------------------------------------------------------------------------