mirror of
https://github.com/KrisKennaway/ii-sound.git
synced 2024-06-25 22:29:29 +00:00
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:
parent
eeeb457b23
commit
86cd1e1114
182
encode_audio.py
182
encode_audio.py
|
@ -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
166
opcodes.py
Normal 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.
|
@ -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
28
preprocess_audio.py
Normal 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)
|
Loading…
Reference in New Issue
Block a user