diff --git a/.gitignore b/.gitignore index 611c8f3..c392ec9 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,5 @@ -references/ \ No newline at end of file +bin/ +vendor/ +*.exe +out.wav +ref.wav diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..ca5f9b0 --- /dev/null +++ b/LICENSE @@ -0,0 +1,23 @@ +Copyright (c) 2026, marcell +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/Makefile b/Makefile index 8475948..6faed72 100644 --- a/Makefile +++ b/Makefile @@ -17,8 +17,10 @@ LDFLAGS := -X 'github.com/sqpp/flex-golang.Version=$(VERSION)' \ -X 'github.com/sqpp/flex-golang.BuildGoVer=$(GO_VERSION)' BINARY_NAME=flex-decode +ENCODE_NAME=flex-encode ifeq ($(OS),Windows_NT) BINARY_NAME=flex-decode.exe + ENCODE_NAME=flex-encode.exe endif # Default target @@ -31,12 +33,14 @@ build: @echo "Building FLEX-GO v$(VERSION)..." @mkdir -p bin go build -ldflags "$(LDFLAGS)" -o bin/$(BINARY_NAME) ./cmd/flex-decode + go build -ldflags "$(LDFLAGS)" -o bin/$(ENCODE_NAME) ./cmd/flex-encode @echo "Build complete!" # Install tools .PHONY: install install: go install -ldflags "$(LDFLAGS)" ./cmd/flex-decode + go install -ldflags "$(LDFLAGS)" ./cmd/flex-encode # Test .PHONY: test diff --git a/README.md b/README.md index 9f13fce..7f8a3e1 100644 --- a/README.md +++ b/README.md @@ -1,22 +1,25 @@ # FLEX-GO v1.0.0 -A complete Go implementation of the Motorola FLEX pager protocol decoder. Inspired by and compatible with the [`pocsag-golang`](https://github.com/sqpp/pocsag-golang) architecture. +A complete Go implementation of the Motorola FLEX pager protocol — encoder, decoder, and everything in between. CLI layout mirrors [`pocsag-golang`](https://github.com/sqpp/pocsag-golang). ## What it can do -- Decode FLEX frames directly from standard WAV audio files (1600/2, 3200/2, 3200/4, 6400/4 modes) -- Handle Frame Information Words (FIW) and Block Information Words (BIW) natively -- Full BCH(31,21) and Motorola 4-bit checksum validation and 2-bit error correction -- Extract Capcode addresses and Alphanumeric/Numeric message payloads -- Pure native Go DPLL demodulator with phase-locking — no CGO, `sox`, or external C tools required +- Encode FLEX messages as WAV audio (1600/2, 1600/4, 3200/2, 3200/4, 6400/4) +- Decode those WAV files back to text with a native PLL demodulator +- BCH(31,21) codewords with Motorola 4-bit checksum validation +- Capcode addressing with alphanumeric, numeric, and tone page types +- Frame Information Words (FIW) and Block Information Words (BIW) - JSON output for scripting and API integration -- Drop-in CLI replacement perfectly mirroring `pocsag-decode`'s payload format +- Pure Go — no CGO or external demod tools required --- ## Installation ```bash +# Encoder +go install github.com/sqpp/flex-golang/cmd/flex-encode@latest + # Decoder go install github.com/sqpp/flex-golang/cmd/flex-decode@latest ``` @@ -26,48 +29,113 @@ Or build from source: ```bash git clone https://github.com/sqpp/flex-golang.git cd flex-golang -make build -# Binary lands in: bin/flex-decode +make build # Linux/macOS +make.bat build # Windows +# Binaries land in: bin/flex-encode, bin/flex-decode +``` + +--- + +## Encoder (`flex-encode`) + +Generate a FLEX message as a WAV file. + +**Required (unless using `--reference` or `--messages`):** +- `-a` / `--address` — pager address / capcode (1..2097151) +- `-m` / `--message` — the message text + +**Optional:** +- `-o` / `--output` — output WAV file (default: `output.wav`) +- `-f` / `--function` — `2` = tone, `3` = numeric, `5` = alphanumeric (default: `5`) +- `--mode` — FLEX mode: `1600/2`, `1600/4`, `3200/2`, `3200/4`, `6400/4` (default: `1600/2`) +- `--cycle` / `--frame` — FIW cycle and frame fields (optional) +- `--reference` — encode the known-good PDW reference page from `tests/test_1600.wav` +- `--messages` / `--messages-file` — JSON file with a list of message objects +- `-j` / `--json` — print result as JSON instead of human-readable text +- `-v` / `--version` — show version info + +**Examples:** + +```bash +# Basic message +flex-encode -a 1913 -m "HELLO WORLD" -o message.wav + +# Long flags +flex-encode --address 1913 --message "HELLO WORLD" --output message.wav + +# Reference page (cap 1913, cycle 3, frame 111) +flex-encode -o reference.wav --reference + +# 3200/4 mode +flex-encode -a 1913 -m "FAST MSG" --mode 3200/4 -o fast.wav + +# JSON output (great for scripts) +flex-encode -a 1913 -m "TEST" -o test.wav --json +``` + +**Normal output:** +``` +✅ Generated message.wav + Address: 1913, Function: 5, Baud: 1600, Message: HELLO WORLD + Size: 220104 bytes, Duration: 2.50 s + +Decode: flex-decode -i message.wav +``` + +**JSON output:** +```json +{ + "success": true, + "output": "message.wav", + "address": 1913, + "function": 5, + "message": "HELLO WORLD", + "baud": 1600, + "type": "alphanumeric", + "size": 220104, + "duration_s": 2.495 +} ``` --- ## Decoder (`flex-decode`) -Decode a WAV file containing 2-FSK or 4-FSK FLEX audio into messages. +Decode a FLEX WAV back to text. **Options:** - `-i` / `--input` — input WAV file (required) -- `-j` / `--json` — print result as JSON instead of human-readable text +- `--no-tones` — filter out tone-only messages +- `-j` / `--json` — JSON output - `-v` / `--version` — show version info **Examples:** ```bash -flex-decode -i Flex-1600.wav -flex-decode -i Flex-1600.wav --json +flex-decode -i message.wav +flex-decode -i message.wav --json flex-decode --version ``` **Normal output:** ``` FLEX-1600/2: Decoded messages: -Address: 1913 Function: 5 ALPHA Message: NEW JOB: BED: B6 ROOM 19 BED 02 +Address: 0001913 Function: 5 ALPHA Message: HELLO WORLD ``` **JSON output:** ```json { + "success": true, "baud": 1600, "messages": [ { "address": 1913, "function": 5, - "message": "NEW JOB: BED: B6 ROOM 19 BED 02", + "message": "HELLO WORLD", "type": "alphanumeric" } - ], - "success": true + ] } ``` @@ -75,33 +143,57 @@ Address: 1913 Function: 5 ALPHA Message: NEW JOB: BED: B6 ROOM 19 BED 02 ## Using as a Go library +**Encode and write a WAV:** ```go -package main - -import ( - "fmt" - "os" - flex "github.com/sqpp/flex-golang" -) - -func main() { - wavData, _ := os.ReadFile("Flex-1600.wav") - - // Decode audio directly into messages via the native DPLL - messages, _ := flex.DecodeFromAudio(wavData) - - for _, msg := range messages { - fmt.Printf("Message to %07d: %s\n", msg.Capcode, msg.Text) - } +import flex "github.com/sqpp/flex-golang" + +msg := flex.EncodeMessage{Capcode: 1913, Type: "alpha", Text: "HELLO WORLD"} +wav, _, _, err := flex.EncodeToWAVBytes([]flex.EncodeMessage{msg}, flex.Mode1600_2, 0, 0) +os.WriteFile("output.wav", wav, 0644) +``` + +**Decode a WAV:** +```go +wavData, _ := os.ReadFile("message.wav") +messages, err := flex.DecodeFromAudio(wavData) +for _, msg := range messages { + fmt.Printf("Message to %07d: %s\n", msg.Capcode, msg.Text) } ``` +**Key functions:** + +| Function | Description | +|----------|-------------| +| `EncodeToWAVBytes(messages, mode, cycle, frame)` | Encode messages to WAV bytes | +| `EncodeToWAVFile(messages, path, mode, cycle, frame)` | Encode and write a WAV file | +| `DecodeFromAudio(wavData)` | Decode a WAV into messages | +| `DemodulateRawFrames(wavData)` | Demodulate to raw phase codewords | +| `EncodeModeNames()` | List supported encoder modes | +| `EncodeModeBitRate(mode)` | On-air bit rate for a mode name | + --- -## Roadmap +## Testing -- **Encoder (`flex`)**: Generate FLEX WAV signals (Pending) -- **Burst Encoder (`flex-burst`)**: Pack multiple messages into single frames (Pending) +```bash +go test -v ./... +``` + +Tests live in the `tests/` directory. Reference captures (`test_1600.wav`, `test_3200.wav`, `test_6400.wav`) are included for roundtrip and decode validation. + +--- + +## About addresses + +FLEX addresses are capcodes in the range 1..2097151. The encoder and decoder use the full capcode value. Function codes follow the FLEX vector type field (`2` = tone, `3` = numeric, `5` = alphanumeric). + +--- + +## Credits + +Part of [PagerCast](https://pagercast.com). Decoder logic references multimon-ng `demod_flex.c` and PDW `Flex.cpp`. ## License -Built natively for pure-Go portability. Decoding architecture originally inspired by PDW and multimon-ng. + +BSD-2-Clause — see [LICENSE](LICENSE). diff --git a/audio.go b/audio.go deleted file mode 100644 index 2ad97c7..0000000 --- a/audio.go +++ /dev/null @@ -1,135 +0,0 @@ -package flex - -import ( - "bytes" - "encoding/binary" - "math" -) - -const ( - // Audio constants - BaudRate1600 = 1600 - BaudRate3200 = 3200 // 4-FSK - SampleRate = 48000 - BitsPerSample = 16 - NumChannels = 1 -) - -var ( - SymbolHigh = int16(-12287) // bit 1 (0xD001 as signed) - SymbolLow = int16(12287) // bit 0 (0x2FFF as signed) -) - -// ConvertToAudio converts raw data bytes into a baseband WAV (for standard 1600 baud 2-FSK) -func ConvertToAudio(data []byte) []byte { - return ConvertToAudioWithBaudRate(data, BaudRate1600) -} - -// ConvertToAudioWithBaudRate converts data bytes to WAV audio with specified baud rate. -// Uses baseband (DC levels): bit 1 = negative, bit 0 = positive. -func ConvertToAudioWithBaudRate(data []byte, baudRate int) []byte { - samplesPerSymbol := float64(SampleRate) / float64(baudRate) - numBits := len(data) * 8 - numSamples := int(float64(numBits) * samplesPerSymbol) - - audioData := make([]int16, numSamples) - - for byteIdx, b := range data { - for bitPos := 7; bitPos >= 0; bitPos-- { - bit := (b >> bitPos) & 1 - var sample int16 - - if bit == 1 { - sample = int16(SymbolHigh) - } else { - sample = int16(SymbolLow) - } - - bitIndex := byteIdx*8 + (7 - bitPos) - startIdx := int(math.Round(float64(bitIndex) * samplesPerSymbol)) - endIdx := int(math.Round(float64(bitIndex+1) * samplesPerSymbol)) - - for j := startIdx; j < endIdx; j++ { - audioData[j] = sample - } - } - } - - return createWAVFile(audioData) -} - -// FSK tone frequencies (mark=1, space=0) -const ( - FSKFreqSpace = 1600.0 // Hz, bit 0 - FSKFreqMark = 3200.0 // Hz, bit 1 -) - -// ConvertToAudioFSK converts raw data bytes to FSK WAV audio (sine waves). -func ConvertToAudioFSK(data []byte, baudRate int) []byte { - samplesPerSymbol := float64(SampleRate) / float64(baudRate) - numBits := len(data) * 8 - numSamples := int(float64(numBits) * samplesPerSymbol) - audioData := make([]int16, numSamples) - - const amplitude = 16000.0 - phase := 0.0 - - for byteIdx, b := range data { - for bitPos := 7; bitPos >= 0; bitPos-- { - bit := (b >> bitPos) & 1 - freq := FSKFreqSpace - if bit == 1 { - freq = FSKFreqMark - } - phaseIncrement := 2.0 * math.Pi * freq / float64(SampleRate) - - bitIndex := byteIdx*8 + (7 - bitPos) - startIdx := int(float64(bitIndex) * samplesPerSymbol) - endIdx := int(float64(bitIndex+1) * samplesPerSymbol) - - for j := startIdx; j < endIdx; j++ { - phase += phaseIncrement - for phase > 2.0*math.Pi { - phase -= 2.0 * math.Pi - } - audioData[j] = int16(amplitude * math.Sin(phase)) - } - } - } - - return createWAVFile(audioData) -} - -func createWAVFile(samples []int16) []byte { - var buf bytes.Buffer - - dataSize := uint32(len(samples) * 2) - fileSize := 36 + dataSize - byteRate := uint32(SampleRate * NumChannels * BitsPerSample / 8) - blockAlign := uint16(NumChannels * BitsPerSample / 8) - - // RIFF header - buf.WriteString("RIFF") - binary.Write(&buf, binary.LittleEndian, fileSize) - buf.WriteString("WAVE") - - // fmt chunk - buf.WriteString("fmt ") - binary.Write(&buf, binary.LittleEndian, uint32(16)) // chunk size - binary.Write(&buf, binary.LittleEndian, uint16(1)) // PCM format - binary.Write(&buf, binary.LittleEndian, uint16(NumChannels)) // channels - binary.Write(&buf, binary.LittleEndian, uint32(SampleRate)) // sample rate - binary.Write(&buf, binary.LittleEndian, byteRate) // byte rate - binary.Write(&buf, binary.LittleEndian, blockAlign) // block align - binary.Write(&buf, binary.LittleEndian, uint16(BitsPerSample)) // bits per sample - - // data chunk - buf.WriteString("data") - binary.Write(&buf, binary.LittleEndian, dataSize) - - for _, sample := range samples { - binary.Write(&buf, binary.LittleEndian, sample) - } - - return buf.Bytes() -} diff --git a/bch_flex.go b/bch_flex.go index 4e68e6a..edc99e4 100644 --- a/bch_flex.go +++ b/bch_flex.go @@ -10,18 +10,6 @@ func reverse32(x uint32) uint32 { return r } -func FLEXBCHEncode21(info uint32) uint32 { - info &= 0x1FFFFF - poc := BCHEncode31_21(info) - data := poc >> 10 - parity := poc & 0x3FF - cw := data | (parity << 21) - if popCount32(cw)&1 != 0 { - cw |= 1 << 31 - } - return reverse32(cw) -} - // reverse21 reverses the lower 21 bits of x. func reverse21(x uint32) uint32 { var r uint32 @@ -32,18 +20,30 @@ func reverse21(x uint32) uint32 { return r } +// flexEncodeWord builds a FLEX wire codeword matching real OTA captures (test_1600.wav). +func flexEncodeWord(logical21 uint32) uint32 { + infoMSB := reverse21(logical21 & 0x1FFFFF) + poc := BCHEncode31_21(infoMSB) & 0x7FFFFFFF + rev := (poc << 1) | uint32(popCount32(poc)&1) + return reverse32(rev) +} + func FLEXBCHDecode32(cw uint32) (uint32, int) { - // The assembled FLEX word from the DPLL has the first transmitted bit at LSB. - // But our BCH decoder expects the first transmitted bit (Even Parity for POCSAG) at LSB. - // In FLEX, the Even Parity bit is transmitted LAST. So we must reverse the 32-bit word. + // Primary path matches real FLEX captures (reverse32 + BCH MSB layout). rev := reverse32(cw) infoMSB, errs := BCHDecode31_21(rev >> 1) - - // The decoded info is 21 bits. Since we reversed the input, the output is reversed. - // We reverse it back to LSB-first orientation for the rest of the FLEX logic. - infoLSB := reverse21(infoMSB) - - return infoLSB, errs + if errs >= 0 { + return reverse21(infoMSB), errs + } + + // Fallback: bit-0 parity layout. + code31 := (cw >> 1) & 0x7FFFFFFF + info := code31 >> 10 + enc := BCHEncode31_21(info) & 0x7FFFFFFF + if enc == code31 { + return info & 0x1FFFFF, 0 + } + return info & 0x1FFFFF, -1 } func FLEXChecksum(info uint32) bool { diff --git a/bin/flex-decode.exe b/bin/flex-decode.exe deleted file mode 100644 index 5047aa4..0000000 Binary files a/bin/flex-decode.exe and /dev/null differ diff --git a/bin/flex.exe b/bin/flex.exe deleted file mode 100644 index be22288..0000000 Binary files a/bin/flex.exe and /dev/null differ diff --git a/cmd/flex-decode/main.go b/cmd/flex-decode/main.go index 7382e6b..a4556fb 100644 --- a/cmd/flex-decode/main.go +++ b/cmd/flex-decode/main.go @@ -4,53 +4,67 @@ import ( "encoding/json" "flag" "fmt" - "log" "os" flex "github.com/sqpp/flex-golang" ) func main() { - inputPath := flag.String("i", "", "Input WAV file (required)") - inputPathLong := flag.String("input", "", "Input WAV file (required)") - jsonOutput := flag.Bool("j", false, "JSON output") - jsonOutputLong := flag.Bool("json", false, "JSON output") + inputFile := flag.String("input", "", "Input WAV file to decode (required)") + flag.StringVar(inputFile, "i", "", "Input WAV file to decode (required)") - versionOutput := flag.Bool("v", false, "Show version information") - versionOutputLong := flag.Bool("version", false, "Show version information") + jsonOutput := flag.Bool("json", false, "Output result as JSON") + flag.BoolVar(jsonOutput, "j", false, "Output result as JSON") + + version := flag.Bool("version", false, "Show version information") + flag.BoolVar(version, "v", false, "Show version information") + + noTones := flag.Bool("no-tones", false, "Filter out tone-only messages") flag.Parse() - if *versionOutput || *versionOutputLong { + if *version { fmt.Println(flex.GetFullVersionInfo()) os.Exit(0) } - inputFile := *inputPath - if *inputPathLong != "" { - inputFile = *inputPathLong - } - isJson := *jsonOutput || *jsonOutputLong - - if inputFile == "" { - fmt.Println("Usage: flex-decode -i ") - flag.PrintDefaults() + if *inputFile == "" { + fmt.Fprintln(os.Stderr, "Error: Input file required") + fmt.Fprintln(os.Stderr, "\nUsage examples:") + fmt.Fprintln(os.Stderr, " flex-decode --input message.wav") + fmt.Fprintln(os.Stderr, " flex-decode -i message.wav") + fmt.Fprintln(os.Stderr, "") + flag.Usage() os.Exit(1) } - data, err := os.ReadFile(inputFile) + data, err := os.ReadFile(*inputFile) if err != nil { - log.Fatalf("Failed to read input file: %v", err) + fmt.Fprintf(os.Stderr, "Error reading file: %v\n", err) + os.Exit(1) } - // Read file - decodedMessages, err := flex.DecodeFromAudio(data) if err != nil { - log.Fatalf("Decoding failed: %v", err) + fmt.Fprintf(os.Stderr, "Error decoding: %v\n", err) + os.Exit(1) + } + + if len(decodedMessages) == 0 { + if *jsonOutput { + result := map[string]interface{}{ + "success": true, + "messages": []interface{}{}, + } + jsonBytes, _ := json.MarshalIndent(result, "", " ") + fmt.Println(string(jsonBytes)) + } else { + fmt.Println("No messages found") + } + return } - if isJson { + if *jsonOutput { jsonMessages := make([]map[string]interface{}, len(decodedMessages)) for i, msg := range decodedMessages { msgType := "unknown" @@ -62,14 +76,20 @@ func main() { msgType = "tone" } + outText := msg.Text + if msg.Type == flex.PageAlphanumeric && msg.Frag != 3 { + fragNum := (3 - msg.Frag) + 1 + outText = fmt.Sprintf("[Continued message - Fragment #%d] %s", fragNum, msg.Text) + } + jsonMessages[i] = map[string]interface{}{ "address": msg.Capcode, "function": int(msg.Type), - "message": msg.Text, + "message": outText, "type": msgType, } } - + baudRate := 1600 levels := 2 if len(decodedMessages) > 0 { @@ -99,16 +119,29 @@ func main() { if levels == 4 { bitRate *= 2 } - + baudStr := fmt.Sprintf("FLEX-%d/%d", bitRate, levels) fmt.Printf("%s: Decoded messages:\n", baudStr) for _, msg := range decodedMessages { + if *noTones && msg.Type == flex.PageTone { + continue + } + msgType := "ALPHA" - if msg.IsNumeric { + if msg.Type == flex.PageTone { + msgType = "TONE" + } else if msg.IsNumeric { msgType = "NUMERIC" } + + outText := msg.Text + if msg.Type == flex.PageAlphanumeric && msg.Frag != 3 { + fragNum := (3 - msg.Frag) + 1 + outText = fmt.Sprintf("[Continued message - Fragment #%d] %s", fragNum, msg.Text) + } + fmt.Printf("Address: %07d Function: %d %-7s Message: %s\n", - msg.Capcode, int(msg.Type), msgType, msg.Text) + msg.Capcode, int(msg.Type), msgType, outText) } } } diff --git a/cmd/flex-encode/main.go b/cmd/flex-encode/main.go new file mode 100644 index 0000000..09c9531 --- /dev/null +++ b/cmd/flex-encode/main.go @@ -0,0 +1,163 @@ +package main + +import ( + "encoding/json" + "flag" + "fmt" + "os" + "strings" + + flex "github.com/sqpp/flex-golang" +) + +func main() { + address := flag.Int("address", 0, "Pager address (capcode) - REQUIRED") + flag.IntVar(address, "a", 0, "Pager address (capcode) - REQUIRED") + + message := flag.String("message", "", "Message text to send - REQUIRED") + flag.StringVar(message, "m", "", "Message text to send - REQUIRED") + + output := flag.String("output", "output.wav", "Output WAV file path") + flag.StringVar(output, "o", "output.wav", "Output WAV file path") + + funcCode := flag.Uint("function", uint(flex.PageAlphanumeric), "Message type: 2=tone, 3=numeric, 5=alphanumeric (default: 5)") + flag.UintVar(funcCode, "f", uint(flex.PageAlphanumeric), "Message type: 2=tone, 3=numeric, 5=alphanumeric") + + mode := flag.String("mode", flex.Mode1600_2, fmt.Sprintf("FLEX mode: %s (default: %s)", strings.Join(flex.EncodeModeNames(), ", "), flex.Mode1600_2)) + + cycle := flag.Int("cycle", 0, "FIW cycle field (optional)") + frame := flag.Int("frame", 0, "FIW frame field (optional)") + + reference := flag.Bool("reference", false, "Encode the known-good PDW message from tests/test_1600.wav (cap 1913)") + + messagesFile := flag.String("messages", "", "JSON file with a list of message objects (optional)") + flag.StringVar(messagesFile, "messages-file", "", "JSON file with a list of message objects (optional)") + + jsonOutput := flag.Bool("json", false, "Output result as JSON") + flag.BoolVar(jsonOutput, "j", false, "Output result as JSON") + + version := flag.Bool("version", false, "Show version information") + flag.BoolVar(version, "v", false, "Show version information") + + flag.Parse() + + if *version { + fmt.Println(flex.GetFullVersionInfo()) + os.Exit(0) + } + + baud, err := flex.EncodeModeBitRate(*mode) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + + var messages []flex.EncodeMessage + cycleVal, frameVal := *cycle, *frame + + switch { + case *reference: + messages = []flex.EncodeMessage{{ + Capcode: flex.ReferenceCapcode1913, + Type: "alpha", + Text: flex.ReferenceMessage1913, + }} + if cycleVal == 0 && frameVal == 0 { + cycleVal = flex.ReferenceCycle1913 + frameVal = flex.ReferenceFrame1913 + } + case *messagesFile != "": + data, err := os.ReadFile(*messagesFile) + if err != nil { + fmt.Fprintf(os.Stderr, "Error reading messages file: %v\n", err) + os.Exit(1) + } + if err := json.Unmarshal(data, &messages); err != nil { + fmt.Fprintf(os.Stderr, "Error parsing messages file: %v\n", err) + os.Exit(1) + } + default: + if *address == 0 || *message == "" { + fmt.Fprintln(os.Stderr, "Error: Address and message are required") + fmt.Fprintln(os.Stderr, "") + fmt.Fprintln(os.Stderr, "Note: FLEX addresses (capcodes) are in the range 1..2097151") + fmt.Fprintln(os.Stderr, "\nUsage examples:") + fmt.Fprintln(os.Stderr, " flex-encode --address 1913 --message \"HELLO WORLD\" --output test.wav") + fmt.Fprintln(os.Stderr, " flex-encode -a 1913 -m \"HELLO WORLD\" -o test.wav") + fmt.Fprintln(os.Stderr, " flex-encode -o test.wav --reference") + fmt.Fprintln(os.Stderr, " flex-encode -o test.wav --messages messages.json") + fmt.Fprintln(os.Stderr, "") + flag.Usage() + os.Exit(1) + } + msgType, err := flex.EncodeTypeFromFunction(int(*funcCode)) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + messages = []flex.EncodeMessage{{ + Capcode: *address, + Type: msgType, + Text: *message, + }} + } + + if len(messages) == 0 { + fmt.Fprintln(os.Stderr, "Error: No messages to encode") + os.Exit(1) + } + + wav, _, nSamples, err := flex.EncodeToWAVBytes(messages, *mode, cycleVal, frameVal) + if err != nil { + fmt.Fprintf(os.Stderr, "Error encoding: %v\n", err) + os.Exit(1) + } + if err := os.WriteFile(*output, wav, 0644); err != nil { + fmt.Fprintf(os.Stderr, "Error writing WAV file: %v\n", err) + os.Exit(1) + } + + durationSec := float64(nSamples) / float64(flex.EncoderSampleRate) + + if *jsonOutput { + result := map[string]interface{}{ + "success": true, + "output": *output, + "baud": baud, + "size": len(wav), + "duration_s": durationSec, + } + if len(messages) == 1 { + result["address"] = messages[0].Capcode + result["function"] = functionForMessage(messages[0].Type) + result["message"] = messages[0].Text + result["type"] = flex.EncodeTypeLabel(messages[0].Type) + } + jsonBytes, _ := json.MarshalIndent(result, "", " ") + fmt.Println(string(jsonBytes)) + } else { + fmt.Printf("✅ Generated %s\n", *output) + if len(messages) == 1 { + fmt.Printf(" Address: %d, Function: %d, Baud: %d, Message: %s\n", + messages[0].Capcode, functionForMessage(messages[0].Type), baud, messages[0].Text) + } else { + fmt.Printf(" Messages: %d, Baud: %d\n", len(messages), baud) + } + if cycleVal != 0 || frameVal != 0 { + fmt.Printf(" Cycle: %d, Frame: %d\n", cycleVal, frameVal) + } + fmt.Printf(" Size: %d bytes, Duration: %.2f s\n", len(wav), durationSec) + fmt.Printf("\nDecode: flex-decode -i %s\n", *output) + } +} + +func functionForMessage(typeName string) int { + switch typeName { + case "numeric": + return int(flex.PageStdNumeric) + case "tone": + return int(flex.PageTone) + default: + return int(flex.PageAlphanumeric) + } +} diff --git a/decoder.go b/decoder.go index 2b0a7a0..8650931 100644 --- a/decoder.go +++ b/decoder.go @@ -19,7 +19,12 @@ var flexModes = []struct { {0xB068, 1600, 4}, {0x7B18, 3200, 2}, {0xDEA0, 3200, 4}, - {0x4C7C, 3200, 4}, + {0x4C7C, 3200, 4}, // ReFLEX 25; same symbol rate as FLEX 6400/4 +} + +// flexBitRate converts FLEX symbol rate + levels to the PDW bit-rate label. +func flexBitRate(symRate, levels int) int { + return symRate * (levels / 2) } // PhaseWords is the number of 32-bit codewords per phase of a FLEX @@ -84,11 +89,20 @@ type Demodulator struct { sync2Count uint32 dataCount uint32 c int - words [PhaseWords]uint32 + words [4 * PhaseWords]uint32 // Cumulative counters. framesSynced atomic.Uint64 pagesEmitted atomic.Uint64 + + capturedFrames []RawPhaseFrame +} + +// RawPhaseFrame holds MSB-assembled wire codewords from one FLEX phase-A block. +type RawPhaseFrame struct { + Cycle int + Frame int + Words [PhaseWords]uint32 } // NewDemodulator returns a Demodulator in the marker-hunt state. @@ -288,15 +302,56 @@ func (d *Demodulator) processSymbol(sym byte) []Message { } case stData: - bit := uint32(0) - if symRectified > 1 { - bit = 0x80000000 + numPhases := int((d.syncBaud / 1600) * (d.syncLevels / 2)) + if numPhases < 1 { + numPhases = 1 } + if numPhases > 4 { + numPhases = 4 + } + + symIndex := d.c + + if d.syncLevels == 2 { + phase := symIndex % numPhases + bitIndex := symIndex / numPhases + + bit := uint32(0) + if symRectified > 1 { + bit = 0x80000000 + } + + word := ((bitIndex >> 5) & 0xFFF8) | (bitIndex & 7) + if word < PhaseWords { + idx := phase*PhaseWords + word + d.words[idx] >>= 1 + d.words[idx] |= bit + } + } else { + msbPhase := (symIndex % (numPhases / 2)) * 2 + lsbPhase := msbPhase + 1 + bitIndex := symIndex / (numPhases / 2) + + msbBit := uint32(0) + if symRectified > 1 { + msbBit = 0x80000000 + } + + lsbBit := uint32(0) + if symRectified == 1 || symRectified == 2 { + lsbBit = 0x80000000 + } - word := ((d.c >> 5) & 0xFFF8) | (d.c & 7) - if word < PhaseWords { - d.words[word] >>= 1 - d.words[word] |= bit + word := ((bitIndex >> 5) & 0xFFF8) | (bitIndex & 7) + if word < PhaseWords { + idxMsb := msbPhase*PhaseWords + word + d.words[idxMsb] >>= 1 + d.words[idxMsb] |= msbBit + + idxLsb := lsbPhase*PhaseWords + word + d.words[idxLsb] >>= 1 + d.words[idxLsb] |= lsbBit + } } d.c++ @@ -336,12 +391,17 @@ func checkSync1(buf uint64) (uint16, bool) { lo := uint16(buf & 0xFFFF) - dist := countBits32(marker^SyncMarker) + dist := countBits32(marker ^ SyncMarker) if dist <= 4 { code := ^lo if code == 0x870C || code == 0x870D || code == 0x8720 || code == 0x8721 || code == 0x8722 { return code, true } + for _, m := range flexModes { + if code == m.code { + return code, true + } + } } return 0, false } @@ -362,7 +422,6 @@ func (d *Demodulator) enterFIW(code uint16) { func (d *Demodulator) processFIW() bool { info, errs := FLEXBCHDecode32(d.fiwReg) chk := FLEXChecksum(info) - // debug: FIW raw if errs >= 0 && chk { d.cycle = int((info >> 4) & 0x0F) d.frame = int((info >> 8) & 0x7F) @@ -373,24 +432,49 @@ func (d *Demodulator) processFIW() bool { } func (d *Demodulator) decodeBlock() []Message { - infos := make([]uint32, PhaseWords) - corr := make([]int, PhaseWords) - - errCnt := 0 - for i := 0; i < PhaseWords; i++ { - info, errs := FLEXBCHDecode32(d.words[i]) - infos[i] = info & 0x1FFFFF - corr[i] = errs - if errs >= 0 { - errCnt++ + numPhases := int((d.syncBaud / 1600) * (d.syncLevels / 2)) + if numPhases < 1 { + numPhases = 1 + } + if numPhases > 4 { + numPhases = 4 + } + + var allMsgs []Message + phaseChars := []byte{'A', 'B', 'C', 'D'} + + for p := 0; p < numPhases; p++ { + if p == 0 { + var rf RawPhaseFrame + rf.Cycle = d.cycle + rf.Frame = d.frame + copy(rf.Words[:], d.words[0:PhaseWords]) + d.capturedFrames = append(d.capturedFrames, rf) + } + + if p*PhaseWords+PhaseWords > len(d.words) { + break + } + + infos := make([]uint32, PhaseWords) + corr := make([]int, PhaseWords) + errCnt := 0 + + for i := 0; i < PhaseWords; i++ { + info, errs := FLEXBCHDecode32(d.words[p*PhaseWords+i]) + infos[i] = info & 0x1FFFFF + corr[i] = errs + if errs >= 0 { + errCnt++ + } } + //fmt.Printf("DEBUG: Phase %c decoded %d valid codewords out of %d\n", phaseChars[p], errCnt, PhaseWords) + msgs := DecodePhase(infos, corr, d.frame, d.cycle, flexBitRate(int(d.syncBaud), int(d.syncLevels)), int(d.syncLevels), phaseChars[p]) + allMsgs = append(allMsgs, msgs...) } - // debug: Words - // debug: Block decode - msgs := DecodePhase(infos, corr, d.frame, d.cycle, int(d.syncBaud), int(d.syncLevels), 'A') - d.pagesEmitted.Add(uint64(len(msgs))) - return msgs + d.pagesEmitted.Add(uint64(len(allMsgs))) + return allMsgs } // Stats reports cumulative decode counters. diff --git a/demod.go b/demod.go index c3e4fb5..b821283 100644 --- a/demod.go +++ b/demod.go @@ -6,11 +6,43 @@ import ( "fmt" ) +const ( + BaudRate1600 = 1600 + BaudRate3200 = 3200 +) + const FrameSyncWord = 0x9c9acf1e const FrameSyncWordInverted = 0x636530e1 const IdleBlockWord = 0x00000000 const IdleBlockWord2 = 0xFFFFFFFF +// DemodulateRawFrames returns MSB-assembled phase-A codewords for each synced frame. +func DemodulateRawFrames(wavData []byte) ([]RawPhaseFrame, error) { + dataOffset := bytes.Index(wavData, []byte("data")) + startIdx := 44 + if dataOffset != -1 { + startIdx = dataOffset + 8 + } + var sampleRate uint32 = 48000 + if len(wavData) > 28 { + sampleRate = binary.LittleEndian.Uint32(wavData[24:28]) + } + d := NewDemodulator(sampleRate) + lpfBuf := make([]float32, 8) + lpfIdx := 0 + var lpfSum float32 + for i := startIdx; i < len(wavData)-1; i += 2 { + rawSample := float32(int16(binary.LittleEndian.Uint16(wavData[i:]))) + lpfSum -= lpfBuf[lpfIdx] + lpfBuf[lpfIdx] = rawSample + lpfSum += rawSample + lpfIdx = (lpfIdx + 1) % len(lpfBuf) + sample := lpfSum / float32(len(lpfBuf)) + d.PushSample(sample) + } + return d.capturedFrames, nil +} + // DecodeFromAudio decodes FLEX messages from a 1600 baud 2-FSK WAV file func DecodeFromAudio(wavData []byte) ([]Message, error) { return DecodeFromAudioWithBaudRate(wavData, BaudRate1600) diff --git a/encoder.go b/encoder.go new file mode 100644 index 0000000..573ebd3 --- /dev/null +++ b/encoder.go @@ -0,0 +1,475 @@ +package flex + +import ( + "fmt" + "os" +) + +const ( + Mode1600_2 = "1600/2" + Mode1600_4 = "1600/4" + Mode3200_2 = "3200/2" + Mode3200_4 = "3200/4" + Mode6400_4 = "6400/4" + + flexSync1600_2 = 0x870C + flexSync1600_4 = 0xB068 // 3200 bps / 4-level @ 1600 sym/s (PDW "FLEX 3200") + flexSync3200_2 = 0x7B18 + flexSync6400_4 = 0xDEA0 // 6400 bps / 4-level @ 3200 sym/s (PDW "FLEX 6400"); not 0x4C7C (ReFLEX) + flexCodewords = PhaseWords // 88 + + // ReferenceMessage1913 is the alpha page PDW decodes from tests/test_1600.wav + // (capcode 1913, cycle 3, frame 111). + ReferenceMessage1913 = "NEW JOB: BED: B6 ROOM 19 BED 02 BED ID: 206192 STATUS: Dirty PRECAUTIONS: *Standard" + ReferenceCapcode1913 = 1913 + ReferenceCycle1913 = 3 + ReferenceFrame1913 = 111 +) + +// EncodeMessage is one pager message to encode into a FLEX frame. +type EncodeMessage struct { + Capcode int + Type string // "alpha", "numeric", "tone" + Text string +} + +type encodeMode struct { + name string + syncCode uint16 + symRate int // FLEX symbol rate on the data block (PDW g_sps) + levels int +} + +// bitRate returns the on-air bit rate shown by PDW (symRate × bits per symbol). +func (m encodeMode) bitRate() int { + return m.symRate * (m.levels / 2) +} + +var encodeModes = map[string]encodeMode{ + // PDW syncs[] index 0: 0x870C → 1600 sym/s, 2-level + Mode1600_2: {Mode1600_2, flexSync1600_2, 1600, 2}, + // PDW index 2: 0xB068 → 1600 sym/s, 4-level (PDW title: "FLEX 3200") + Mode1600_4: {Mode1600_4, flexSync1600_4, 1600, 4}, + Mode3200_4: {Mode3200_4, flexSync1600_4, 1600, 4}, + // PDW index 1: 0x7B18 → 3200 sym/s, 2-level + Mode3200_2: {Mode3200_2, flexSync3200_2, 3200, 2}, + // PDW index 3: 0xDEA0 → 3200 sym/s, 4-level (PDW title: "FLEX 6400") + Mode6400_4: {Mode6400_4, flexSync6400_4, 3200, 4}, +} + +// EncodeModeNames returns modes supported by the encoder. +func EncodeModeNames() []string { + return []string{Mode1600_2, Mode1600_4, Mode3200_2, Mode3200_4, Mode6400_4} +} + +func getEncodeMode(name string) (encodeMode, error) { + m, ok := encodeModes[name] + if !ok { + return encodeMode{}, fmt.Errorf("encoder mode %q not supported yet (available: %v)", name, EncodeModeNames()) + } + return m, nil +} + +// EncodeModeBitRate returns the on-air bit rate for a FLEX mode (e.g. 1600 for "1600/2"). +func EncodeModeBitRate(modeName string) (int, error) { + m, err := getEncodeMode(modeName) + if err != nil { + return 0, err + } + return m.bitRate(), nil +} + +// EncodeModeDetails describes a supported FLEX encoder mode. +type EncodeModeDetails struct { + Name string + SyncCode uint16 + SymRate int + Levels int + BitRate int +} + +// LookupEncodeMode returns metadata for a FLEX encoder mode name. +func LookupEncodeMode(modeName string) (EncodeModeDetails, error) { + m, err := getEncodeMode(modeName) + if err != nil { + return EncodeModeDetails{}, err + } + return EncodeModeDetails{ + Name: m.name, + SyncCode: m.syncCode, + SymRate: m.symRate, + Levels: m.levels, + BitRate: m.bitRate(), + }, nil +} + +// EncodeTypeFromFunction maps a FLEX vector function code to an encoder type string. +func EncodeTypeFromFunction(function int) (string, error) { + switch PageType(function) { + case PageAlphanumeric: + return "alpha", nil + case PageStdNumeric, PageSpecialNum, PageNumberedNum: + return "numeric", nil + case PageTone: + return "tone", nil + default: + return "", fmt.Errorf("invalid function %d (supported: 2=tone, 3=numeric, 5=alphanumeric)", function) + } +} + +// EncodeTypeLabel returns a stable type name for CLI/JSON output. +func EncodeTypeLabel(typeName string) string { + switch typeName { + case "numeric": + return "numeric" + case "tone": + return "tone" + default: + return "alphanumeric" + } +} + +func flexChecksumSum(info uint32) uint32 { + return (info & 0x0F) + ((info >> 4) & 0x0F) + ((info >> 8) & 0x0F) + + ((info >> 12) & 0x0F) + ((info >> 16) & 0x0F) + ((info >> 20) & 0x1) +} + +func flexSetChecksum(info uint32) uint32 { + info &^= 0x0F + info |= (0xF - flexChecksumSum(info)) & 0x0F + return info +} + +func encodeWord(data21 uint32) uint32 { + return flexEncodeWord(data21) +} + +func buildFIWData(cycle, frame int) uint32 { + fiw := uint32((cycle&0xF)<<4) | uint32((frame&0x7F)<<8) + return flexSetChecksum(fiw) +} + +func buildBIW(voffset, aoffset int) uint32 { + biw := uint32((aoffset&0x3)<<8) | uint32((voffset&0x3F)<<10) + return encodeWord(flexSetChecksum(biw)) +} + +func buildAddressWord(capcode int) uint32 { + return encodeWord(uint32((capcode + 0x8000) & 0x1FFFFF)) +} + +func buildVectorWord(msgType, msgStart, msgLen int) uint32 { + vec := uint32((msgType&0x7)<<4) | uint32((msgStart&0x7F)<<7) | uint32((msgLen&0x7F)<<14) + return encodeWord(flexSetChecksum(vec)) +} + +func buildMessageWord(data21 uint32) uint32 { + return encodeWord(data21 & 0x1FFFFF) +} + +func idleCodeword(i int) uint32 { + if i%2 == 0 { + return encodeWord(0x0AAAAA) + } + return encodeWord(0x155555) +} + +// encodeAlphaPayload packs 7-bit ASCII into 21-bit message words. +// skipFirstChar leaves bits 0-6 empty in the first word for the signature slot. +func encodeAlphaPayload(text string, maxWords int, skipFirstChar bool) []uint32 { + bitPos := 0 + if skipFirstChar { + bitPos = 7 + } + + var words []uint32 + var current uint32 + + for i := 0; i < len(text) && len(words) < maxWords; i++ { + ch := uint32(text[i]) & 0x7F + current |= ch << bitPos + bitPos += 7 + + if bitPos >= 21 { + words = append(words, current&0x1FFFFF) + overflow := bitPos - 21 + if overflow > 0 { + current = ch >> (7 - overflow) + bitPos = overflow + } else { + current = 0 + bitPos = 0 + } + } + } + + if bitPos > 0 && len(words) < maxWords { + words = append(words, current&0x1FFFFF) + } + + // ETX padding (gr-mixalot / FLEX spec). + if bitPos == 7 && len(words) > 0 { + words[len(words)-1] |= (0x03 << 7) | (0x03 << 14) + } else if bitPos == 14 && len(words) > 0 { + words[len(words)-1] |= 0x03 << 14 + } + + return words +} + +func alphaSignature(msgWords []uint32) uint32 { + var sigSum uint32 + if len(msgWords) > 0 { + for _, slot := range []int{7, 14} { + ch := (msgWords[0] >> slot) & 0x7F + if ch != 0x03 { + sigSum += ch + } + } + for i := 1; i < len(msgWords); i++ { + for _, slot := range []int{0, 7, 14} { + ch := (msgWords[i] >> slot) & 0x7F + if ch != 0x03 { + sigSum += ch + } + } + } + } + return (^sigSum) & 0x7F +} + +func alphaHeaderChecksum(header uint32, content []uint32) uint32 { + kSum := (header & 0xFF) + ((header >> 8) & 0xFF) + ((header >> 16) & 0x1F) + for _, w := range content { + kSum += (w & 0xFF) + ((w >> 8) & 0xFF) + ((w >> 16) & 0x1F) + } + return (^kSum) & 0x3FF +} + +func buildAlphaCodewords(text string, codewords []uint32) error { + const maxContent = 84 + content := encodeAlphaPayload(text, maxContent, true) + if len(content) == 0 && text != "" { + content = []uint32{0} + } + content[0] |= alphaSignature(content) + + const ( + voffset = 2 + aoffset = 0 + msgStart = 3 + ) + totalMsgWords := len(content) + 1 + + codewords[0] = buildBIW(voffset, aoffset) + codewords[2] = buildVectorWord(int(PageAlphanumeric), msgStart, totalMsgWords) + + header := uint32(0x1800) + header |= alphaHeaderChecksum(header, content) + codewords[msgStart] = buildMessageWord(header) + + for i, w := range content { + idx := msgStart + 1 + i + if idx >= flexCodewords { + break + } + codewords[idx] = buildMessageWord(w) + } + return nil +} + +func buildToneCodewords(codewords []uint32) { + const ( + voffset = 2 + aoffset = 0 + msgStart = 3 + ) + codewords[0] = buildBIW(voffset, aoffset) + codewords[2] = buildVectorWord(int(PageTone), msgStart, 1) + codewords[msgStart] = buildMessageWord(0) +} + +func buildSync1(syncCode uint16) uint64 { + complement := syncCode ^ 0xFFFF + return (uint64(syncCode) << 48) | (uint64(SyncMarker) << 16) | uint64(complement) +} + +func appendBitsMSBInv(bits *[]byte, value uint64, n int) { + for i := n - 1; i >= 0; i-- { + b := byte((value >> uint(i)) & 1) + *bits = append(*bits, b^1) + } +} + +func appendBitsLSB(bits *[]byte, value uint32, n int) { + for i := 0; i < n; i++ { + *bits = append(*bits, byte((value>>uint(i))&1)) + } +} + +func appendBitsMSB(bits *[]byte, value uint32, n int) { + for i := n - 1; i >= 0; i-- { + *bits = append(*bits, byte((value>>uint(i))&1)) + } +} + +func appendInterleavedData(bits *[]byte, codewords []uint32) { + for block := 0; block < 11; block++ { + base := block * 8 + for bit := 0; bit < 32; bit++ { + for cwInBlock := 0; cwInBlock < 8; cwInBlock++ { + cw := base + cwInBlock + if cw >= len(codewords) { + continue + } + *bits = append(*bits, byte((codewords[cw]>>uint(bit))&1)) + } + } + } +} + +// BuildBitstream assembles a complete FLEX frame symbol stream (header + data). +func BuildBitstream(msg EncodeMessage, modeName string, cycle, frame int) (flexTransmission, error) { + codewords, mode, err := assembleCodewords(msg, modeName, cycle, frame) + if err != nil { + return flexTransmission{}, err + } + return buildTransmission(codewords, mode, cycle, frame), nil +} + +func buildTransmission(codewords []uint32, mode encodeMode, cycle, frame int) flexTransmission { + var header []byte + + for i := 0; i < 960; i++ { + if i&1 == 0 { + header = append(header, 0) + } else { + header = append(header, 1) + } + } + + appendBitsMSBInv(&header, buildSync1(mode.syncCode), 64) + + for i := 0; i < 16; i++ { + if i&1 == 0 { + header = append(header, 0) + } else { + header = append(header, 1) + } + } + + fiw := encodeWord(buildFIWData(cycle, frame)) + appendBitsLSB(&header, fiw, 32) + + for i := 0; i < 4; i++ { + if i&1 == 0 { + header = append(header, 0) + } else { + header = append(header, 1) + } + } + const cPat = uint16(0xED84) + for i := 15; i >= 0; i-- { + header = append(header, byte((cPat>>uint(i))&1)) + } + for i := 0; i < 4; i++ { + if i&1 == 0 { + header = append(header, 1) + } else { + header = append(header, 0) + } + } + const cInvPat = uint16(0x127B) + for i := 15; i >= 0; i-- { + header = append(header, byte((cInvPat>>uint(i))&1)) + } + + phases := makePhaseCodewords(codewords, mode.symRate, mode.levels) + var data []byte + appendMultiphaseSymbols(&data, phases, mode.symRate, mode.levels) + + var trailer []byte + for i := 0; i < 64; i++ { + if i&1 == 0 { + trailer = append(trailer, 0) + } else { + trailer = append(trailer, 1) + } + } + + return flexTransmission{ + header: header, + data: data, + trailer: trailer, + dataBaud: mode.symRate, + levels: mode.levels, + } +} + +func assembleCodewords(msg EncodeMessage, modeName string, cycle, frame int) ([]uint32, encodeMode, error) { + mode, err := getEncodeMode(modeName) + if err != nil { + return nil, encodeMode{}, err + } + if msg.Capcode <= 0 { + return nil, encodeMode{}, fmt.Errorf("capcode must be positive") + } + + codewords := make([]uint32, flexCodewords) + for i := range codewords { + codewords[i] = idleCodeword(i) + } + + switch msg.Type { + case "alpha", "": + if err := buildAlphaCodewords(msg.Text, codewords); err != nil { + return nil, encodeMode{}, err + } + case "tone": + buildToneCodewords(codewords) + default: + return nil, encodeMode{}, fmt.Errorf("message type %q not supported yet", msg.Type) + } + codewords[1] = buildAddressWord(msg.Capcode) + return codewords, mode, nil +} + +func bitstreamFromCodewords(codewords []uint32, mode encodeMode, cycle, frame int) (flexTransmission, error) { + return buildTransmission(codewords, mode, cycle, frame), nil +} + +// EncodeToWAVBytes encodes messages into a FLEX WAV file. +func EncodeToWAVBytes(messages []EncodeMessage, modeName string, cycle, frame int) ([]byte, int, int, error) { + if len(messages) == 0 { + return nil, 0, 0, fmt.Errorf("no messages to encode") + } + if _, err := getEncodeMode(modeName); err != nil { + return nil, 0, 0, err + } + + bits, err := BuildBitstream(messages[0], modeName, cycle, frame) + if err != nil { + return nil, 0, 0, err + } + + wav := modulateTransmission(bits) + nSymbols := symbolCount(bits) + numSamples := 0 + if len(wav) >= 44 { + dataSize := int(wav[40]) | int(wav[41])<<8 | int(wav[42])<<16 | int(wav[43])<<24 + numSamples = dataSize / 2 + } + return wav, nSymbols, numSamples, nil +} + +// EncodeToWAVFile writes a FLEX WAV file. +func EncodeToWAVFile(messages []EncodeMessage, outPath, modeName string, cycle, frame int) (int, int, error) { + wav, nBits, nSamples, err := EncodeToWAVBytes(messages, modeName, cycle, frame) + if err != nil { + return 0, 0, err + } + if err := os.WriteFile(outPath, wav, 0644); err != nil { + return 0, 0, err + } + return nBits, nSamples, nil +} diff --git a/flex.go b/flex.go index efa8cf2..0565031 100644 --- a/flex.go +++ b/flex.go @@ -60,6 +60,7 @@ type Message struct { LongAddress bool `json:"LongAddress"` Type PageType `json:"Function"` Text string `json:"Message"` + Frag byte `json:"Frag"` FragFlag byte `json:"FragFlag,omitempty"` Cycle int `json:"Cycle"` Frame int `json:"Frame"` @@ -179,7 +180,7 @@ func DecodePhase(words []uint32, corr []int, frame, cycle, baud, levels int, pha switch vt { case PageAlphanumeric, PageSecure: - msg.Text, msg.FragFlag, msg.Corrected = decodeAlpha(words, corr, viw, longAddr, vIdx, msg.Corrected) + msg.Text, msg.Frag, msg.FragFlag, msg.Corrected = decodeAlpha(words, corr, viw, longAddr, vIdx, msg.Corrected) out = append(out, msg) case PageStdNumeric, PageSpecialNum, PageNumberedNum: @@ -248,41 +249,48 @@ func decodeLongCapcode(aw1, aw2 uint32) int64 { // // The word at mw1 is the header word: // - bits 12..11 → frag (0=last/only, 1..2=middle, 3=first) -// - bit 10 → cont (1=fragment, 0=final piece) +// - bit 10 → cont // -// Characters are packed three-per-word as 7-bit values at bits 0, 7, 14. -// ETX (0x03) terminates the message. The first character of mw1 is only -// output when frag == 3 (i.e. message begins at this word). -func decodeAlpha(words []uint32, corr []int, viw uint32, longAddr bool, vIdx int, corrSoFar int) (text string, fragFlag byte, corrTotal int) { +// The message characters are packed 7-bits each. +func decodeAlpha(words []uint32, corr []int, viw uint32, longAddr bool, vIdx int, corrSoFar int) (text string, frag byte, fragFlag byte, corrTotal int) { corrTotal = corrSoFar - // Decode w1/w2 from VIW exactly as multimon does: - // int w1 = viw >> 7; - // int w2 = w1 >> 7; - // w1 = w1 & 0x7f; - // w2 = (w2 & 0x7f) + w1 - 1; tmp := viw >> 7 tmp2 := tmp >> 7 mw1 := int(tmp & 0x7F) mw2 := int((tmp2&0x7F)+uint32(mw1)) - 1 - if mw1 <= 0 || mw2 < mw1 || mw2 >= len(words) { - return "", 'K', corrTotal - } + var hw uint32 + var actualMw1 int + var actualMw2 int - // Long address: the second vector word is consumed as a data header, - // so the actual message start is one later and end is one earlier. if longAddr { - // mw1 is already the data start (PDW: w2-- for long addresses) - mw2-- - if mw2 < mw1 { - return "", 'K', corrTotal + // For long addresses, the header word is the SECOND vector word (vIdx + 1). + if vIdx+1 >= len(words) { + return "", 0, 'K', corrTotal } + hw = words[vIdx+1] + actualMw1 = mw1 + actualMw2 = mw2 - 1 + if corr != nil && vIdx+1 < len(corr) && corr[vIdx+1] > 0 { + corrTotal += corr[vIdx+1] + } + } else { + // For short addresses, the header word is at mw1. + if mw1 >= len(words) { + return "", 0, 'K', corrTotal + } + hw = words[mw1] + actualMw1 = mw1 + 1 + actualMw2 = mw2 + } + + if actualMw1 <= 0 || actualMw2 < actualMw1 || actualMw2 >= len(words) { + return "", 0, 'K', corrTotal } - // Header word — extract frag / cont bits, then advance mw1. - hw := words[mw1] - frag := (hw >> 11) & 0x03 + // Header word — extract frag / cont bits + frag = byte((hw >> 11) & 0x03) cont := (hw >> 10) & 0x01 switch { @@ -296,49 +304,65 @@ func decodeAlpha(words []uint32, corr []int, viw uint32, longAddr bool, vIdx int // Collect BCH corrections for message words. if corr != nil { - for k := mw1; k <= mw2 && k < len(corr); k++ { + for k := actualMw1; k <= actualMw2 && k < len(corr); k++ { if corr[k] > 0 { corrTotal += corr[k] } } } - mw1++ // move past header word - var sb strings.Builder - for wi := mw1; wi <= mw2; wi++ { + + // For short addresses, the header word (mw1) contains characters. + // For long addresses, the header word (vIdx+1) is purely structural and contains NO characters. + // PDW skips the header word for short-address complete messages (frag==3). + if !longAddr && frag != 0x03 { + ch3 := byte((hw >> 14) & 0x7F) + if ch3 == 0x03 { + return sb.String(), frag, fragFlag, corrTotal + } + if ch3 >= 0x20 { + sb.WriteByte(ch3) + } + } + + // Extract characters from data words + firstWord := true + for wi := actualMw1; wi <= actualMw2; wi++ { dw := words[wi] - // First character of the first word is only included when - // frag == 3 (meaning this frame starts a new message). - if wi > mw1 || frag != 0x03 { - ch := byte(dw & 0x7F) - if ch == 0x03 { - return sb.String(), fragFlag, corrTotal + ch1 := byte(dw & 0x7F) + // First content word bits 0-6 hold the signature; PDW skips them on complete messages. + skipCh1 := firstWord && frag == 0x03 + firstWord = false + + if !skipCh1 { + if ch1 == 0x03 { + return sb.String(), frag, fragFlag, corrTotal } - if ch >= 0x20 { - sb.WriteByte(ch) + if ch1 >= 0x20 { + sb.WriteByte(ch1) } } - ch := byte((dw >> 7) & 0x7F) - if ch == 0x03 { - return sb.String(), fragFlag, corrTotal + ch2 := byte((dw >> 7) & 0x7F) + if ch2 == 0x03 { + return sb.String(), frag, fragFlag, corrTotal } - if ch >= 0x20 { - sb.WriteByte(ch) + if ch2 >= 0x20 { + sb.WriteByte(ch2) } - ch = byte((dw >> 14) & 0x7F) - if ch == 0x03 { - return sb.String(), fragFlag, corrTotal + ch3 := byte((dw >> 14) & 0x7F) + if ch3 == 0x03 { + return sb.String(), frag, fragFlag, corrTotal } - if ch >= 0x20 { - sb.WriteByte(ch) + if ch3 >= 0x20 { + sb.WriteByte(ch3) } } - return sb.String(), fragFlag, corrTotal + return sb.String(), frag, fragFlag, corrTotal } // ─── Numeric ────────────────────────────────────────────────────────────────── diff --git a/make.bat b/make.bat index b41f0cd..c67298c 100644 --- a/make.bat +++ b/make.bat @@ -1,33 +1,108 @@ @echo off -SETLOCAL +setlocal EnableDelayedExpansion -IF "%1"=="build" GOTO Build -IF "%1"=="clean" GOTO Clean -IF "%1"=="test" GOTO Test -GOTO Help +REM Default version if not provided +if "%VERSION%"=="" set VERSION=1.0.0 -:Build -echo Building flex-decode... +REM Build metadata +for /f %%i in ('powershell -NoProfile -Command "Get-Date -Format yyyy-MM-dd_HH:mm:ss"') do set BUILD_TIME=%%i +for /f %%i in ('git rev-parse --short HEAD 2^>nul') do set GIT_COMMIT=%%i +if "%GIT_COMMIT%"=="" set GIT_COMMIT=unknown + +for /f %%i in ('go env GOOS') do set GOOS_VAL=%%i +for /f %%i in ('go env GOARCH') do set GOARCH_VAL=%%i +set BUILD_ARCH=%GOOS_VAL%/%GOARCH_VAL% + +for /f "tokens=3" %%i in ('go version') do set GO_VERSION=%%i + +REM LDFLAGS +set LDFLAGS=-X github.com/sqpp/flex-golang.Version=%VERSION% ^ + -X github.com/sqpp/flex-golang.BuildTime=%BUILD_TIME% ^ + -X github.com/sqpp/flex-golang.GitCommit=%GIT_COMMIT% ^ + -X github.com/sqpp/flex-golang.Author=marcell ^ + -X github.com/sqpp/flex-golang.ProjectURL=https://pagercast.com ^ + -X github.com/sqpp/flex-golang.BuildArch=%BUILD_ARCH% ^ + -X github.com/sqpp/flex-golang.BuildGoVer=%GO_VERSION% + +REM Create bin folder if not exist bin mkdir bin -go build -o bin/flex-decode.exe ./cmd/flex-decode -echo Build complete! Binary is in the bin\ directory. -GOTO End -:Clean -echo Cleaning... -if exist bin rmdir /s /q bin -echo Cleaned. -GOTO End +if "%1"=="" goto build -:Test +if "%1"=="build" goto build +if "%1"=="install" goto install +if "%1"=="test" goto test +if "%1"=="clean" goto clean +if "%1"=="version" goto version +if "%1"=="cross-compile" goto cross +if "%1"=="help" goto help + +:build +echo Building FLEX-GO v%VERSION%... +go build -ldflags "%LDFLAGS%" -o bin\flex-encode.exe ./cmd/flex-encode +go build -ldflags "%LDFLAGS%" -o bin\flex-decode.exe ./cmd/flex-decode +echo Build complete! +goto end + +:install +go install -ldflags "%LDFLAGS%" ./cmd/flex-encode +go install -ldflags "%LDFLAGS%" ./cmd/flex-decode +goto end + +:test go test -v ./... -GOTO End +goto end + +:clean +rmdir /s /q bin +goto end + +:version +echo Version: %VERSION% +echo Build Time: %BUILD_TIME% +echo Git Commit: %GIT_COMMIT% +goto end + +:cross +echo Cross-compiling... +set GOOS=linux +set GOARCH=amd64 +go build -ldflags "%LDFLAGS%" -o bin\flex-encode-linux-amd64 ./cmd/flex-encode +go build -ldflags "%LDFLAGS%" -o bin\flex-decode-linux-amd64 ./cmd/flex-decode + +set GOOS=linux +set GOARCH=arm64 +go build -ldflags "%LDFLAGS%" -o bin\flex-encode-linux-arm64 ./cmd/flex-encode +go build -ldflags "%LDFLAGS%" -o bin\flex-decode-linux-arm64 ./cmd/flex-decode + +set GOOS=windows +set GOARCH=amd64 +go build -ldflags "%LDFLAGS%" -o bin\flex-encode-windows-amd64.exe ./cmd/flex-encode +go build -ldflags "%LDFLAGS%" -o bin\flex-decode-windows-amd64.exe ./cmd/flex-decode + +set GOOS=darwin +set GOARCH=amd64 +go build -ldflags "%LDFLAGS%" -o bin\flex-encode-darwin-amd64 ./cmd/flex-encode +go build -ldflags "%LDFLAGS%" -o bin\flex-decode-darwin-amd64 ./cmd/flex-decode + +set GOOS=darwin +set GOARCH=arm64 +go build -ldflags "%LDFLAGS%" -o bin\flex-encode-darwin-arm64 ./cmd/flex-encode +go build -ldflags "%LDFLAGS%" -o bin\flex-decode-darwin-arm64 ./cmd/flex-decode + +echo Cross-compilation complete! +goto end -:Help -echo Usage: make.bat [build^|clean^|test] -echo build - Compiles flex-decode.exe into bin\ -echo clean - Removes the bin\ directory -echo test - Runs tests +:help +echo Available targets: +echo build +echo install +echo test +echo clean +echo version +echo cross-compile +echo help +goto end -:End -ENDLOCAL +:end +endlocal diff --git a/mode_multiphase.go b/mode_multiphase.go new file mode 100644 index 0000000..fc01a49 --- /dev/null +++ b/mode_multiphase.go @@ -0,0 +1,109 @@ +package flex + +// frameNumPhases returns how many FLEX phases are active for a baud/level pair. +// Matches decoder.go stData / decodeBlock. +func frameNumPhases(baud, levels int) int { + n := (baud / 1600) * (levels / 2) + if n < 1 { + n = 1 + } + if n > 4 { + n = 4 + } + return n +} + +// frameDataSymbols is the number of channel symbols in one FLEX data block. +func frameDataSymbols(baud int) int { + return baud * 1760 / 1000 +} + +// codewordsToInterleavedBits expands 88 codewords into the block-interleaved bit order +// used on a single FLEX phase (2816 bits). +func codewordsToInterleavedBits(codewords []uint32) []byte { + bits := make([]byte, 0, PhaseBits) + for block := 0; block < 11; block++ { + base := block * 8 + for bit := 0; bit < 32; bit++ { + for cwInBlock := 0; cwInBlock < 8; cwInBlock++ { + cw := base + cwInBlock + if cw >= len(codewords) { + bits = append(bits, 0) + continue + } + bits = append(bits, byte((codewords[cw]>>uint(bit))&1)) + } + } + } + return bits +} + +func makeIdlePhaseCodewords() []uint32 { + cws := make([]uint32, flexCodewords) + for i := range cws { + cws[i] = idleCodeword(i) + } + return cws +} + +// makePhaseCodewords builds per-phase codeword arrays; phase A carries the page. +func makePhaseCodewords(primary []uint32, baud, levels int) [][]uint32 { + numP := frameNumPhases(baud, levels) + phases := make([][]uint32, numP) + phases[0] = primary + for p := 1; p < numP; p++ { + phases[p] = makeIdlePhaseCodewords() + } + return phases +} + +func dibitToSymbol(msb, lsb byte) byte { + return (msb << 1) | lsb +} + +// appendMultiphaseSymbols appends the data-block symbol stream (2-level bits or 4-level 0..3). +func appendMultiphaseSymbols(out *[]byte, phaseCodewords [][]uint32, baud, levels int) { + numP := frameNumPhases(baud, levels) + symCount := frameDataSymbols(baud) + + phaseBits := make([][]byte, numP) + for p := 0; p < numP; p++ { + cw := phaseCodewords[p] + if len(cw) < flexCodewords { + padded := makeIdlePhaseCodewords() + copy(padded, cw) + cw = padded + } + phaseBits[p] = codewordsToInterleavedBits(cw) + } + + if levels == 2 { + for sym := 0; sym < symCount; sym++ { + phase := sym % numP + bitIdx := sym / numP + b := byte(0) + if bitIdx < len(phaseBits[phase]) { + b = phaseBits[phase][bitIdx] + } + *out = append(*out, b) + } + return + } + + pairs := numP / 2 + for sym := 0; sym < symCount; sym++ { + pair := sym % pairs + bitIdx := sym / pairs + msbP := pair * 2 + lsbP := msbP + 1 + msb := byte(0) + lsb := byte(0) + if bitIdx < len(phaseBits[msbP]) { + msb = phaseBits[msbP][bitIdx] + } + if bitIdx < len(phaseBits[lsbP]) { + lsb = phaseBits[lsbP][bitIdx] + } + *out = append(*out, dibitToSymbol(msb, lsb)) + } +} diff --git a/modulate.go b/modulate.go new file mode 100644 index 0000000..7d41987 --- /dev/null +++ b/modulate.go @@ -0,0 +1,133 @@ +package flex + +import ( + "bytes" + "encoding/binary" + "math" +) + +const ( + // EncoderSampleRate matches FLEX reference captures (44100 Hz). + EncoderSampleRate = 44100 + // PDW: high audio = mark (bit 1), low audio = space (bit 0). + flexMarkLevel int16 = 21400 + flexSpaceLevel int16 = -21400 + // Inner 4-level steps sit between mark/space for the native demodulator. + flex4LevelNegInner int16 = -10000 + flex4LevelPosInner int16 = 10000 +) + +// flexTransmission holds header (1600 baud), data symbols, and trailer (1600 baud). +type flexTransmission struct { + header []byte + data []byte + trailer []byte + dataBaud int + levels int +} + +func symbolCount(tx flexTransmission) int { + return len(tx.header) + len(tx.data) + len(tx.trailer) +} + +// modulateTransmission converts a FLEX frame to 16-bit mono baseband WAV. +func modulateTransmission(tx flexTransmission) []byte { + sampleRate := float64(EncoderSampleRate) + headerSPB := sampleRate / 1600.0 + dataSPB := sampleRate / float64(tx.dataBaud) + + headerSamples := int(math.Ceil(float64(len(tx.header)) * headerSPB)) + dataSamples := int(math.Ceil(float64(len(tx.data)) * dataSPB)) + trailerSamples := int(math.Ceil(float64(len(tx.trailer)) * headerSPB)) + audio := make([]int16, headerSamples+dataSamples+trailerSamples) + + write2Level := func(offset int, spb float64, symbols []byte) { + for i, sym := range symbols { + sample := flexSpaceLevel + if sym&1 != 0 { + sample = flexMarkLevel + } + start := offset + int(math.Round(float64(i)*spb)) + end := offset + int(math.Round(float64(i+1)*spb)) + if end > len(audio) { + end = len(audio) + } + for j := start; j < end; j++ { + audio[j] = sample + } + } + } + + write4Level := func(offset int, spb float64, symbols []byte) { + levels := []int16{flexSpaceLevel, flex4LevelNegInner, flex4LevelPosInner, flexMarkLevel} + for i, sym := range symbols { + idx := int(sym) & 3 + sample := levels[idx] + start := offset + int(math.Round(float64(i)*spb)) + end := offset + int(math.Round(float64(i+1)*spb)) + if end > len(audio) { + end = len(audio) + } + for j := start; j < end; j++ { + audio[j] = sample + } + } + } + + write2Level(0, headerSPB, tx.header) + dataOffset := headerSamples + if tx.levels == 2 { + write2Level(dataOffset, dataSPB, tx.data) + } else { + write4Level(dataOffset, dataSPB, tx.data) + } + write2Level(dataOffset+dataSamples, headerSPB, tx.trailer) + + return createWAVFileAtRate(audio, EncoderSampleRate) +} + +// modulateBits converts a FLEX bitstream to 16-bit mono baseband WAV for PDW. +// Deprecated: use modulateTransmission via EncodeToWAVBytes. +func modulateBits(bits []byte, baudRate int) []byte { + return modulateTransmission(flexTransmission{ + header: bits, + data: nil, + dataBaud: baudRate, + levels: 2, + }) +} + +func createWAVFileAtRate(samples []int16, sampleRate uint32) []byte { + const ( + numChannels = 1 + bitsPerSample = 16 + ) + var buf bytes.Buffer + + dataSize := uint32(len(samples) * 2) + fileSize := 36 + dataSize + byteRate := uint32(sampleRate * numChannels * bitsPerSample / 8) + blockAlign := uint16(numChannels * bitsPerSample / 8) + + buf.WriteString("RIFF") + binary.Write(&buf, binary.LittleEndian, fileSize) + buf.WriteString("WAVE") + + buf.WriteString("fmt ") + binary.Write(&buf, binary.LittleEndian, uint32(16)) + binary.Write(&buf, binary.LittleEndian, uint16(1)) + binary.Write(&buf, binary.LittleEndian, uint16(numChannels)) + binary.Write(&buf, binary.LittleEndian, sampleRate) + binary.Write(&buf, binary.LittleEndian, byteRate) + binary.Write(&buf, binary.LittleEndian, blockAlign) + binary.Write(&buf, binary.LittleEndian, uint16(bitsPerSample)) + + buf.WriteString("data") + binary.Write(&buf, binary.LittleEndian, dataSize) + + for _, sample := range samples { + binary.Write(&buf, binary.LittleEndian, sample) + } + + return buf.Bytes() +} diff --git a/pdw_ecd.go b/pdw_ecd.go new file mode 100644 index 0000000..f0c62a1 --- /dev/null +++ b/pdw_ecd.go @@ -0,0 +1,170 @@ +package flex + +// PDW FLEX codeword decode path (Flex.cpp showblock + Misc.cpp ecd). + +var ( + pdwECS [21]int + pdwBCH [1024]int +) + +func init() { + setupPDWECC() +} + +func setupPDWECC() { + srr := 0x3B4 + for i := 0; i <= 20; i++ { + pdwECS[i] = srr + if srr&0x01 != 0 { + srr = (srr >> 1) ^ 0x3B4 + } else { + srr >>= 1 + } + } + + for i := range pdwBCH { + pdwBCH[i] = 0 + } + + for n := 0; n <= 20; n++ { + for i := 0; i <= 20; i++ { + j := (i << 5) + n + k := pdwECS[n] ^ pdwECS[i] + pdwBCH[k] = j + 0x2000 + } + } + + for n := 0; n <= 20; n++ { + k := pdwECS[n] + j := n + (0x1f << 5) + pdwBCH[k] = j + 0x1000 + } + + for n := 0; n <= 20; n++ { + for i := 0; i < 10; i++ { + k := pdwECS[n] ^ (1 << i) + j := n + (0x1f << 5) + pdwBCH[k] = j + 0x2000 + } + } + + for n := 0; n < 10; n++ { + pdwBCH[1<>= 1 + } + return k +} + +func pdwECD(ob *[32]byte) int { + ecc := 0 + parity := 0 + + for i := 0; i <= 20; i++ { + if ob[i] == 1 { + ecc ^= pdwECS[i] + parity ^= 0x01 + } + } + + acc := 0 + for i := 21; i <= 30; i++ { + acc <<= 1 + if ob[i] == 1 { + acc ^= 0x01 + } + } + + synd := ecc ^ acc + errors := 0 + + if synd != 0 { + entry := pdwBCH[synd&0x3FF] + if entry != 0 { + b1 := entry & 0x1f + b2 := (entry >> 5) & 0x1f + + if b2 != 0x1f { + ob[b2] ^= 1 + ecc ^= pdwECS[b2] + } + if b1 != 0x1f { + ob[b1] ^= 1 + ecc ^= pdwECS[b1] + } + errors = entry >> 12 + } else { + errors = 3 + } + + if errors == 1 { + parity ^= 0x01 + } + } + + parity = (parity + pdwBit10(ecc)) & 0x01 + if parity != int(ob[31]) { + errors++ + } + if errors > 3 { + errors = 3 + } + return errors +} + +func wireToOB(wire uint32) [32]byte { + var ob [32]byte + for i := 0; i < 32; i++ { + bit := (wire >> uint(i)) & 1 + ob[i] = byte(bit ^ 1) // PDW ob[] is active-low vs demod wire bits + } + return ob +} + +// PDWDecodeWire decodes a demodulated 32-bit wire word the way PDW does. +func PDWDecodeWire(wire uint32) (logical int64, errLevel int) { + ob := wireToOB(wire) + errLevel = pdwECD(&ob) + + var cc int64 + for j := 0; j < 21; j++ { + cc >>= 1 + if ob[j] == 0 { + cc ^= 0x100000 + } + } + if errLevel == 3 { + cc ^= 0x400000 + } + return cc, errLevel +} + +func pdwXsumchk(l int64) bool { + if l > 0x3FFFFF { + return false + } + xs := int(l & 0x0F) + xs += int((l >> 4) & 0x0F) + xs += int((l >> 8) & 0x0F) + xs += int((l >> 12) & 0x0F) + xs += int((l >> 16) & 0x0F) + xs += int((l >> 20) & 0x01) + return (xs & 0x0F) == 0x0F +} diff --git a/testexport.go b/testexport.go new file mode 100644 index 0000000..b8c9a1b --- /dev/null +++ b/testexport.go @@ -0,0 +1,89 @@ +package flex + +// testexport.go exposes internal symbols for white-box tests in ./tests. +// These are not part of the stable public API. + +func ExportBuildFIWData(cycle, frame int) uint32 { return buildFIWData(cycle, frame) } + +func ExportEncodeWord(data21 uint32) uint32 { return encodeWord(data21) } + +func ExportBuildBIW(voffset, aoffset int) uint32 { return buildBIW(voffset, aoffset) } + +func ExportFlexEncodeWord(logical21 uint32) uint32 { return flexEncodeWord(logical21) } + +func ExportIdleCodeword(i int) uint32 { return idleCodeword(i) } + +func ExportFlexCodewordCount() int { return flexCodewords } + +func ExportBitstreamFromCodewords(codewords []uint32, modeName string, cycle, frame int) ([]byte, error) { + mode, err := getEncodeMode(modeName) + if err != nil { + return nil, err + } + tx, err := bitstreamFromCodewords(codewords, mode, cycle, frame) + if err != nil { + return nil, err + } + return modulateTransmission(tx), nil +} + +func ExportBitstreamWithFIW(fiw uint32, fiwMSB bool) ([]byte, error) { + codewords, mode, err := assembleCodewords(EncodeMessage{ + Capcode: 1913, Type: "alpha", Text: "HELLO WORLD", + }, Mode1600_2, 0, 0) + if err != nil { + return nil, err + } + tx := buildTransmission(codewords, mode, 0, 0) + const fiwOffset = 1040 + rebuilt := append([]byte(nil), tx.header[:fiwOffset]...) + if fiwMSB { + appendBitsMSB(&rebuilt, fiw, 32) + } else { + appendBitsLSB(&rebuilt, fiw, 32) + } + rebuilt = append(rebuilt, tx.header[fiwOffset+32:]...) + tx.header = rebuilt + return modulateTransmission(tx), nil +} + +func ExportEncodeFIWDirect(logical21 uint32) uint32 { + poc := BCHEncode31_21(logical21 & 0x1FFFFF) + cw := (poc >> 10) | ((poc & 0x3FF) << 21) + if popCount32(cw)&1 != 0 { + cw |= 1 << 31 + } + return cw +} + +func ExportCheckSync1(buf uint64) (uint16, bool) { return checkSync1(buf) } + +func ExportEncodeAlphaPayload(text string, maxWords int, skipFirstChar bool) []uint32 { + return encodeAlphaPayload(text, maxWords, skipFirstChar) +} + +func ExportAlphaSignature(msgWords []uint32) uint32 { return alphaSignature(msgWords) } + +func ExportAlphaHeaderChecksum(header uint32, content []uint32) uint32 { + return alphaHeaderChecksum(header, content) +} + +func ExportPDWXsumchk(l int64) bool { return pdwXsumchk(l) } + +func ExportReverse21(v uint32) uint32 { return reverse21(v) } + +func ExportBCHEncode31_21(v uint32) uint32 { return BCHEncode31_21(v) } + +func ExportPopCount32(v uint32) int { return popCount32(v) } + +func ExportReverse32(v uint32) uint32 { return reverse32(v) } + +func ExportFLEXChecksum(info uint32) bool { return FLEXChecksum(info) } + +func ExportBuildBitstreamHeader(msg EncodeMessage, modeName string, cycle, frame int) ([]byte, error) { + tx, err := BuildBitstream(msg, modeName, cycle, frame) + if err != nil { + return nil, err + } + return tx.header, nil +} diff --git a/tests/encoder_modes_test.go b/tests/encoder_modes_test.go new file mode 100644 index 0000000..a1d0ef1 --- /dev/null +++ b/tests/encoder_modes_test.go @@ -0,0 +1,82 @@ +package flex_test + +import ( + "os" + "testing" + + flex "github.com/sqpp/flex-golang" +) + +func TestEncodeDecodeAllModes(t *testing.T) { + msg := flex.EncodeMessage{Capcode: 123456, Type: "alpha", Text: "MODE TEST"} + + for _, modeName := range flex.EncodeModeNames() { + t.Run(modeName, func(t *testing.T) { + wav, _, _, err := flex.EncodeToWAVBytes([]flex.EncodeMessage{msg}, modeName, 0, 42) + if err != nil { + t.Fatal(err) + } + decoded, err := flex.DecodeFromAudio(wav) + if err != nil { + t.Fatal(err) + } + if len(decoded) == 0 { + t.Fatalf("%s: no messages decoded", modeName) + } + found := false + for _, m := range decoded { + if m.Capcode == 123456 && m.Text == "MODE TEST" { + found = true + if m.Baud == 0 { + t.Logf("baud not set on message") + } + t.Logf("%s: cap=%d baud=%d levels=%d phase=%c text=%q", + modeName, m.Capcode, m.Baud, m.Levels, m.Phase, m.Text) + } + } + if !found { + t.Fatalf("%s: cap 123456 not found in %+v", modeName, decoded) + } + }) + } +} + +func TestEncodeReferenceAllModesWAV(t *testing.T) { + msg := flex.EncodeMessage{ + Capcode: flex.ReferenceCapcode1913, + Type: "alpha", + Text: flex.ReferenceMessage1913, + } + for _, modeName := range flex.EncodeModeNames() { + wav, nSym, nSamples, err := flex.EncodeToWAVBytes([]flex.EncodeMessage{msg}, modeName, flex.ReferenceCycle1913, flex.ReferenceFrame1913) + if err != nil { + t.Fatalf("%s: %v", modeName, err) + } + if len(wav) < 44 || nSym == 0 || nSamples == 0 { + t.Fatalf("%s: bad output len=%d sym=%d samples=%d", modeName, len(wav), nSym, nSamples) + } + } +} + +func TestReferenceWAVFilesStillDecode(t *testing.T) { + files := map[string]string{ + "1600": "./test_1600.wav", + "3200": "./test_3200.wav", + "6400": "./test_6400.wav", + } + for label, path := range files { + data, err := os.ReadFile(path) + if err != nil { + t.Logf("skip %s: %v", label, err) + continue + } + msgs, err := flex.DecodeFromAudio(data) + if err != nil { + t.Fatalf("%s: %v", label, err) + } + t.Logf("%s: decoded %d messages", label, len(msgs)) + if len(msgs) == 0 { + t.Fatalf("%s: expected messages", label) + } + } +} diff --git a/tests/encoder_roundtrip_test.go b/tests/encoder_roundtrip_test.go new file mode 100644 index 0000000..3b95537 --- /dev/null +++ b/tests/encoder_roundtrip_test.go @@ -0,0 +1,15 @@ +package flex_test + +import ( + "testing" + + flex "github.com/sqpp/flex-golang" +) + +func TestBIWEncoded(t *testing.T) { + cw := flex.ExportBuildBIW(2, 0) + info, errs := flex.FLEXBCHDecode32(cw) + if errs < 0 || !flex.ExportFLEXChecksum(info) { + t.Fatalf("BIW decode failed: info=0x%X errs=%d", info, errs) + } +} diff --git a/tests/encoder_sync_test.go b/tests/encoder_sync_test.go new file mode 100644 index 0000000..5c196ec --- /dev/null +++ b/tests/encoder_sync_test.go @@ -0,0 +1,53 @@ +package flex_test + +import ( + "testing" + + flex "github.com/sqpp/flex-golang" +) + +func TestSyncInBitstream(t *testing.T) { + header, err := flex.ExportBuildBitstreamHeader(flex.EncodeMessage{ + Capcode: 1913, Type: "alpha", Text: "X", + }, flex.Mode1600_2, 0, 0) + if err != nil { + t.Fatal(err) + } + + var syncReg uint64 + syncAt := -1 + for i, bit := range header { + b := uint64(0) + if bit == 0 { + b = 1 + } + syncReg = (syncReg << 1) | b + if i >= 63 { + if _, ok := flex.ExportCheckSync1(syncReg); ok { + syncAt = i + break + } + } + } + if syncAt < 0 { + t.Fatal("sync not found") + } + t.Logf("sync at bit %d (header symbols %d)", syncAt, len(header)) +} + +func TestPushPerfectSquares(t *testing.T) { + wav, _, _, err := flex.EncodeToWAVBytes([]flex.EncodeMessage{{ + Capcode: 1913, Type: "alpha", Text: "HELLO WORLD", + }}, flex.Mode1600_2, 0, 0) + if err != nil { + t.Fatal(err) + } + msgs, err := flex.DecodeFromAudio(wav) + if err != nil { + t.Fatal(err) + } + if len(msgs) == 0 { + t.Fatal("no sync on encoded WAV") + } + t.Logf("decoded: %+v", msgs[0]) +} diff --git a/tests/encoder_test.go b/tests/encoder_test.go new file mode 100644 index 0000000..bb4d1f9 --- /dev/null +++ b/tests/encoder_test.go @@ -0,0 +1,47 @@ +package flex_test + +import ( + "os" + "testing" + + flex "github.com/sqpp/flex-golang" +) + +func TestFlexCodewordRoundtrip(t *testing.T) { + for _, logical := range []uint32{ + flex.ExportBuildFIWData(0, 0), + flex.ExportBuildFIWData(5, 42), + } { + cw := flex.ExportEncodeWord(logical) + got, errs := flex.FLEXBCHDecode32(cw) + if got != logical || errs != 0 || !flex.ExportFLEXChecksum(got) { + t.Fatalf("logical=0x%X cw=0x%08X got=0x%X errs=%d", logical, cw, got, errs) + } + } +} + +func TestEncodeToWAV(t *testing.T) { + msg := flex.EncodeMessage{Capcode: 1913, Type: "alpha", Text: "HELLO WORLD"} + wav, nBits, nSamples, err := flex.EncodeToWAVBytes([]flex.EncodeMessage{msg}, flex.Mode1600_2, 0, 0) + if err != nil { + t.Fatal(err) + } + if len(wav) < 44 || nBits == 0 || nSamples == 0 { + t.Fatalf("wav=%d bits=%d samples=%d", len(wav), nBits, nSamples) + } +} + +func TestDecodeReferenceWAV(t *testing.T) { + data, err := os.ReadFile("./test_6400.wav") + if err != nil { + t.Skip("./test_6400.wav not available") + } + msgs, err := flex.DecodeFromAudio(data) + if err != nil { + t.Fatal(err) + } + if len(msgs) == 0 { + t.Fatal("reference wav produced no messages") + } + t.Logf("reference decoded %d messages", len(msgs)) +} diff --git a/tests/fiw_alpha_test.go b/tests/fiw_alpha_test.go new file mode 100644 index 0000000..560dde0 --- /dev/null +++ b/tests/fiw_alpha_test.go @@ -0,0 +1,77 @@ +package flex_test + +import ( + "os" + "testing" + + flex "github.com/sqpp/flex-golang" +) + +func TestFIWAndAlphaLayout(t *testing.T) { + ref, _ := os.ReadFile("./test_1600.wav") + frames, _ := flex.DemodulateRawFrames(ref) + for _, rf := range frames { + info, _ := flex.FLEXBCHDecode32(rf.Words[1]) + if int64(info&0x1FFFFF)-0x8000 != 1913 { + continue + } + vec, _ := flex.FLEXBCHDecode32(rf.Words[2]) + w1 := (vec >> 7) & 0x7F + w2 := ((vec >> 14) & 0x7F) + w1 - 1 + t.Logf("ref frame cycle=%d frame=%d", rf.Cycle, rf.Frame) + t.Logf("ref vector logical=0x%05X start=%d end=%d type=%d", vec, w1, w2, (vec>>4)&7) + for i := int(w1); i <= int(w2) && i < 12; i++ { + log, _ := flex.FLEXBCHDecode32(rf.Words[i]) + t.Logf(" ref cw[%d] wire=0x%08X logical=0x%05X", i, rf.Words[i], log) + } + break + } + + ourWav, _, _, _ := flex.EncodeToWAVBytes([]flex.EncodeMessage{{ + Capcode: 1913, Type: "alpha", Text: "HELLO WORLD", + }}, flex.Mode1600_2, 0, 0) + ourFrames, _ := flex.DemodulateRawFrames(ourWav) + if len(ourFrames) == 0 { + t.Fatal("no our frames") + } + of := ourFrames[0] + vec, _ := flex.FLEXBCHDecode32(of.Words[2]) + w1 := (vec >> 7) & 0x7F + w2 := ((vec >> 14) & 0x7F) + w1 - 1 + t.Logf("our frame cycle=%d frame=%d", of.Cycle, of.Frame) + t.Logf("our vector logical=0x%05X start=%d end=%d type=%d", vec, w1, w2, (vec>>4)&7) + for i := 0; i < 10; i++ { + log, _ := flex.FLEXBCHDecode32(of.Words[i]) + t.Logf(" our cw[%d] wire=0x%08X logical=0x%05X", i, of.Words[i], log) + } + + msgs, _ := flex.DecodeFromAudio(ourWav) + for _, m := range msgs { + t.Logf("our decode: cap=%d text=%q", m.Capcode, m.Text) + } +} + +func TestFIWBitOrder(t *testing.T) { + for _, tc := range []struct { + name string + fiw uint32 + msb bool + }{ + {"refLayout_LSB", flex.ExportFlexEncodeWord(flex.ExportBuildFIWData(0, 0)), false}, + {"refLayout_MSB", flex.ExportFlexEncodeWord(flex.ExportBuildFIWData(0, 0)), true}, + {"direct_LSB", flex.ExportEncodeFIWDirect(flex.ExportBuildFIWData(0, 0)), false}, + {"direct_MSB", flex.ExportEncodeFIWDirect(flex.ExportBuildFIWData(0, 0)), true}, + } { + wav, err := flex.ExportBitstreamWithFIW(tc.fiw, tc.msb) + if err != nil { + t.Fatal(err) + } + frames, _ := flex.DemodulateRawFrames(wav) + cy, fr := -1, -1 + if len(frames) > 0 { + cy, fr = frames[0].Cycle, frames[0].Frame + } + t.Logf("%s fiw=0x%08X msb=%v -> demod cycle=%d frame=%d frames=%d", + tc.name, tc.fiw, tc.msb, cy, fr, len(frames)) + } +} diff --git a/tests/flex_decode_test.go b/tests/flex_decode_test.go index 104b3e2..0a2a7e1 100644 --- a/tests/flex_decode_test.go +++ b/tests/flex_decode_test.go @@ -15,9 +15,9 @@ import ( // FLEX: 1600/2/A/22/048 A:0001789 ALN: HELP! HELP! HELP! HELP! HELP! HELP! HELP! (frame 110) // FLEX: 1600/2/A/22/049 A:0001913 ALN: Call Me ASAP! (frame 111) func TestDecodeFlexWAV(t *testing.T) { - wavData, err := os.ReadFile("./test.wav") + wavData, err := os.ReadFile("./test_1600.wav") if err != nil { - t.Skip("./test.wav not found:", err) + t.Skip("./test_1600.wav not found:", err) } msgs, err := flex.DecodeFromAudio(wavData) diff --git a/tests/pdw_ecd_test.go b/tests/pdw_ecd_test.go new file mode 100644 index 0000000..18cf107 --- /dev/null +++ b/tests/pdw_ecd_test.go @@ -0,0 +1,57 @@ +package flex_test + +import ( + "os" + "testing" + + flex "github.com/sqpp/flex-golang" +) + +func TestPDWDecodeOurFrame(t *testing.T) { + ourWav, _, _, err := flex.EncodeToWAVBytes([]flex.EncodeMessage{{ + Capcode: 1913, Type: "alpha", Text: "HELLO WORLD", + }}, flex.Mode1600_2, 0, 0) + if err != nil { + t.Fatal(err) + } + frames, _ := flex.DemodulateRawFrames(ourWav) + if len(frames) == 0 { + t.Fatal("no frames") + } + + refData, _ := os.ReadFile("./test_1600.wav") + refFrames, _ := flex.DemodulateRawFrames(refData) + + var refFrame *flex.RawPhaseFrame + for i := range refFrames { + info, _ := flex.FLEXBCHDecode32(refFrames[i].Words[1]) + if int64(info&0x1FFFFF)-0x8000 == 1913 { + refFrame = &refFrames[i] + break + } + } + + of := frames[0] + for i := 0; i < 10; i++ { + goLog, goErrs := flex.FLEXBCHDecode32(of.Words[i]) + pdwLog, pdwErr := flex.PDWDecodeWire(of.Words[i]) + t.Logf("our cw[%d] wire=0x%08X go=0x%05X errs=%d xsum=%v pdw=0x%06X err=%d xsum=%v match=%v", + i, of.Words[i], goLog, goErrs, flex.ExportFLEXChecksum(goLog), + pdwLog&0x1FFFFF, pdwErr, flex.ExportPDWXsumchk(pdwLog), + (int64(goLog)&0x1FFFFF) == (pdwLog&0x1FFFFF)) + + if refFrame != nil { + refLog, refErr := flex.PDWDecodeWire(refFrame.Words[i]) + t.Logf(" ref pdw=0x%06X err=%d xsum=%v", refLog&0x1FFFFF, refErr, flex.ExportPDWXsumchk(refLog)) + } + } + + biw, _ := flex.PDWDecodeWire(of.Words[0]) + if !flex.ExportPDWXsumchk(biw) { + t.Error("BIW fails PDW xsumchk") + } + vec, _ := flex.PDWDecodeWire(of.Words[2]) + if !flex.ExportPDWXsumchk(vec) { + t.Error("vector fails PDW xsumchk") + } +} diff --git a/tests/pdw_showframe_test.go b/tests/pdw_showframe_test.go new file mode 100644 index 0000000..e98ff7e --- /dev/null +++ b/tests/pdw_showframe_test.go @@ -0,0 +1,62 @@ +package flex_test + +import ( + "testing" + + flex "github.com/sqpp/flex-golang" +) + +func TestPDWShowframeSimulation(t *testing.T) { + wav, _, _, err := flex.EncodeToWAVBytes([]flex.EncodeMessage{{ + Capcode: 1913, Type: "alpha", Text: "HELLO WORLD", + }}, flex.Mode1600_2, 3, 111) + if err != nil { + t.Fatal(err) + } + frames, _ := flex.DemodulateRawFrames(wav) + if len(frames) == 0 { + t.Fatal("no frames") + } + + frame := make([]int64, flex.PhaseWords) + for i := 0; i < flex.PhaseWords; i++ { + log, _ := flex.PDWDecodeWire(frames[0].Words[i]) + frame[i] = log + } + + if !flex.ExportPDWXsumchk(frame[0]) { + t.Fatal("BIW xsum fail") + } + + asa := int((frame[0]>>8)&0x03) + 1 + vsa := int((frame[0] >> 10) & 0x3F) + if vsa <= asa { + t.Fatalf("empty frame asa=%d vsa=%d", asa, vsa) + } + + j := asa + vb := vsa + j - asa + if !flex.ExportPDWXsumchk(frame[vb]) { + t.Fatalf("vector xsum fail at %d logical=0x%05X", vb, frame[vb]&0x1FFFFF) + } + vt := (frame[vb] >> 4) & 0x07 + if vt != 5 { + t.Fatalf("expected alpha vector type 5, got %d", vt) + } + + capcode := (frame[j] & 0x1FFFFF) - 32768 + if capcode != 1913 { + t.Fatalf("capcode=%d want 1913", capcode) + } + + w1 := int((frame[vb] >> 7) & 0x7F) + w2 := int(((frame[vb]>>14)&0x7F)+int64(w1)) - 1 + frag := int((frame[w1] >> 11) & 0x03) + t.Logf("capcode=%d frag=%d msg words %d..%d header=0x%05X", + capcode, frag, w1, w2, frame[w1]&0x1FFFFF) + + msgs, _ := flex.DecodeFromAudio(wav) + if len(msgs) != 1 || msgs[0].Text != "HELLO WORLD" { + t.Fatalf("decode: %+v", msgs) + } +} diff --git a/tests/pdw_sync_test.go b/tests/pdw_sync_test.go new file mode 100644 index 0000000..8b32855 --- /dev/null +++ b/tests/pdw_sync_test.go @@ -0,0 +1,36 @@ +package flex_test + +import ( + "testing" + + flex "github.com/sqpp/flex-golang" +) + +func TestPDWSyncMapping(t *testing.T) { + cases := []struct { + mode string + syncCode uint16 + symRate int + bitRate int + levels int + }{ + {flex.Mode1600_2, 0x870C, 1600, 1600, 2}, + {flex.Mode3200_2, 0x7B18, 3200, 3200, 2}, + {flex.Mode3200_4, 0xB068, 1600, 3200, 4}, + {flex.Mode1600_4, 0xB068, 1600, 3200, 4}, + {flex.Mode6400_4, 0xDEA0, 3200, 6400, 4}, + } + for _, tc := range cases { + m, err := flex.LookupEncodeMode(tc.mode) + if err != nil { + t.Fatalf("%s: %v", tc.mode, err) + } + if m.SyncCode != tc.syncCode || m.SymRate != tc.symRate || m.Levels != tc.levels { + t.Fatalf("%s: mode=%+v want sync=0x%04X sym=%d lev=%d", + tc.mode, m, tc.syncCode, tc.symRate, tc.levels) + } + if m.BitRate != tc.bitRate { + t.Fatalf("%s: bitRate=%d want %d", tc.mode, m.BitRate, tc.bitRate) + } + } +} diff --git a/tests/reference_compare_test.go b/tests/reference_compare_test.go new file mode 100644 index 0000000..6734c87 --- /dev/null +++ b/tests/reference_compare_test.go @@ -0,0 +1,62 @@ +package flex_test + +import ( + "os" + "testing" + + flex "github.com/sqpp/flex-golang" +) + +func TestCompareReferenceCodewords(t *testing.T) { + refData, err := os.ReadFile("./test_1600.wav") + if err != nil { + t.Skip("./test_1600.wav not available") + } + frames, err := flex.DemodulateRawFrames(refData) + if err != nil { + t.Fatal(err) + } + t.Logf("reference: %d raw frames", len(frames)) + + ourWav, _, _, err := flex.EncodeToWAVBytes([]flex.EncodeMessage{{ + Capcode: 1913, Type: "alpha", Text: "HELLO WORLD", + }}, flex.Mode1600_2, 0, 0) + if err != nil { + t.Fatal(err) + } + ourFrames, err := flex.DemodulateRawFrames(ourWav) + if err != nil { + t.Fatal(err) + } + t.Logf("ours: %d raw frames", len(ourFrames)) + + for _, rf := range frames { + info1, _ := flex.FLEXBCHDecode32(rf.Words[1]) + capcode := int64(info1&0x1FFFFF) - 0x8000 + if capcode != 1913 { + continue + } + t.Logf("=== reference frame cycle=%d frame=%d capcode=%d ===", rf.Cycle, rf.Frame, capcode) + for i := 0; i < 8; i++ { + logWire(t, "ref", i, rf.Words[i]) + } + if len(ourFrames) > 0 { + of := ourFrames[0] + t.Log("=== our frame 0 ===") + for i := 0; i < 8; i++ { + logWire(t, "our", i, of.Words[i]) + if rf.Words[i] != of.Words[i] { + t.Logf(" MISMATCH cw[%d] ref=0x%08X our=0x%08X", i, rf.Words[i], of.Words[i]) + } + } + } + return + } + t.Fatal("no reference frame with capcode 1913") +} + +func logWire(t *testing.T, tag string, idx int, wire uint32) { + logical, errs := flex.FLEXBCHDecode32(wire) + t.Logf("%s cw[%d] wire=0x%08X decode=0x%05X errs=%d xsum=%v", + tag, idx, wire, logical, errs, flex.ExportFLEXChecksum(logical)) +} diff --git a/tests/reference_k_test.go b/tests/reference_k_test.go new file mode 100644 index 0000000..8321a6c --- /dev/null +++ b/tests/reference_k_test.go @@ -0,0 +1,69 @@ +package flex_test + +import ( + "os" + "testing" + + flex "github.com/sqpp/flex-golang" +) + +func TestReferenceHeaderK(t *testing.T) { + refData, _ := os.ReadFile("./test_1600.wav") + refFrames, _ := flex.DemodulateRawFrames(refData) + var refFrame *flex.RawPhaseFrame + for i := range refFrames { + info, _ := flex.FLEXBCHDecode32(refFrames[i].Words[1]) + if int64(info&0x1FFFFF)-0x8000 == 1913 { + refFrame = &refFrames[i] + break + } + } + if refFrame == nil { + t.Fatal("no ref frame") + } + + refHeader, _ := flex.FLEXBCHDecode32(refFrame.Words[3]) + targetK := refHeader & 0x7FF + t.Logf("reference header=0x%05X K=0x%03X", refHeader, targetK) + + content := flex.ExportEncodeAlphaPayload(flex.ReferenceMessage1913, 84, true) + if len(content) == 0 { + t.Fatal("no content") + } + + type try struct { + name string + fn func() uint32 + } + tries := []try{ + {"after_sig", func() uint32 { + c := append([]uint32(nil), content...) + c[0] |= flex.ExportAlphaSignature(c) + return flex.ExportAlphaHeaderChecksum(0x1800, c) + }}, + {"before_sig", func() uint32 { + return flex.ExportAlphaHeaderChecksum(0x1800, content) + }}, + {"header_zero", func() uint32 { + c := append([]uint32(nil), content...) + c[0] |= flex.ExportAlphaSignature(c) + return flex.ExportAlphaHeaderChecksum(0, c) + }}, + {"with_frag_in_sum", func() uint32 { + c := append([]uint32(nil), content...) + c[0] |= flex.ExportAlphaSignature(c) + kSum := uint32(0) + hw := uint32(0x1800) + kSum += (hw & 0xFF) + ((hw >> 8) & 0xFF) + ((hw >> 16) & 0x1F) + for _, w := range c { + kSum += w & 0x1FFFFF + } + return (^kSum) & 0x3FF + }}, + } + + for _, tr := range tries { + k := tr.fn() + t.Logf("%s -> K=0x%03X match=%v", tr.name, k, k == targetK) + } +} diff --git a/tests/reference_message_test.go b/tests/reference_message_test.go new file mode 100644 index 0000000..5bcea8e --- /dev/null +++ b/tests/reference_message_test.go @@ -0,0 +1,76 @@ +package flex_test + +import ( + "os" + "testing" + + flex "github.com/sqpp/flex-golang" +) + +func TestEncodeReferenceMessage(t *testing.T) { + refData, err := os.ReadFile("./test_1600.wav") + if err != nil { + t.Skip("./test_1600.wav not available") + } + + var refFrame *flex.RawPhaseFrame + refFrames, err := flex.DemodulateRawFrames(refData) + if err != nil { + t.Fatal(err) + } + for i := range refFrames { + info, _ := flex.FLEXBCHDecode32(refFrames[i].Words[1]) + if int64(info&0x1FFFFF)-0x8000 == 1913 { + refFrame = &refFrames[i] + t.Logf("reference frame cycle=%d frame=%d", refFrames[i].Cycle, refFrames[i].Frame) + break + } + } + if refFrame == nil { + t.Fatal("no reference frame for capcode 1913") + } + + ourWav, _, _, err := flex.EncodeToWAVBytes([]flex.EncodeMessage{{ + Capcode: 1913, + Type: "alpha", + Text: flex.ReferenceMessage1913, + }}, flex.Mode1600_2, refFrame.Cycle, refFrame.Frame) + if err != nil { + t.Fatal(err) + } + ourFrames, err := flex.DemodulateRawFrames(ourWav) + if err != nil { + t.Fatal(err) + } + if len(ourFrames) == 0 { + t.Fatal("no encoded frames") + } + of := ourFrames[0] + + msgs, err := flex.DecodeFromAudio(ourWav) + if err != nil { + t.Fatal(err) + } + if len(msgs) != 1 { + t.Fatalf("expected 1 message, got %d: %+v", len(msgs), msgs) + } + t.Logf("our decode text=%q", msgs[0].Text) + if msgs[0].Text != flex.ReferenceMessage1913 { + t.Errorf("text mismatch:\n got: %q\nwant: %q", msgs[0].Text, flex.ReferenceMessage1913) + } + + matches := 0 + for i := 0; i < 12; i++ { + refWire := refFrame.Words[i] + ourWire := of.Words[i] + refLog, _ := flex.FLEXBCHDecode32(refWire) + ourLog, _ := flex.FLEXBCHDecode32(ourWire) + match := refWire == ourWire + if match { + matches++ + } + t.Logf("cw[%2d] ref=0x%08X our=0x%08X wire_match=%v ref_log=0x%05X our_log=0x%05X", + i, refWire, ourWire, match, refLog, ourLog) + } + t.Logf("wire matches in first 12 codewords: %d/12", matches) +} diff --git a/tests/test.wav b/tests/test_1600.wav similarity index 100% rename from tests/test.wav rename to tests/test_1600.wav diff --git a/tests/test_3200.wav b/tests/test_3200.wav new file mode 100644 index 0000000..6309222 Binary files /dev/null and b/tests/test_3200.wav differ diff --git a/tests/test_6400.wav b/tests/test_6400.wav new file mode 100644 index 0000000..d5de905 Binary files /dev/null and b/tests/test_6400.wav differ diff --git a/tests/wav_slice_test.go b/tests/wav_slice_test.go new file mode 100644 index 0000000..ce5e9e7 --- /dev/null +++ b/tests/wav_slice_test.go @@ -0,0 +1,88 @@ +package flex_test + +import ( + "os" + "testing" + + flex "github.com/sqpp/flex-golang" +) + +func TestReferenceWAVSlice(t *testing.T) { + refData, err := os.ReadFile("./test_1600.wav") + if err != nil { + t.Skip("no reference wav") + } + sliced, err := sliceWAVPDWStyle(refData) + if err != nil { + t.Fatal(err) + } + frames, _ := flex.DemodulateRawFrames(refData) + t.Logf("reference: sliced %d bits, PLL frames=%d", len(sliced), len(frames)) + if len(frames) > 0 { + t.Logf(" first frame cycle=%d frame=%d", frames[0].Cycle, frames[0].Frame) + } +} + +func TestBitstreamThroughWAVSlice(t *testing.T) { + wav, _, _, err := flex.EncodeToWAVBytes([]flex.EncodeMessage{{ + Capcode: 1913, Type: "alpha", Text: "HELLO WORLD", + }}, flex.Mode1600_2, 3, 111) + if err != nil { + t.Fatal(err) + } + + frames, _ := flex.DemodulateRawFrames(wav) + if len(frames) == 0 { + t.Fatal("PLL demod produced no frames") + } + t.Logf("PLL demod: cycle=%d frame=%d cw0=0x%08X cw1=0x%08X cw2=0x%08X", + frames[0].Cycle, frames[0].Frame, + frames[0].Words[0], frames[0].Words[1], frames[0].Words[2]) +} + +func sliceWAVPDWStyle(wav []byte) ([]byte, error) { + samples, rate, err := readWAVSamples(wav) + if err != nil { + return nil, err + } + spb := float64(rate) / 1600.0 + var bits []byte + center := 0.0 + bit := byte(0) + const thresh = 2000.0 + + for sym := 0; ; sym++ { + idx := int(float64(sym)*spb + spb*0.5) + if idx >= len(samples) { + break + } + val := float64(samples[idx]) + center = center*0.999 + val*0.001 + + if val > center+thresh && bit == 0 { + bit = 1 + } else if val < center-thresh && bit == 1 { + bit = 0 + } + bits = append(bits, bit) + } + return bits, nil +} + +func readWAVSamples(wav []byte) ([]int16, int, error) { + if len(wav) < 44 { + return nil, 0, nil + } + rate := int(wav[24]) | int(wav[25])<<8 | int(wav[26])<<16 | int(wav[27])<<24 + dataSize := int(wav[40]) | int(wav[41])<<8 | int(wav[42])<<16 | int(wav[43])<<24 + data := wav[44:] + if len(data) > dataSize { + data = data[:dataSize] + } + n := len(data) / 2 + samples := make([]int16, n) + for i := 0; i < n; i++ { + samples[i] = int16(data[i*2]) | int16(data[i+1])<<8 + } + return samples, rate, nil +} diff --git a/tests/wire_test.go b/tests/wire_test.go new file mode 100644 index 0000000..a9621df --- /dev/null +++ b/tests/wire_test.go @@ -0,0 +1,61 @@ +package flex_test + +import ( + "testing" + + flex "github.com/sqpp/flex-golang" +) + +func TestReferenceWireRoundtrip(t *testing.T) { + refBIW := uint32(0x19400807) + refAddr := uint32(0xC9808779) + + for name, cws := range map[string][]uint32{ + "ref_wire": {refBIW, refAddr, 0, 0, 0, 0, 0, 0}, + "our_wire": {flex.ExportFlexEncodeWord(0x807), flex.ExportFlexEncodeWord(0x8779), 0, 0, 0, 0, 0, 0}, + } { + codewords := make([]uint32, flex.ExportFlexCodewordCount()) + for i := range codewords { + codewords[i] = flex.ExportIdleCodeword(i) + } + copy(codewords, cws) + + wav, err := flex.ExportBitstreamFromCodewords(codewords, flex.Mode1600_2, 0, 0) + if err != nil { + t.Fatal(err) + } + frames, err := flex.DemodulateRawFrames(wav) + if err != nil { + t.Fatal(err) + } + if len(frames) == 0 { + t.Fatalf("%s: no frames", name) + } + t.Logf("%s roundtrip cw[0]=0x%08X cw[1]=0x%08X", name, frames[0].Words[0], frames[0].Words[1]) + } +} + +func refLayoutEncode(logical21 uint32) uint32 { + infoMSB := flex.ExportReverse21(logical21 & 0x1FFFFF) + poc := flex.ExportBCHEncode31_21(infoMSB) & 0x7FFFFFFF + rev := (poc << 1) | uint32(flex.ExportPopCount32(poc)&1) + return flex.ExportReverse32(rev) +} + +func TestFindEncodeForRef(t *testing.T) { + targets := map[string]uint32{ + "BIW": 0x19400807, + "ADDR": 0xC9808779, + } + logicals := map[string]uint32{ + "BIW": 0x807, + "ADDR": 0x8779, + } + + for name, logical := range logicals { + target := targets[name] + ref := refLayoutEncode(logical) + t.Logf("%s logical=0x%05X refLayout=0x%08X target=0x%08X match=%v", + name, logical, ref, target, ref == target) + } +} diff --git a/version.go b/version.go index 906495d..6948f39 100644 --- a/version.go +++ b/version.go @@ -2,42 +2,42 @@ package flex import ( "fmt" - "strings" + "runtime" ) +// Version information - can be set at build time var ( Version = "1.0.0" BuildTime = "unknown" GitCommit = "unknown" - Author = "unknown" - ProjectURL = "unknown" + Author = "marcell" + ProjectURL = "https://pagercast.com" BuildArch = "unknown" BuildGoVer = "unknown" ) -// GetFullVersionInfo returns a formatted version string for the CLI +// GetVersionString returns a formatted version string +func GetVersionString() string { + return fmt.Sprintf("FLEX-GO v%s", Version) +} + +// GetBinaryInfo returns binary architecture and runtime information +func GetBinaryInfo() string { + return fmt.Sprintf("Architecture: %s/%s | Go Version: %s", runtime.GOOS, runtime.GOARCH, runtime.Version()) +} + +// GetFullVersionInfo returns detailed version information func GetFullVersionInfo() string { - var sb strings.Builder - sb.WriteString(fmt.Sprintf("FLEX Decoder - v%s\n", Version)) - - if BuildTime != "unknown" { - sb.WriteString(fmt.Sprintf("Build Time: %s\n", BuildTime)) - } - if GitCommit != "unknown" { - sb.WriteString(fmt.Sprintf("Commit: %s\n", GitCommit)) - } - if BuildArch != "unknown" { - sb.WriteString(fmt.Sprintf("Arch: %s\n", BuildArch)) - } - if BuildGoVer != "unknown" { - sb.WriteString(fmt.Sprintf("Go Version: %s\n", BuildGoVer)) - } - if Author != "unknown" { - sb.WriteString(fmt.Sprintf("Author: %s\n", Author)) - } - if ProjectURL != "unknown" { - sb.WriteString(fmt.Sprintf("Project: %s\n", ProjectURL)) - } - - return strings.TrimSpace(sb.String()) + return fmt.Sprintf(`FLEX-GO v%s +Complete Go implementation of FLEX pager protocol +Author: %s +Build Time: %s +Git Commit: %s +Build Architecture: %s +Build Go Version: %s +Runtime Architecture: %s/%s +Runtime Go Version: %s +GitHub: https://github.com/sqpp/flex-golang +Project: PagerCast | %s +`, Version, Author, BuildTime, GitCommit, BuildArch, BuildGoVer, runtime.GOOS, runtime.GOARCH, runtime.Version(), ProjectURL) }