Pulse width encoding

Working hello world transfer in sim and irl
This commit is contained in:
Joppe Blondel
2026-10-05 18:14:23 +02:00
parent 8ba8d6c680
commit 0ca388c1d0
6 changed files with 544 additions and 93 deletions
+95 -93
View File
@@ -1,133 +1,135 @@
#!/usr/bin/env python3
from __future__ import annotations
import os
import argparse
import math
import struct
import wave
from pathlib import Path
class FSKWriter:
def __init__(self, sample_rate: int, c_low: int, c_high: int, f_low: float, f_high: float, amplitude: float, block_spacing: float):
class PulseWriter:
def __init__(
self,
sample_rate: int,
t_short_us: float,
t_long_us: float,
amplitude: float,
) -> None:
self.sample_rate = sample_rate
self.c_low = c_low
self.c_high = c_high
self.f_low = f_low
self.f_high = f_high
self.amplitude = amplitude
self.samples_per_low = (sample_rate * c_low) // f_low
self.samples_per_high = (sample_rate * c_high) // f_high
self.phase = 0.0
self.block_spacing = block_spacing
self.short_samples = self._duration_samples(t_short_us)
self.long_samples = self._duration_samples(t_long_us)
self.high = round(32767 * amplitude)
self.low = -self.high
self.samples = bytearray()
self.space(1.0)
self.space(0.1)
def finalize(self):
self.space(1.0)
def space(self, time: float) -> None:
self._level(0, int(time*self.sample_rate))
def space(self, time:float = 0.03):
self.samples.extend(b'\0\0'*int(time*self.sample_rate))
def _duration_samples(self, duration_us: float) -> int:
sample_count = round(duration_us * self.sample_rate / 1_000_000)
if sample_count < 1:
raise ValueError(
f"pulse duration {duration_us:g} us is shorter than one sample"
)
return sample_count
def _tone(self, frequency: float, sample_count: int) -> None:
phase_step = 2.0 * math.pi * frequency / self.sample_rate
scale = int(32767 * self.amplitude)
phase = self.phase
out = self.samples
def _level(self, value: int, sample_count: int) -> None:
self.samples.extend(struct.pack("<h", value) * sample_count)
for _ in range(int(sample_count)):
sample = int(round(scale * math.sin(phase)))
out.extend(struct.pack("<h", sample))
phase += phase_step
if phase >= 2.0 * math.pi:
phase -= 2.0 * math.pi
def short_pulse(self) -> None:
self._level(self.high, self.short_samples)
self._level(self.low, self.long_samples)
self.phase = phase
def long_pulse(self) -> None:
self._level(self.high, self.long_samples)
self._level(self.low, self.short_samples)
def leader(self) -> None:
self._tone(self.f_high, (200*self.sample_rate)//self.f_high)
self._tone(self.f_low, (200*self.sample_rate)//self.f_low)
for _ in range(8):
self.bit(1)
self.bit(0)
self.byte(0xf0)
def marker(self) -> None:
self._level(self.high, 2*self.long_samples)
self._level(self.low, 2*self.long_samples)
def bit(self, value: int) -> None:
if value:
self._tone(self.f_high, self.samples_per_high)
self.long_pulse()
else:
self._tone(self.f_low, self.samples_per_low)
self.short_pulse()
def byte(self, value: int) -> None:
# MSB first
for bit_no in range(7, -1, -1):
self.bit((value >> bit_no) & 1)
# Transmit bytes most-significant bit first.
for bit_number in range(7, -1, -1):
self.bit((value >> bit_number) & 1)
def bytes(self, data: bytes) -> None:
for value in data:
def bytes(self, value: bytes) -> None:
for byte in value:
self.byte(byte)
def leader(self) -> None:
self.space(0.2)
for _ in range(50):
self._level(self.high, self.short_samples)
self._level(self.low, self.short_samples)
for _ in range(50):
self._level(self.high, self.long_samples)
self._level(self.low, self.long_samples)
self.marker()
for value in (0x55, 0x5A, 0xC3):
self.byte(value)
def block(self, nr: int, data: bytes) -> None:
print(f'> {data}')
self.leader()
self.byte(nr)
l = len(data)
self.byte(l>>8)
self.byte(l&0xff)
#self.samples.extend(b'\0'*int(self.samples_per_low*8))
self.bytes(data)
self.byte(self.chsum(data))
self.space(self.block_spacing)
def chsum(self, data: bytes) -> int:
cs = 0
for d in data:
cs += int(d)
return cs
def main() -> None:
parser = argparse.ArgumentParser(description="Encode a binary file as two tone FSK WAV")
parser.add_argument("input", type=Path, help="Binary input file")
parser.add_argument("output", type=Path, help="output WAV; default <input>.wav")
parser.add_argument("--sample-rate", type=int, default=48000)
parser.add_argument("--low", type=float, default=2000.0, help="low tone in Hz")
parser.add_argument("--high", type=float, default=4000.0, help="high tone in Hz")
parser.add_argument("--low-cycles", type=int, default=2, help="low tone cycle count for a 0")
parser.add_argument("--high-cycles", type=int, default=4, help="high tone cycle count for a 1")
parser = argparse.ArgumentParser(
description="Generate the pulse-width encoded tape leader"
)
parser.add_argument("output", type=Path, help="output WAV file")
parser.add_argument("--sample-rate", type=int, default=48_000)
parser.add_argument(
"--t-short",
type=float,
default=250.0,
metavar="MICROSECONDS",
help="short pulse interval in microseconds (default: 250)",
)
parser.add_argument(
"--t-long",
type=float,
default=500.0,
metavar="MICROSECONDS",
help="long pulse interval in microseconds (default: 500)",
)
parser.add_argument("--amplitude", type=float, default=0.75)
parser.add_argument("--block-size", type=int, default=128)
parser.add_argument("--block-spacing", type=float, default=1.0)
args = parser.parse_args()
output_path = args.output or args.input.with_name(args.input.stem + ".wav")
if args.sample_rate <= 0:
parser.error("--sample-rate must be positive")
if args.t_short <= 0 or args.t_long <= 0:
parser.error("pulse intervals must be positive")
if args.t_short >= args.t_long:
parser.error("--t-short must be less than --t-long")
if not 0.0 <= args.amplitude <= 1.0:
parser.error("--amplitude must be between 0 and 1")
tones = FSKWriter(args.sample_rate, args.low_cycles, args.high_cycles, args.low, args.high, args.amplitude, args.block_spacing)
try:
pulses = PulseWriter(
args.sample_rate,
args.t_short,
args.t_long,
args.amplitude,
)
except ValueError as error:
parser.error(str(error))
# Create header block
nrblocks = math.ceil(os.path.getsize(args.input)/args.block_size)
print(f"Number of blocks {nrblocks}*2")
pulses.leader()
pulses.bytes(b'Hello world!\0')
tones.block(0, struct.pack('<H', nrblocks*2) + args.input.name.encode() + b'\0')
tones.space(1.0)
block = 1
with open(args.input, 'rb') as f:
while True:
data = f.read(args.block_size)
if not len(data):
break
tones.block(block, data)
tones.block(block, data)
block += 1
tones.finalize()
with wave.open(str(output_path), "wb") as wav:
pulses.space(1.0)
with wave.open(str(args.output), "wb") as wav:
wav.setnchannels(1)
wav.setsampwidth(2)
wav.setframerate(args.sample_rate)
wav.writeframes(tones.samples)
wav.writeframes(pulses.samples)
if __name__ == "__main__":
main()
+449
View File
@@ -3,6 +3,21 @@
.area TAPELOAD (ABS)
.org TAPE_LOADER_ROM_ADDRESS
.macro print_var arg1
ld a, (arg1)
ei
call print_hex8
call print_crlf
di
.endm
.macro error_ret arg1,arg2
arg1:
ld a,#arg2
scf
ret
.endm
TAPE_PORT = 0x18
MAX_BLOCK_SIZE = 0x100
@@ -11,6 +26,12 @@ WORK_BASE = 0xB000
OLD_SP = WORK_BASE + 0x00 ; word
OLD_IFF = WORK_BASE + 0x02 ; byte
SHORT_AVG = WORK_BASE + 0x03 ; byte
SHORT_MAX = WORK_BASE + 0x04 ; byte
SHORT_MIN = WORK_BASE + 0x05 ; byte
LONG_AVG = WORK_BASE + 0x06 ; byte
LONG_MAX = WORK_BASE + 0x07 ; byte
LONG_MIN = WORK_BASE + 0x08 ; byte
BLOCK_BUFFER = WORK_BASE + 0x100 ; MAX_BLOCK_SIZE
@@ -36,6 +57,52 @@ main:
ld hl,#msg_start
call print_string_ei
; Calibrate reader
call calibrate
jr nc,1$
ei
call print_hex8
call print_crlf
ld hl,#msg_cal_fail
call print_string
di
jp exit
1$:
; try to read the sync bytes
call read_sync
jr nc,2$
ld hl,#msg_sync_error
call print_string_ei
jp exit
2$:
; Test, read Hello World!\0 (13 bytes)
exx
ld e,#0
ld hl,#BLOCK_BUFFER
exx
4$:
call read_byte
jr nc,5$
ld hl,#msg_read_error
call print_string_ei
exx
ei
call print_hex16
call print_crlf
di
exx
jp exit
5$:
exx
ld (hl),a
inc hl
inc e
ld a,e
cp a,#13
exx
jr nz,4$
; #################################################3
; Exit
; #################################################3
@@ -57,6 +124,379 @@ exit:
return_di:
ret
; #################################################3
; Measure edge-to-edge pulse
; needs d (comparator state)
; returns b (loop count)
; carry is timeout
; uses a
; loop is 36T, extra 22T (0.6 loops)
; #################################################3
measure_pulse:
ld b,#0 ; 4T
1$:
; Use b as timeout counter (256 loops)
inc b ; 4T
jr z,9$ ; 12/7T
in a,(TAPE_PORT) ; 11T
cp a,d ; 4T
jp z,1$ ; 10T
; loop has 36T
; Edge found
ld d,a ; 4T
or a,a ; 4T
ret ; 10T
9$:
scf
ret
; #################################################3
; Ignore one pulse then measure next pulse
; needs d (comparator state)
; returns b (loop count)
; carry is timeout
; uses a
; loop is 36T, extra 26T (0.7 loops)
; #################################################3
sample_pulse:
; wait for first edge
ld b,#0 ; 4T
1$:
; Use b as timeout counter (256 loops)
inc b ; 4T
jr z,9$ ; 12/7T
in a,(TAPE_PORT) ; 11T
cp a,d ; 4T
jp z,1$ ; 10T
; loop has 36T
; Edge found
ld d,a ; 4T
; start timing pulse length
ld b,#0 ; 4T
2$:
; Use b as timeout counter (256 loops)
inc b ; 4T
jr z,9$ ; 12/7T
in a,(TAPE_PORT) ; 11T
cp a,d ; 4T
jp z,2$ ; 10T
; loop has 36T
; Edge found
ld d,a ; 4T
or a,a ; 4T
ret ; 10T
9$:
scf
ret
; #################################################3
; Average 8 pulse measurements
; needs d (comparator state)
; returns a (average)
; carry is timeout
; uses b,e,h
; #################################################3
average8:
ld e,#0
ld h,#8
1$:
; synchronize to next pulse
; ignore first measurement
; contains part of the calculations
call sample_pulse
call sample_pulse
; when timeout just return with carry
ret c
; Add loop count to e
ld a,e
add a,b
ld e,a
; repeat 8 times
dec h
jr nz,1$
; divide by 8
ld a,e
srl a
srl a
srl a
; clear carry
or a,a
ret
; #################################################3
; Make +/-25% window around a
; needs a (average)
; returns b (minimum) c (maximum)
; uses e,h
; NOTE: USES 8 BIT COUNTER
; @1MHZ t_long<1.14ms
; @2MHZ t_long<571us
; @4MHZ t_long<285us
; TODO: Check feasibility of using 16bit accumulator
; #################################################3
make_window:
ld e,a
; get 25%
srl a
srl a
ld h,a
; a-25%
ld a,e
sub h
ld b,a
; a+25%
ld a,e
add a,h
ld c,a
ret
; #################################################3
; Calibration
; #################################################3
calibrate:
; Get current comparator state
in a,(TAPE_PORT)
ld d,a
; make sure we start in silence
call sample_pulse
; should timeout
jp nc,cal_fail_no_silence
; Wait for first edge
ld l,#0
1$:
call sample_pulse
jr nc,2$
; Use b as timeout counter (256 loops)
; inc l
; jr nz,1$
jr 1$
; timeout
;jp cal_fail_no_short_leader ; TODO IGNORED TIMEOUT FOR NOW HERE
2$:
; edge found
; get short leader timing
call average8
jr c,cal_fail_short
ld (SHORT_AVG),a
call make_window
ld a,b
ld (SHORT_MIN),a
ld a,c
ld (SHORT_MAX),a
; find long leader
; need 4 consecutive pulses > short*1.5
ld a,(SHORT_AVG)
ld c,a
srl a
add a,c
ld c,a
; c = short*1.5
; TODO no overal timeout here. a continuous
; short pulse leader will keep on waiting
; forever
3$:
ld e,#4
4$:
; ignore first to get accurate measurement
call sample_pulse
call sample_pulse
jr c,cal_fail_find_long
ld a,b
; if it is no long pulse try again
cp a,c
jr c,3$
; long pulse found
dec e
jr nz,4$
; 4 (or well... 8) long pulses found
; get long leader timning
call average8
jr c,cal_fail_long
ld (LONG_AVG),a
call make_window
ld a,b
ld (LONG_MIN),a
ld a,c
ld (LONG_MAX),a
; A quick sanity check
; long > 1.5*short
ld a,(SHORT_AVG)
ld c,a
srl a
add a,c
ld c,a
; c = 1.5*short
ld a,(LONG_AVG)
cp a,c
jr c,cal_fail_sanity
; Find the marker
; marker is 2*long high 2*long low
; threshold is 1.5*long
ld a,(LONG_AVG)
ld c,a
srl a
add a,c
ld c,a
; c = 1.5*long
; Find two consecutive marker-width intervals.
5$:
ld e,#0
6$:
call measure_pulse
jr c,cal_fail
ld a,b
cp a,c
jr c,5$
inc e
ld a,e
cp a,#2
jr c,6$
; Both marker halves consumed. We are now at the
; beginning of the first data pulse.
; next pulse is first pulse of training byte
; must decode as 0x55
call read_byte
jr c,cal_fail_read_error
cp a,#0x55
jr nz,cal_fail_training_byte
ld a,#0
or a,a
ret
error_ret cal_fail 0
error_ret cal_fail_short 1
error_ret cal_fail_find_long 2
error_ret cal_fail_long 3
error_ret cal_fail_sanity 4
error_ret cal_fail_training_byte 5
error_ret cal_fail_no_silence 6
error_ret cal_fail_no_short_leader 7
error_ret cal_fail_read_error 8
; #################################################3
; Read single bit
; needs d (comparator state)
; valid: z=1 c=bit
; error: z=0
; uses a,b,c
; #################################################3
read_bit:
; entry may be late into first pulse
; wait for first edge to measure
ld b,#0
1$:
inc b
jr z,bit_bad
in a,(TAPE_PORT)
cp a,d
jp z,1$
ld d,a
; ignored first edge
; measure second pulse
ld b,#0
2$:
inc b
jr z,bit_bad
in a,(TAPE_PORT)
cp a,d
jp z,2$
ld d,a
; if in short window (second half is short pulse)
; 1
ld a,(SHORT_MIN)
ld c,a
ld a,b
cp a,c
jr c,bit_bad ; pulse<SHORT_MIN
ld a,(SHORT_MAX)
ld c,a
ld a,b
cp a,c
jr c,bit_one
jr z,bit_one ; pulse<=SHORT_MAX
; test long window (second half is long pulse)
ld a,(LONG_MIN)
ld c,a
ld a,b
cp a,c
jr c,bit_bad ; pulse<LONG_MAX
ld a,(LONG_MAX)
ld c,a
ld a,b
cp a,c
jr c,bit_zero
jr z,bit_zero ; pulse<=LONG_MAX
bit_bad:
ld a,#1
or a,a
ret
bit_zero:
xor a,a
ret
bit_one:
xor a,a
scf
ret
; #################################################3
; Read byte, MSB first
; returns a (byte)
; carry means error
; needs d (comparator state)
; uses e,h,b,c
; #################################################3
read_byte:
ld e,#0
ld h,#8
1$:
call read_bit
jr nz,9$
; shift in the carry bit
rl e
; repeat 8 times
dec h
jr nz,1$
; byte done,
ld a,e
or a
ret
9$:
scf
ret
; #################################################3
; Read and check sync bytes
; carry is error
; #################################################3
read_sync:
call read_byte
ret c
cp a,#0x5a
jr nz,9$
call read_byte
ret c
cp a,#0xc3
jr nz,9$
or a,a
ret
9$:
scf
ret
; #################################################3
; IO Helper functions
; #################################################3
@@ -118,3 +558,12 @@ print_hex_digit:
msg_start:
.ascii "TAPE DECODE TEST - PLAY TAPE"
.db 13,10,0
msg_cal_fail:
.ascii "Calibration failed"
.db 13,10,0
msg_sync_error:
.ascii "Sync bytes not found"
.db 13,10,0
msg_read_error:
.ascii "read error"
.db 13,10,0
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.