1MHz encoder and player

- we simulate the speaker at full 1-cycle resolution, and
  evaluate all possible opcodes that can be scheduled for the
  given position in the TCP stream
- this lets us tick the speaker at any cycle interval >=9 (except 10
  cycles)
This commit is contained in:
kris 2020-08-13 22:08:50 +01:00
parent eeeb457b23
commit 86cd1e1114
5 changed files with 325 additions and 105 deletions

View File

@ -18,79 +18,94 @@
# this "dead" period when we're keeping the speaker in a net-neutral position.
import sys
import functools
import librosa
import numpy
from typing import List, Tuple
from eta import ETA
OPCODES = {
'tick_page1': 0x00,
'notick_page1': 0x09,
'notick_page2': 0x11,
'exit': 0x19,
'slowpath': 0x29
}
# TODO: notick also has room to flip another softswitch, what can I do with it?
import opcodes
# TODO: test
@functools.lru_cache(None)
def lookahead_patterns(
lookahead: int, slowpath_distance: int,
voltage: float) -> numpy.ndarray:
initial_voltage = voltage
patterns = set()
slowpath_pre_bits = 0
slowpath_post_bits = 0
if slowpath_distance <= 0:
slowpath_pre_bits = min(12 + slowpath_distance, lookahead)
elif slowpath_distance <= lookahead:
slowpath_post_bits = lookahead - slowpath_distance
enumerate_bits = lookahead - slowpath_pre_bits - slowpath_post_bits
assert slowpath_pre_bits + enumerate_bits + slowpath_post_bits == lookahead
for i in range(2 ** enumerate_bits):
voltage = initial_voltage
pattern = []
for j in range(slowpath_pre_bits):
voltage = -voltage
pattern.append(voltage)
for j in range(enumerate_bits):
voltage = 1.0 if ((i >> j) & 1) else -1.0
pattern.append(voltage)
for j in range(slowpath_post_bits):
voltage = -voltage
pattern.append(voltage)
patterns.add(tuple(pattern))
res = numpy.array(list(patterns), dtype=numpy.float32)
return res
#
# # TODO: test
# @functools.lru_cache(None)
# def lookahead_patterns(
# lookahead: int, slowpath_distance: int,
# voltage: float) -> numpy.ndarray:
# initial_voltage = voltage
# patterns = set()
#
# slowpath_pre_bits = 0
# slowpath_post_bits = 0
# if slowpath_distance <= 0:
# slowpath_pre_bits = min(12 + slowpath_distance, lookahead)
# elif slowpath_distance <= lookahead:
# slowpath_post_bits = lookahead - slowpath_distance
#
# enumerate_bits = lookahead - slowpath_pre_bits - slowpath_post_bits
# assert slowpath_pre_bits + enumerate_bits + slowpath_post_bits == lookahead
#
# for i in range(2 ** enumerate_bits):
# voltage = initial_voltage
# pattern = []
# for j in range(slowpath_pre_bits):
# voltage = -voltage
# pattern.append(voltage)
#
# for j in range(enumerate_bits):
# voltage = 1.0 if ((i >> j) & 1) else -1.0
# pattern.append(voltage)
#
# for j in range(slowpath_post_bits):
# voltage = -voltage
# pattern.append(voltage)
#
# patterns.add(tuple(pattern))
#
# res = numpy.array(list(patterns), dtype=numpy.float32)
# return res
def lookahead(step_size: int, initial_position: float, data: numpy.ndarray,
offset: int,
voltages: numpy.ndarray):
positions = numpy.full(voltages.shape[0], initial_position,
dtype=numpy.float32)
positions = numpy.empty((voltages.shape[0], voltages.shape[1] + 1),
dtype=numpy.float32)
positions[:, 0] = initial_position
target_val = data[offset:offset + voltages.shape[1]]
total_error = numpy.zeros(shape=voltages.shape[0], dtype=numpy.float32)
# total_error = numpy.zeros(shape=voltages.shape[0], dtype=numpy.float32)
for i in range(0, voltages.shape[1]):
positions += (voltages[:, i] - positions) / step_size
err = numpy.power(numpy.abs(positions - target_val[i]), 2)
total_error += err
# err = numpy.abs(positions[:, 1:] - target_val)
# total_error = numpy.sum(err, axis=1)
positions[:, i + 1] = positions[:, i] + (
voltages[:, i] - positions[:, i]) / step_size
# err = numpy.power(numpy.abs(positions - target_val[i]), 2)
# total_error += err
try:
err = positions[:, 1:] - target_val
except ValueError:
print(offset, len(data), positions.shape, target_val.shape)
raise
total_error = numpy.sum(numpy.power(err, 2), axis=1)
best = numpy.argmin(total_error)
return voltages[best, 0]
return best
def evolve(opcode: opcodes.Opcode, starting_position, starting_voltage,
step_size, data, starting_idx):
# Skip ahead to end of this opcode
opcode_length = opcodes.cycle_length(opcode)
voltages = starting_voltage * opcodes.CYCLE_SCHEDULE[opcode]
position = starting_position
total_err = 0.0
v = starting_voltage
for i, v in enumerate(voltages):
position += (v - position) / step_size
err = position - data[starting_idx + i]
total_err += err ** 2
return position, v, total_err, starting_idx + opcode_length
@profile
def sample(data: numpy.ndarray, step: int, lookahead_steps: int):
dlen = len(data)
data = numpy.concatenate([data, numpy.zeros(lookahead_steps)]).astype(
@ -100,41 +115,33 @@ def sample(data: numpy.ndarray, step: int, lookahead_steps: int):
position = -1.0
total_err = 0.0
slowpath_distance = 2047
cnt = 0
frame_offset = 0
eta = ETA(total=1000)
for i, val in enumerate(data[:dlen]):
if i and i % int((dlen / 1000)) == 0:
i = 0
last_updated = 0
while i < int(dlen / 100):
if (i - last_updated) > int((dlen / 1000)):
eta.print_status()
last_updated = i
voltages = lookahead_patterns(
lookahead_steps, slowpath_distance, voltage)
new_voltage = lookahead(step, position, data, i, voltages)
candidate_opcodes = opcodes.opcode_lookahead(
frame_offset, lookahead_steps)
pruned_opcodes, voltages = opcodes.prune_opcodes(
candidate_opcodes, lookahead_steps)
if slowpath_distance == 0:
yield OPCODES['slowpath']
cnt += 1
elif slowpath_distance > 0:
if new_voltage != voltage:
yield OPCODES['tick_page1']
cnt += 1
else:
yield OPCODES['notick_page2']
cnt += 1
opcode_idx = lookahead(step, position, data, i, voltage * voltages)
opcode = pruned_opcodes[opcode_idx].opcodes[0]
yield opcode
slowpath_distance -= 1
if slowpath_distance == -12:
# End of slowpath
slowpath_distance = 2047
position, voltage, new_error, i = evolve(
opcode, position, voltage, step, data, i)
voltage = new_voltage
position += (voltage - position) / step
err = (position - val) ** 2
total_err += abs(err)
total_err += new_error
frame_offset = (frame_offset + 1) % 2048
for _ in range(cnt % 2048, 2047):
yield OPCODES['notick_page1']
yield OPCODES['exit']
for _ in range(frame_offset % 2048, 2047):
yield opcodes.Opcode.NOTICK_5
yield opcodes.Opcode.EXIT
eta.done()
print("Total error %f" % total_err)
@ -150,17 +157,18 @@ def preprocess(
return data
def main(argv):
serve_file = argv[1]
step = int(argv[2])
lookahead_steps = int(argv[3])
out = argv[4]
sample_rate = int(1024. * 1000 / 13)
sample_rate = int(1024. * 1000)
data = preprocess(serve_file, sample_rate)
with open(out, "wb+") as f:
for b in sample(data, step, lookahead_steps):
f.write(bytes([b]))
for opcode in sample(data, step, lookahead_steps):
f.write(bytes([opcode.value]))
if __name__ == "__main__":

166
opcodes.py Normal file
View File

@ -0,0 +1,166 @@
import enum
import functools
import numpy
from typing import Dict, List, Tuple, Iterable
class Opcode(enum.Enum):
TICK_12 = 0x00
TICK_17 = 0x08
TICK_15 = 0x09
TICK_13 = 0x0a
TICK_11 = 0x0b
TICK_9 = 0x0c
NOTICK_8 = 0x12
NOTICK_11 = 0x17
NOTICK_9 = 0x18
NOTICK_7 = 0x19
NOTICK_5 = 0x1a
EXIT = 0x1d
SLOWPATH = 0x2d
def make_tick_cycles(length) -> numpy.ndarray:
c = numpy.full(length, 1.0, dtype=numpy.float32)
for i in range(length - 6, length):
c[i] = -1.0
return c
def make_notick_cycles(length) -> numpy.ndarray:
return numpy.full(length, 1.0, dtype=numpy.float32)
def make_slowpath_cycles() -> numpy.ndarray:
length = 12 * 13
c = numpy.full(length, 1.0, dtype=numpy.float32)
voltage_high = True
for i in range(12):
voltage_high = not voltage_high
for j in range(3 + 13 * i, min(length, 3 + 13 * (i + 1))):
c[j] = 1.0 if voltage_high else -1.0
return c
# XXX rename to voltages
CYCLE_SCHEDULE = {
Opcode.TICK_12: make_tick_cycles(12),
Opcode.TICK_17: make_tick_cycles(17),
Opcode.TICK_15: make_tick_cycles(15),
Opcode.TICK_13: make_tick_cycles(13),
Opcode.TICK_11: make_tick_cycles(11),
Opcode.TICK_9: make_tick_cycles(9),
Opcode.NOTICK_8: make_notick_cycles(8),
Opcode.NOTICK_11: make_notick_cycles(11),
Opcode.NOTICK_9: make_notick_cycles(9),
Opcode.NOTICK_7: make_notick_cycles(7),
Opcode.NOTICK_5: make_notick_cycles(5),
Opcode.SLOWPATH: make_slowpath_cycles()
} # type: Dict[Opcode, numpy.ndarray]
def cycle_length(op: Opcode) -> int:
return len(CYCLE_SCHEDULE[op])
class _Opcodes:
def __init__(self, opcodes: Iterable[Opcode]):
self.opcodes = tuple(opcodes)
self._hash = hash(self.opcodes)
def __hash__(self):
return self._hash
# Guarantees each Tuple[Opcode] has a unique _Opcodes representation
_OPCODES_CACHE = {}
@functools.lru_cache(None)
def Opcodes(opcodes: Tuple[Opcode]):
return _OPCODES_CACHE.setdefault(opcodes, _Opcodes(opcodes))
@functools.lru_cache(None)
def opcode_choices(frame_offset: int) -> List[Opcode]:
if frame_offset == 2047:
return [Opcode.SLOWPATH]
opcodes = set(CYCLE_SCHEDULE.keys()) - {Opcode.SLOWPATH}
# Prefer longer opcodes to have a more compact bytestream
# XXX if we aren't looking ahead beyond 1 opcode we should
# pick the shortest?
return sorted(list(opcodes), key=cycle_length, reverse=True)
@functools.lru_cache(None)
def _opcode_lookahead(
frame_offset: int,
lookahead_cycles: int) -> Tuple[Tuple[Opcode]]:
ch = opcode_choices(frame_offset)
ops = []
for op in ch:
if cycle_length(op) >= lookahead_cycles:
ops.append((op,))
else:
for res in _opcode_lookahead((frame_offset + 1) % 2048,
lookahead_cycles - cycle_length(op)):
ops.append((op,) + res)
return tuple(ops) # XXX type
@functools.lru_cache(None)
def opcode_lookahead(
frame_offset: int,
lookahead_cycles: int) -> Tuple[_Opcodes]:
return tuple(Opcodes(ops) for ops in
_opcode_lookahead(frame_offset, lookahead_cycles))
_CYCLES_CACHE = {}
class Cycles:
def __init__(self, cycles: Tuple[float]):
self.cycles = cycles
self._hash = hash(cycles)
def __hash__(self):
return self._hash
@functools.lru_cache(None)
def cycle_lookahead(
opcodes: _Opcodes,
lookahead_cycles: int
) -> Cycles:
cycles = []
for op in opcodes.opcodes:
cycles.extend(CYCLE_SCHEDULE[op])
trunc_cycles = tuple(cycles[:lookahead_cycles])
return _CYCLES_CACHE.setdefault(trunc_cycles, Cycles(trunc_cycles))
@functools.lru_cache(None)
def prune_opcodes(
opcodes: Tuple[_Opcodes], lookahead_cycles: int
) -> Tuple[List[_Opcodes], numpy.ndarray]:
seen_cycles = set()
pruned_opcodes = []
pruned_cycles = []
for ops in opcodes:
cycles = cycle_lookahead(ops, lookahead_cycles)
if cycles in seen_cycles:
continue
seen_cycles.add(cycles)
pruned_opcodes.append(ops)
pruned_cycles.append(cycles.cycles)
return pruned_opcodes, numpy.array(pruned_cycles, dtype=numpy.float32)
if __name__ == "__main__":
lah = 50
ops = opcode_lookahead(0, lah)
pruned = prune_opcodes(ops, lah)
print(len(ops), len(pruned[0]))

Binary file not shown.

View File

@ -312,25 +312,43 @@ fill:
; The actual player code
begin_copy_page1:
tick_page1: ; $300
STA TICK
STA PAGE2OFF
JMP (WDATA)
notick_page1: ; $309
STA PAGE2OFF
NOP
NOP
JMP (WDATA)
notick_page2: ;$311
STA PAGE2ON
NOP
NOP
JMP (WDATA)
; $300
tick_12: ; ticks on cycle 7 of 12
STA zpdummy
STA $C030
JMP (WDATA)
; $308
; ticks on cycle count 2n+4 out of 2n+9, minimum 4 out of 9
; 9, 11, 13, 15, 17
; only need up to tick_17 because others come from combinations
tick_n_odd:
NOP
NOP
NOP
NOP
STA $C030
JMP (WDATA)
; $312
notick_8:
STA zpdummy
JMP (WDATA)
; $317
; 2n+5 cycles, minimum 5
; only need 5,7,9,11
; then 13 = 8+5
notick_n_odd:
NOP
NOP
NOP
JMP (WDATA)
; $31d
; Quit to ProDOS
exit: ; $319
exit:
INC RESET_VECTOR+2 ; Invalidate power-up byte
JSR PRODOS ; Call the MLI ($BF00)
.BYTE $65 ; CALL TYPE = QUIT
@ -353,7 +371,7 @@ exit_parmtable:
; net position of the speaker cone. It might be possible to compensate for some other cadence in the encoder,
; but this risks introducing unwanted harmonics. We end up ticking 12 times assuming we don't stall waiting for
; the socket buffer to refill. In that case audio is already going to be disrupted though.
slowpath: ;$329
slowpath: ;$32d
STA TICK ; 4
; Save the W5100 address pointer so we can come back here later

28
preprocess_audio.py Normal file
View File

@ -0,0 +1,28 @@
import sys
import librosa
import numpy
import soundfile as sf
def preprocess(
filename: str, target_sample_rate: int,
normalize: float = 0.5) -> numpy.ndarray:
data, _ = librosa.load(filename, sr=target_sample_rate, mono=True)
max_value = numpy.percentile(data, 90)
data /= max_value
data *= normalize
return data
def main(argv):
serve_file = argv[1]
out = argv[2]
sample_rate = int(1024. * 1000)
sf.write(out, preprocess(serve_file, sample_rate), sample_rate)
if __name__ == "__main__":
main(sys.argv)