Skip to content

Instantly share code, notes, and snippets.

@pathawks
Last active August 1, 2026 23:28
Show Gist options
  • Select an option

  • Save pathawks/6b01a2a97941b31abaf1cc346ac019c6 to your computer and use it in GitHub Desktop.

Select an option

Save pathawks/6b01a2a97941b31abaf1cc346ac019c6 to your computer and use it in GitHub Desktop.
Desk Mate Song to MIDI
#!/usr/bin/env python3
"""Convert DeskMate 3 Sound Editor .SND files to 8-bit mono WAV files.
The bundled DeskMate 3 files use the 2500-series .SND container: a 114-byte
header followed by linked 46-byte sample descriptors. Uncompressed samples
are unsigned 8-bit PCM. Music-compressed samples are decoded from the native
COMPRESS.RES codebook and nibble stream.
"""
from __future__ import annotations
import argparse
import json
import re
import struct
from dataclasses import asdict, dataclass
from pathlib import Path
FIXED_HEADER_SIZE = 0x72
DESCRIPTOR_SIZE = 46
MAX_SAMPLES = 16
NEW_FORMAT_ID = b"\x1a\x80"
COMPRESSION_NAMES = {
0: "none",
1: "music",
2: "speech",
}
@dataclass
class SampleDescriptor:
index: int
descriptor_offset: int
pitch: int
range_low: int
range_high: int
data_offset: int
stored_bytes: int
sample_count: int
sustain_start: int
sustain_end: int
@dataclass
class SoundFile:
path: Path
name: str
instrument_id: int
sample_rate: int
compression: int
descriptors: list[SampleDescriptor]
data: bytes
class SndFormatError(ValueError):
"""The input does not match the DeskMate 3 .SND format."""
def read_u16(data: bytes, offset: int) -> int:
return struct.unpack_from("<H", data, offset)[0]
def read_u32(data: bytes, offset: int) -> int:
return struct.unpack_from("<I", data, offset)[0]
def parse_snd(path: Path) -> SoundFile:
data = path.read_bytes()
if len(data) < FIXED_HEADER_SIZE:
raise SndFormatError(f"{path}: too short for a DeskMate 3 .SND header")
if data[0x2C:0x2E] != NEW_FORMAT_ID:
raise SndFormatError(
f"{path}: not a DeskMate 3 / Tandy 2500-series .SND file "
f"(expected ID 1a80 at 0x2c)"
)
count = read_u16(data, 0x2E)
if not 1 <= count <= MAX_SAMPLES:
raise SndFormatError(f"{path}: invalid sample count {count}")
compression = read_u16(data, 0x42)
if compression not in COMPRESSION_NAMES:
raise SndFormatError(f"{path}: unsupported compression code {compression}")
sample_rate = read_u16(data, 0x58)
if sample_rate == 0:
raise SndFormatError(f"{path}: invalid zero sample rate")
raw_name = data[:10].split(b"\0", 1)[0]
name = raw_name.decode("cp437", errors="replace")
instrument_id = read_u16(data, 0x30)
descriptors: list[SampleDescriptor] = []
descriptor_offset = FIXED_HEADER_SIZE
seen_offsets: set[int] = set()
for index in range(1, count + 1):
if descriptor_offset in seen_offsets:
raise SndFormatError(f"{path}: cyclic sample-descriptor chain")
seen_offsets.add(descriptor_offset)
if descriptor_offset + DESCRIPTOR_SIZE > len(data):
raise SndFormatError(f"{path}: sample descriptor {index} is truncated")
link = read_u32(data, descriptor_offset)
descriptor = SampleDescriptor(
index=index,
descriptor_offset=descriptor_offset,
pitch=data[descriptor_offset + 6],
range_low=data[descriptor_offset + 8],
range_high=data[descriptor_offset + 9],
data_offset=read_u32(data, descriptor_offset + 10),
stored_bytes=read_u32(data, descriptor_offset + 14),
sample_count=read_u32(data, descriptor_offset + 18),
sustain_start=read_u32(data, descriptor_offset + 22),
sustain_end=read_u32(data, descriptor_offset + 26),
)
validate_descriptor(path, descriptor, len(data), compression)
descriptors.append(descriptor)
if index < count:
if link == 0:
raise SndFormatError(f"{path}: sample-descriptor chain ends at entry {index}")
descriptor_offset = link
elif link != 0:
raise SndFormatError(f"{path}: final sample descriptor has a nonzero link")
return SoundFile(
path=path,
name=name,
instrument_id=instrument_id,
sample_rate=sample_rate,
compression=compression,
descriptors=descriptors,
data=data,
)
def validate_descriptor(
path: Path, descriptor: SampleDescriptor, file_size: int, compression: int
) -> None:
if descriptor.sample_count == 0:
return
if descriptor.data_offset >= file_size:
raise SndFormatError(
f"{path}: sample {descriptor.index} starts outside the file "
f"at 0x{descriptor.data_offset:x}"
)
stored_bytes = descriptor.stored_bytes
if compression == 0 and stored_bytes == 0:
stored_bytes = descriptor.sample_count
if stored_bytes == 0 or descriptor.data_offset + stored_bytes > file_size:
raise SndFormatError(f"{path}: sample {descriptor.index} data is truncated")
def decode_music_compression(payload: bytes, expected_samples: int) -> tuple[bytes, int]:
"""Decode DeskMate's music-compression stream from COMPRESS.RES.
Each stream starts with fourteen signed big-endian delta values and a
big-endian extended-run base. Codes are four-bit values, packed as the
first low nibble twice, then high/low nibble pairs. The final byte can
complete a command after the requested sample count; those samples are
padding and are discarded by the caller.
"""
if len(payload) < 30:
raise SndFormatError("music-compressed sample is shorter than its 30-byte codebook")
deltas = struct.unpack_from(">14h", payload)
extended_run_base = struct.unpack_from(">H", payload, 28)[0]
codes: list[int] = []
stream = payload[30:]
if stream:
codes.extend((stream[0] & 0x0F, stream[0] & 0x0F))
for byte in stream[1:]:
codes.extend((byte >> 4, byte & 0x0F))
output = bytearray()
sample = 0x80
state = 0
value = 0
def emit_run(length: int) -> None:
output.extend(bytes([sample]) * length)
for code in codes:
if state == 0:
if code < 14:
sample = max(0, min(255, sample + deltas[code]))
output.append(sample)
elif code == 14:
state = 4
else:
state = 1
elif state == 1:
if code < 14:
emit_run(code + 1)
elif code == 14:
sample = 0x80
state = 6
else:
state = 2
elif state == 2:
value = code << 4
state = 3
elif state == 3:
emit_run((value | code) + 14)
state = 0
elif state == 4:
value = code << 4
state = 5
elif state == 5:
sample = value | code
output.append(sample)
state = 0
elif state == 6:
value = code << 4
state = 7
elif state == 7:
emit_run((value | code) + extended_run_base + 1)
state = 0
else: # pragma: no cover - protects against accidental future edits.
raise AssertionError(f"invalid decoder state {state}")
if state != 0:
raise SndFormatError(f"music-compressed sample ends in incomplete decoder state {state}")
if len(output) < expected_samples:
raise SndFormatError(
f"music-compressed sample decodes to {len(output)} samples, "
f"shorter than its declared {expected_samples} samples"
)
return bytes(output[:expected_samples]), len(output) - expected_samples
def decode_sample(sound: SoundFile, descriptor: SampleDescriptor) -> tuple[bytes, int]:
if descriptor.sample_count == 0:
return b"", 0
stored_bytes = descriptor.stored_bytes
if sound.compression == 0 and stored_bytes == 0:
stored_bytes = descriptor.sample_count
payload = sound.data[descriptor.data_offset : descriptor.data_offset + stored_bytes]
if sound.compression == 0:
if len(payload) != descriptor.sample_count:
raise SndFormatError(
f"{sound.path}: uncompressed sample {descriptor.index} has "
f"{len(payload)} stored bytes but declares {descriptor.sample_count} samples"
)
return payload, 0
if sound.compression == 1:
return decode_music_compression(payload, descriptor.sample_count)
raise SndFormatError(
f"{sound.path}: speech compression is not implemented; no verified "
"speech-compressed sample is present in this corpus"
)
def midi_note_name(pitch: int) -> str | None:
if not 1 <= pitch <= 0x3F:
return None
midi_note = 32 + pitch
names = ("C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B")
return f"{names[midi_note % 12]}{midi_note // 12 - 1}"
def safe_name(value: str) -> str:
value = re.sub(r"[^A-Za-z0-9._-]+", "-", value).strip(".-")
return value or "sound"
def output_name(sound: SoundFile, descriptor: SampleDescriptor) -> str:
base = safe_name(sound.path.stem)
if len(sound.descriptors) == 1:
return f"{base}.wav"
pitch = midi_note_name(descriptor.pitch)
suffix = f"sample-{descriptor.index:02d}"
if pitch:
suffix += f"-{pitch}"
return f"{base}-{suffix}.wav"
def write_wav(path: Path, samples: bytes, sample_rate: int, descriptor: SampleDescriptor) -> None:
loop_enabled = (
descriptor.sustain_end > descriptor.sustain_start
and descriptor.sustain_end < len(samples)
)
chunks = [
(b"fmt ", struct.pack("<HHIIHH", 1, 1, sample_rate, sample_rate, 1, 8)),
]
if loop_enabled:
unity_note = 60
if 1 <= descriptor.pitch <= 0x3F:
unity_note = 32 + descriptor.pitch
sample_period = int(1_000_000_000 / sample_rate)
smpl = struct.pack(
"<9I6I",
0,
0,
sample_period,
unity_note,
0,
0,
0,
1,
0,
0,
0,
descriptor.sustain_start,
descriptor.sustain_end,
0,
0,
)
chunks.append((b"smpl", smpl))
chunks.append((b"data", samples))
body = bytearray(b"WAVE")
for chunk_id, chunk_data in chunks:
body += chunk_id + struct.pack("<I", len(chunk_data)) + chunk_data
if len(chunk_data) & 1:
body += b"\0"
path.write_bytes(b"RIFF" + struct.pack("<I", len(body)) + body)
def prepare_conversion(path: Path) -> tuple[SoundFile, list[tuple[SampleDescriptor, bytes, int]]]:
sound = parse_snd(path)
decoded = [(descriptor, *decode_sample(sound, descriptor)) for descriptor in sound.descriptors]
return sound, decoded
def write_conversion(
sound: SoundFile, decoded: list[tuple[SampleDescriptor, bytes, int]], out_dir: Path
) -> dict[str, object]:
out_dir.mkdir(parents=True, exist_ok=True)
sample_reports: list[dict[str, object]] = []
for descriptor, samples, discarded_padding in decoded:
report = asdict(descriptor)
report["pitch_name"] = midi_note_name(descriptor.pitch)
report["decoded_samples"] = len(samples)
report["discarded_padding_samples"] = discarded_padding
if samples:
wav_path = out_dir / output_name(sound, descriptor)
write_wav(wav_path, samples, sound.sample_rate, descriptor)
report["wav"] = str(wav_path)
else:
report["wav"] = None
sample_reports.append(report)
report = {
"source": str(sound.path),
"format": "DeskMate 3 / Tandy 2500-series SND",
"name": sound.name,
"instrument_id": sound.instrument_id,
"sample_rate": sound.sample_rate,
"compression": sound.compression,
"compression_name": COMPRESSION_NAMES[sound.compression],
"samples": sample_reports,
}
report_path = out_dir / f"{safe_name(sound.path.stem)}.json"
report_path.write_text(json.dumps(report, indent=2) + "\n", encoding="utf-8")
return {"report": str(report_path), **report}
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("inputs", nargs="+", type=Path, help="DeskMate 3 .SND files")
parser.add_argument("--out-dir", type=Path, default=Path("recovered/snd-wav"))
args = parser.parse_args()
prepared = []
for path in args.inputs:
try:
prepared.append(prepare_conversion(path))
except (OSError, SndFormatError) as exc:
parser.error(str(exc))
results = [write_conversion(sound, decoded, args.out_dir) for sound, decoded in prepared]
print(json.dumps(results, indent=2))
return 0
if __name__ == "__main__":
raise SystemExit(main())
#!/usr/bin/env python3
"""Convert Tandy DeskMate Music .SNG files to Standard MIDI files.
This is intentionally a small, dependency-free decoder. The container,
duration table, and pitch indexing are taken from the DeskMate 3 Music player.
"""
from __future__ import annotations
import argparse
import json
import struct
from dataclasses import dataclass, field
from pathlib import Path
PPQ = 96
DEFAULT_TEMPO_BPM = 100
# Native table at MUSIC.PDM DS:2268. A quarter note is 24 DeskMate units;
# scaling each unit to four MIDI ticks makes PPQ=96 exact.
DURATION_UNITS = (
168, 144, 96, 84, 72, 64, 48, 42,
36, 32, 24, 21, 18, 16, 12, -10,
9, 8, 6, -5, -4, 4, 3, 2,
)
# DeskMate's instrument numbers are the numbers found in the .SND headers.
# MIDI programs are zero-based General MIDI program numbers.
MIDI_PROGRAMS = {
1: 71, # clarinet
2: 0, # acoustic grand piano
3: 9, # glockenspiel, a useful bells approximation
4: 42, # cello
}
@dataclass
class Song:
path: Path
title: str
composer: str
voices: list[list[int]]
control: list[int]
diagnostics: dict[str, object] = field(default_factory=dict)
def read_c_string(data: bytes, offset: int) -> tuple[str, int]:
end = data.find(b"\0", offset)
if end < 0:
raise ValueError(f"unterminated metadata string at 0x{offset:x}")
return data[offset:end].decode("cp437", errors="replace"), end + 1
def parse_sng(path: Path) -> Song:
data = path.read_bytes()
if len(data) < 0x18 or data[:4] != b"\x0cSNG":
raise ValueError(f"{path}: not a DeskMate .SNG file")
title, cursor = read_c_string(data, 0x18)
composer, cursor = read_c_string(data, cursor)
streams: list[list[int]] = []
search = cursor
while True:
start = data.find(b"\xfe\x7f", search)
if start < 0:
break
end = data.find(b"\xff\x7f", start + 2)
if end < 0:
raise ValueError(f"{path}: unterminated stream at 0x{start:x}")
payload = data[start + 2 : end]
if len(payload) % 2:
raise ValueError(f"{path}: odd-sized stream at 0x{start:x}")
streams.append(list(struct.unpack(f"<{len(payload) // 2}H", payload)))
search = end + 2
if len(streams) < 3:
raise ValueError(f"{path}: expected three voice streams, found {len(streams)}")
return Song(
path=path,
title=title,
composer=composer,
voices=streams[:3],
control=streams[3] if len(streams) > 3 else [],
)
def vlq(value: int) -> bytes:
value = max(0, value)
out = bytearray([value & 0x7F])
value >>= 7
while value:
out.insert(0, (value & 0x7F) | 0x80)
value >>= 7
return bytes(out)
def text_meta(kind: int, value: str) -> bytes:
encoded = value.encode("utf-8", errors="replace")
return bytes([0xFF, kind]) + vlq(len(encoded)) + encoded
def make_track(events: list[tuple[int, bytes]], name: str) -> bytes:
body = bytearray()
body += b"\0" + text_meta(0x03, name)
for delta, event in events:
body += vlq(delta)
body += event
body += b"\0\xff\x2f\0"
return b"MTrk" + struct.pack(">I", len(body)) + body
def absolute_to_delta(events: list[tuple[int, bytes]]) -> list[tuple[int, bytes]]:
ordered = sorted(enumerate(events), key=lambda item: (item[1][0], item[0]))
result: list[tuple[int, bytes]] = []
previous = 0
for _, (position, event) in ordered:
result.append((position - previous, event))
previous = position
return result
def dynamic_volume(value: int) -> int:
# DeskMate stores a small dynamic-level index in 0x72xx. These values are
# deliberately moderate because the original player applies instrument
# sample scaling as well as the score dynamic.
return min(127, 32 + value * 16)
def decode_voice(
words: list[int], channel: int, pitch_offset: int, all_piano: bool
) -> tuple[bytes, dict[str, object]]:
events: list[tuple[int, bytes]] = []
if all_piano:
events.append((0, bytes([0xC0 | channel, 0])))
diagnostics = {
"initial_program": 0 if all_piano else None,
"instrument_changes": [],
"volume_changes": [],
"rests": 0,
"notes": 0,
"note_flag_words": [],
"unknown_words": [],
}
pending = 0
program = 0
volume = 100
for index, word in enumerate(words):
high = word >> 8
low = word & 0xFF
if high == 0x73:
program = 0 if all_piano else MIDI_PROGRAMS.get(low, 0)
diagnostics["instrument_changes"].append(
{"word": f"{word:04x}", "instrument_id": low, "program": program}
)
events.append((pending, bytes([0xC0 | channel, program])))
pending = 0
continue
if high == 0x72:
volume = dynamic_volume(low)
diagnostics["volume_changes"].append({"word": f"{word:04x}", "volume": volume})
events.append((pending, bytes([0xB0 | channel, 7, volume])))
pending = 0
continue
if high < 0x80:
diagnostics["unknown_words"].append({"index": index, "word": f"{word:04x}"})
continue
duration_index = high & 0x1F
if duration_index >= len(DURATION_UNITS) or DURATION_UNITS[duration_index] <= 0:
diagnostics["unknown_words"].append(
{"index": index, "word": f"{word:04x}", "reason": "unsupported duration index"}
)
continue
duration = DURATION_UNITS[duration_index] * 4
pending += duration
pitch_index = low & 0x3F
if pitch_index == 0:
diagnostics["rests"] += 1
continue
if low & 0xC0 or high & 0xE0 != 0x80:
diagnostics["note_flag_words"].append(
{
"index": index,
"word": f"{word:04x}",
"pitch_flags": low & 0xC0,
"event_flags": high & 0xE0,
}
)
# The native tone table has entries 1..63 for A1..B6.
pitch = 32 + pitch_index + pitch_offset
pitch = max(0, min(127, pitch))
# Consume the duration as a note length. A later event begins after
# this event, so its delta is accumulated in `pending`.
events.append((pending - duration, bytes([0x90 | channel, pitch, volume])))
events.append((duration, bytes([0x80 | channel, pitch, 0])))
pending = 0
diagnostics["notes"] += 1
if pending:
# MIDI delta times must precede a real event. Use an empty text meta
# event to carry a final rest before End of Track.
events.append((pending, bytes([0xFF, 0x01, 0x00])))
return make_track(events, f"DeskMate voice {channel + 1}"), diagnostics
def decode_control(words: list[int]) -> tuple[list[tuple[int, bytes]], dict[str, object]]:
position_units = 0
events: list[tuple[int, bytes]] = []
diagnostics: dict[str, object] = {
"tempo_changes": [],
"time_signature_changes": [],
"repeat_markers": [],
"other_controls": [],
}
for index, word in enumerate(words):
kind = word >> 12
value = word & 0x0FFF
position = position_units * 4
if kind == 0x2:
position_units += value
elif kind == 0x1:
bpm = word & 0xFF
if bpm:
tempo = int(60_000_000 / bpm)
events.append((position, bytes([0xFF, 0x51, 0x03]) + tempo.to_bytes(3, "big")))
diagnostics["tempo_changes"].append({"position_units": position_units, "bpm": bpm})
elif kind == 0x6:
numerator = ((word >> 4) & 0xFF) // 4
denominator = word & 0x0F
if numerator > 0 and denominator in (2, 4, 8, 16, 32):
denominator_power = denominator.bit_length() - 1
events.append(
(position, bytes([0xFF, 0x58, 0x04, numerator, denominator_power, 24, 8]))
)
diagnostics["time_signature_changes"].append(
{"position_units": position_units, "numerator": numerator, "denominator": denominator}
)
elif kind in (0x3, 0x4, 0x5):
diagnostics["repeat_markers"].append(
{"index": index, "position_units": position_units, "word": f"{word:04x}"}
)
elif kind != 0x7:
diagnostics["other_controls"].append(
{"index": index, "position_units": position_units, "word": f"{word:04x}"}
)
return events, diagnostics
def make_midi(song: Song, pitch_offset: int, all_piano: bool) -> tuple[bytes, dict[str, object]]:
tracks: list[bytes] = []
conductor_absolute = [
(0, text_meta(0x03, song.title)),
(0, text_meta(0x02, song.composer)),
(0, bytes([0xFF, 0x51, 0x03]) + int(60_000_000 / DEFAULT_TEMPO_BPM).to_bytes(3, "big")),
(0, bytes([0xFF, 0x58, 0x04, 4, 2, 24, 8])),
]
control_events, control_diagnostics = decode_control(song.control)
conductor_absolute.extend(control_events)
tracks.append(make_track(absolute_to_delta(conductor_absolute), "DeskMate conductor"))
diagnostics: dict[str, object] = {
"source": str(song.path),
"title": song.title,
"composer": song.composer,
"voice_count": len(song.voices),
"control_word_count": len(song.control),
"control": control_diagnostics,
"voices": [],
"pitch_offset": pitch_offset,
"all_piano": all_piano,
}
for channel, words in enumerate(song.voices):
track, voice_diag = decode_voice(words, channel, pitch_offset, all_piano)
tracks.append(track)
diagnostics["voices"].append(voice_diag)
header = b"MThd" + struct.pack(">IHHH", 6, 1, len(tracks), PPQ)
return header + b"".join(tracks), diagnostics
def convert(path: Path, out_dir: Path, pitch_offset: int, all_piano: bool) -> dict[str, object]:
song = parse_sng(path)
midi, diagnostics = make_midi(song, pitch_offset, all_piano)
out_dir.mkdir(parents=True, exist_ok=True)
midi_path = out_dir / f"{path.stem}.mid"
report_path = out_dir / f"{path.stem}.json"
midi_path.write_bytes(midi)
report_path.write_text(json.dumps(diagnostics, indent=2) + "\n", encoding="utf-8")
return {"midi": str(midi_path), "report": str(report_path), **diagnostics}
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("inputs", nargs="+", type=Path, help="DeskMate .SNG files")
parser.add_argument("--out-dir", type=Path, default=Path("recovered/sng-midi"))
parser.add_argument(
"--pitch-offset",
type=int,
default=0,
help="offset applied to the stored pitch byte (default: 0)",
)
parser.add_argument(
"--all-piano",
action="store_true",
help="use Acoustic Grand Piano for every instrument change",
)
args = parser.parse_args()
results = []
for path in args.inputs:
try:
results.append(convert(path, args.out_dir, args.pitch_offset, args.all_piano))
except (OSError, ValueError) as exc:
parser.error(str(exc))
print(json.dumps(results, indent=2))
return 0
if __name__ == "__main__":
raise SystemExit(main())
#!/usr/bin/env python3
"""Render DeskMate Music .SNG scores with their original .SND instruments.
The score decoder uses the native DeskMate duration and pitch indexing already
recovered by sng_to_midi.py. Instrument sample selection, pitch transposition,
and Clarinet sustain loops come directly from the DeskMate .SND descriptors.
Repeat blocks and alternate endings are unfolded into playback order.
"""
from __future__ import annotations
import argparse
import bisect
import json
import math
import struct
import sys
import wave
from array import array
from dataclasses import dataclass, field
from pathlib import Path
from snd_to_wav import SampleDescriptor, SndFormatError, decode_sample, parse_snd
from sng_to_midi import DEFAULT_TEMPO_BPM, DURATION_UNITS, Song, parse_sng
DEFAULT_OUTPUT_RATE = 44_000
DEFAULT_INSTRUMENT_ID = 2
DEFAULT_VOLUME_LEVEL = 3
MAX_VOLUME_LEVEL = 5
VOICE_MIX_GAIN = 1.0 / 3.0
RELEASE_SECONDS = 0.004
@dataclass(frozen=True)
class NoteEvent:
voice: int
start: int
duration: int
pitch: int
instrument_id: int
volume_level: int
connected_from_previous: bool
connected_to_next: bool
@property
def end(self) -> int:
return self.start + self.duration
@dataclass(frozen=True)
class InstrumentSample:
descriptor: SampleDescriptor
pcm: bytes
@dataclass
class Instrument:
instrument_id: int
name: str
source: Path
sample_rate: int
samples: list[InstrumentSample]
def sample_for_pitch(self, pitch: int) -> InstrumentSample:
for sample in self.samples:
descriptor = sample.descriptor
if descriptor.range_low <= pitch <= descriptor.range_high:
return sample
return min(self.samples, key=lambda item: abs(item.descriptor.pitch - pitch))
@dataclass
class RepeatNode:
start: int
count: int
marker_index: int
end: int | None = None
endings: list[tuple[int, int, int]] = field(default_factory=list)
children: list["RepeatNode"] = field(default_factory=list)
@dataclass(frozen=True)
class PlaybackInterval:
source_start: int
source_end: int
output_start_seconds: float
@dataclass(frozen=True)
class NoteOccurrence:
event: NoteEvent
start_frame: int
end_frame: int
class TempoMap:
def __init__(self, changes: list[tuple[int, int]]) -> None:
by_position: dict[int, int] = {0: DEFAULT_TEMPO_BPM}
for position, bpm in changes:
by_position[position] = bpm
self.changes = sorted(by_position.items())
self.positions = [position for position, _ in self.changes]
def tempo_at(self, position: int) -> int:
index = bisect.bisect_right(self.positions, position) - 1
return self.changes[max(0, index)][1]
def seconds_between(self, start: int, end: int) -> float:
if end <= start:
return 0.0
seconds = 0.0
cursor = start
bpm = self.tempo_at(start)
first = bisect.bisect_right(self.positions, start)
for position, new_bpm in self.changes[first:]:
if position >= end:
break
seconds += (position - cursor) * 60.0 / (bpm * 24.0)
cursor = position
bpm = new_bpm
return seconds + (end - cursor) * 60.0 / (bpm * 24.0)
def decode_tempo_changes(song: Song) -> list[tuple[int, int]]:
position = 0
changes: list[tuple[int, int]] = []
for word in song.control:
kind = word >> 12
if kind == 0x2:
position += word & 0x0FFF
elif kind == 0x1:
bpm = word & 0xFF
if bpm:
changes.append((position, bpm))
return changes
def decode_voice(words: list[int], voice: int) -> tuple[list[NoteEvent], int, dict[str, int]]:
position = 0
instrument_id = 0
volume_level = DEFAULT_VOLUME_LEVEL
notes: list[NoteEvent] = []
diagnostics = {
"notes": 0,
"rests": 0,
"instrument_changes": 0,
"volume_changes": 0,
"connected_notes": 0,
"unknown_words": 0,
}
for word in words:
high = word >> 8
low = word & 0xFF
if high == 0x73:
instrument_id = low
diagnostics["instrument_changes"] += 1
continue
if high == 0x72:
volume_level = low
diagnostics["volume_changes"] += 1
continue
if high < 0x80:
diagnostics["unknown_words"] += 1
continue
duration_index = high & 0x1F
if duration_index >= len(DURATION_UNITS):
diagnostics["unknown_words"] += 1
continue
duration = DURATION_UNITS[duration_index]
if duration <= 0:
diagnostics["unknown_words"] += 1
continue
pitch = low & 0x3F
if pitch:
# Bits 5 and 6 are inverted connection boundaries. 0xe0 is an
# ordinary detached note, 0xc0 begins a tie/slur, 0xa0 ends one,
# and 0x80 is an interior connected note.
connected_from = (high & 0x40) == 0
connected_to = (high & 0x20) == 0
notes.append(
NoteEvent(
voice=voice,
start=position,
duration=duration,
pitch=pitch,
instrument_id=instrument_id,
volume_level=volume_level,
connected_from_previous=connected_from,
connected_to_next=connected_to,
)
)
diagnostics["notes"] += 1
if connected_from or connected_to:
diagnostics["connected_notes"] += 1
else:
diagnostics["rests"] += 1
position += duration
return notes, position, diagnostics
def parse_repeat_tree(song: Song) -> tuple[list[RepeatNode], list[dict[str, int]]]:
roots: list[RepeatNode] = []
stack: list[RepeatNode] = []
records: list[dict[str, int]] = []
position = 0
for index, word in enumerate(song.control):
kind = word >> 12
value = word & 0x0FFF
if kind == 0x2:
position += value
continue
if kind == 0x3:
if value < 2:
raise ValueError(f"{song.path}: invalid repeat count {value} at control word {index}")
node = RepeatNode(start=position, count=value, marker_index=index)
if stack:
stack[-1].children.append(node)
else:
roots.append(node)
stack.append(node)
records.append({"kind": 3, "position_units": position, "value": value})
elif kind == 0x4:
if not stack:
raise ValueError(f"{song.path}: alternate ending outside a repeat at word {index}")
stack[-1].endings.append((position, value, index))
records.append({"kind": 4, "position_units": position, "value": value})
elif kind == 0x5:
if not stack:
raise ValueError(f"{song.path}: repeat end without a start at word {index}")
node = stack.pop()
if position <= node.start:
raise ValueError(f"{song.path}: empty repeat at control word {index}")
node.end = position
records.append({"kind": 5, "position_units": position, "value": value})
if stack:
raise ValueError(f"{song.path}: {len(stack)} unterminated repeat block(s)")
return roots, records
def unfold_repeats(roots: list[RepeatNode], total_units: int) -> list[tuple[int, int]]:
intervals: list[tuple[int, int]] = []
def append_interval(start: int, end: int) -> None:
if end <= start:
return
if intervals and intervals[-1][1] == start:
intervals[-1] = (intervals[-1][0], end)
else:
intervals.append((start, end))
def expand_range(start: int, end: int, children: list[RepeatNode]) -> None:
cursor = start
for child in sorted(children, key=lambda item: (item.start, item.marker_index)):
if child.end is None or child.start < start or child.end > end:
continue
if child.start < cursor:
raise ValueError("overlapping repeat blocks are not supported")
append_interval(cursor, child.start)
expand_repeat(child)
cursor = child.end
append_interval(cursor, end)
def expand_repeat(node: RepeatNode) -> None:
assert node.end is not None
endings = sorted(node.endings, key=lambda item: (item[1], item[0], item[2]))
first_ending_position = min((item[0] for item in endings), default=node.end)
if not node.start <= first_ending_position <= node.end:
raise ValueError("alternate ending lies outside its repeat")
for pass_number in range(1, node.count + 1):
expand_range(
node.start,
first_ending_position,
[child for child in node.children if child.end is not None and child.end <= first_ending_position],
)
if not endings:
continue
selected_index: int | None = None
for ending_index, (_, ending_number, _) in enumerate(endings):
next_number = endings[ending_index + 1][1] if ending_index + 1 < len(endings) else ending_number + 1
if ending_number <= pass_number < next_number:
selected_index = ending_index
break
if selected_index is None:
continue
ending_start = endings[selected_index][0]
ending_end = endings[selected_index + 1][0] if selected_index + 1 < len(endings) else node.end
expand_range(
ending_start,
ending_end,
[
child
for child in node.children
if child.end is not None and child.start >= ending_start and child.end <= ending_end
],
)
expand_range(0, total_units, roots)
return intervals
def make_playback_intervals(
source_intervals: list[tuple[int, int]], tempo_map: TempoMap
) -> tuple[list[PlaybackInterval], float]:
result: list[PlaybackInterval] = []
output_seconds = 0.0
for start, end in source_intervals:
result.append(PlaybackInterval(start, end, output_seconds))
output_seconds += tempo_map.seconds_between(start, end)
return result, output_seconds
def load_instruments(snd_dir: Path) -> dict[int, Instrument]:
instruments: dict[int, Instrument] = {}
candidates = sorted(
(path for path in snd_dir.iterdir() if path.is_file() and path.suffix.lower() == ".snd"),
key=lambda path: path.name.lower(),
)
for path in candidates:
sound = parse_snd(path)
# ID zero is used by ordinary Sound Editor recordings such as
# DESKTOP.SND. It is not a Music instrument file.
if sound.instrument_id == 0 or len(sound.descriptors) < 2:
continue
if sound.instrument_id in instruments:
other = instruments[sound.instrument_id].source
raise ValueError(f"duplicate instrument ID {sound.instrument_id}: {other} and {path}")
samples = [
InstrumentSample(descriptor, decode_sample(sound, descriptor)[0])
for descriptor in sound.descriptors
if descriptor.sample_count
]
if samples:
instruments[sound.instrument_id] = Instrument(
instrument_id=sound.instrument_id,
name=sound.name,
source=path,
sample_rate=sound.sample_rate,
samples=samples,
)
return instruments
def effective_instrument_id(instrument_id: int) -> int:
return DEFAULT_INSTRUMENT_ID if instrument_id == 0 else instrument_id
def note_occurrences(
notes: list[NoteEvent],
intervals: list[PlaybackInterval],
tempo_map: TempoMap,
sample_rate: int,
) -> list[NoteOccurrence]:
occurrences: list[NoteOccurrence] = []
note_starts = [note.start for note in notes]
for interval in intervals:
index = bisect.bisect_left(note_starts, interval.source_start)
while index < len(notes):
note = notes[index]
if note.start >= interval.source_end:
break
end = min(note.end, interval.source_end)
start_seconds = interval.output_start_seconds + tempo_map.seconds_between(
interval.source_start, note.start
)
end_seconds = interval.output_start_seconds + tempo_map.seconds_between(
interval.source_start, end
)
start_frame = round(start_seconds * sample_rate)
end_frame = max(start_frame + 1, round(end_seconds * sample_rate))
occurrences.append(NoteOccurrence(note, start_frame, end_frame))
index += 1
return occurrences
def connected_phrases(occurrences: list[NoteOccurrence]) -> list[list[NoteOccurrence]]:
phrases: list[list[NoteOccurrence]] = []
for occurrence in occurrences:
if not phrases:
phrases.append([occurrence])
continue
previous = phrases[-1][-1]
connected = (
previous.event.connected_to_next
and occurrence.event.connected_from_previous
and abs(previous.end_frame - occurrence.start_frame) <= 1
and previous.event.end == occurrence.event.start
and effective_instrument_id(previous.event.instrument_id)
== effective_instrument_id(occurrence.event.instrument_id)
)
if connected:
phrases[-1].append(occurrence)
else:
phrases.append([occurrence])
return phrases
def render_phrase(
mix: array,
phrase: list[NoteOccurrence],
instruments: dict[int, Instrument],
output_rate: int,
) -> None:
source_position = 0.0
active_sample: InstrumentSample | None = None
phrase_end = phrase[-1].end_frame
release_frames = max(1, round(RELEASE_SECONDS * output_rate))
for occurrence in phrase:
note = occurrence.event
instrument_id = effective_instrument_id(note.instrument_id)
instrument = instruments[instrument_id]
sample = instrument.sample_for_pitch(note.pitch)
if sample is not active_sample:
active_sample = sample
source_position = 0.0
descriptor = sample.descriptor
pcm = sample.pcm
step = math.pow(2.0, (note.pitch - descriptor.pitch) / 12.0)
step *= instrument.sample_rate / output_rate
loop_enabled = (
descriptor.sustain_end > descriptor.sustain_start
and descriptor.sustain_end < len(pcm)
)
gain = max(0.0, min(MAX_VOLUME_LEVEL, note.volume_level)) / MAX_VOLUME_LEVEL
gain *= VOICE_MIX_GAIN
for frame in range(occurrence.start_frame, occurrence.end_frame):
if loop_enabled and source_position >= descriptor.sustain_end:
loop_length = descriptor.sustain_end - descriptor.sustain_start
source_position = descriptor.sustain_start + (
(source_position - descriptor.sustain_start) % loop_length
)
source_index = int(source_position)
if source_index >= len(pcm):
break
envelope = 1.0
if frame >= phrase_end - release_frames:
envelope = max(0.0, (phrase_end - frame) / release_frames)
mix[frame] += ((pcm[source_index] - 128) / 128.0) * gain * envelope
source_position += step
def render_song(
song: Song,
instruments: dict[int, Instrument],
output_rate: int,
) -> tuple[array, dict[str, object]]:
voices: list[list[NoteEvent]] = []
voice_diagnostics: list[dict[str, int]] = []
total_units = 0
for voice_index, words in enumerate(song.voices):
notes, voice_units, diagnostics = decode_voice(words, voice_index)
voices.append(notes)
voice_diagnostics.append(diagnostics)
total_units = max(total_units, voice_units)
control_position = sum((word & 0x0FFF) for word in song.control if word >> 12 == 0x2)
total_units = max(total_units, control_position)
roots, repeat_records = parse_repeat_tree(song)
source_intervals = unfold_repeats(roots, total_units)
tempo_changes = decode_tempo_changes(song)
tempo_map = TempoMap(tempo_changes)
playback, duration_seconds = make_playback_intervals(source_intervals, tempo_map)
total_frames = max(1, round(duration_seconds * output_rate))
required_ids = sorted(
{
effective_instrument_id(note.instrument_id)
for voice in voices
for note in voice
}
)
missing = [instrument_id for instrument_id in required_ids if instrument_id not in instruments]
if missing:
raise ValueError(
f"{song.path}: missing DeskMate instrument ID(s) {', '.join(map(str, missing))}"
)
mix = array("f", [0.0]) * total_frames
occurrence_counts: list[int] = []
phrase_counts: list[int] = []
for notes in voices:
occurrences = note_occurrences(notes, playback, tempo_map, output_rate)
phrases = connected_phrases(occurrences)
occurrence_counts.append(len(occurrences))
phrase_counts.append(len(phrases))
for phrase in phrases:
render_phrase(mix, phrase, instruments, output_rate)
peak = max((abs(value) for value in mix), default=0.0)
clipped_frames = sum(1 for value in mix if abs(value) > 1.0)
rms = math.sqrt(sum(value * value for value in mix) / len(mix))
report: dict[str, object] = {
"source": str(song.path),
"title": song.title,
"composer": song.composer,
"sample_rate": output_rate,
"channels": 1,
"sample_width_bits": 16,
"duration_seconds": duration_seconds,
"source_duration_units": total_units,
"playback_duration_units": sum(end - start for start, end in source_intervals),
"tempo_changes": [
{"position_units": position, "bpm": bpm} for position, bpm in tempo_changes
],
"repeat_records": repeat_records,
"playback_intervals": [
{"source_start": start, "source_end": end} for start, end in source_intervals
],
"default_instrument": {
"score_id": 0,
"resolved_instrument_id": DEFAULT_INSTRUMENT_ID,
"name": instruments[DEFAULT_INSTRUMENT_ID].name,
},
"instruments": [
{
"instrument_id": instrument_id,
"name": instruments[instrument_id].name,
"source": str(instruments[instrument_id].source),
}
for instrument_id in required_ids
],
"voices": [
{
**diagnostics,
"rendered_note_occurrences": occurrence_counts[index],
"rendered_phrases": phrase_counts[index],
}
for index, diagnostics in enumerate(voice_diagnostics)
],
"peak": peak,
"rms": rms,
"clipped_frames": clipped_frames,
}
return mix, report
def write_pcm16_wav(path: Path, mix: array, sample_rate: int) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
with wave.open(str(path), "wb") as output:
output.setnchannels(1)
output.setsampwidth(2)
output.setframerate(sample_rate)
block_size = 65_536
for start in range(0, len(mix), block_size):
pcm = array(
"h",
(
round(max(-1.0, min(1.0, value)) * 32767.0)
for value in mix[start : start + block_size]
),
)
if sys.byteorder != "little":
pcm.byteswap()
output.writeframesraw(pcm.tobytes())
def convert(
path: Path,
out_dir: Path,
instruments: dict[int, Instrument],
sample_rate: int,
) -> dict[str, object]:
song = parse_sng(path)
mix, report = render_song(song, instruments, sample_rate)
out_dir.mkdir(parents=True, exist_ok=True)
wav_path = out_dir / f"{path.stem}.wav"
report_path = out_dir / f"{path.stem}.json"
write_pcm16_wav(wav_path, mix, sample_rate)
report["wav"] = str(wav_path)
report_path.write_text(json.dumps(report, indent=2) + "\n", encoding="utf-8")
return {"wav": str(wav_path), "report": str(report_path), **report}
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("inputs", nargs="+", type=Path, help="DeskMate Music .SNG files")
parser.add_argument(
"--snd-dir",
type=Path,
help="directory containing the original DeskMate instrument .SND files "
"(default: directory of the first input)",
)
parser.add_argument("--out-dir", type=Path, default=Path("recovered/sng-wav"))
parser.add_argument(
"--sample-rate",
type=int,
default=DEFAULT_OUTPUT_RATE,
help=f"output sample rate (default: {DEFAULT_OUTPUT_RATE})",
)
args = parser.parse_args()
if args.sample_rate < 8_000 or args.sample_rate > 192_000:
parser.error("--sample-rate must be between 8000 and 192000")
snd_dir = args.snd_dir if args.snd_dir is not None else args.inputs[0].parent
try:
instruments = load_instruments(snd_dir)
if DEFAULT_INSTRUMENT_ID not in instruments:
raise ValueError(f"{snd_dir}: the default Piano instrument (ID 2) was not found")
results = [convert(path, args.out_dir, instruments, args.sample_rate) for path in args.inputs]
except (OSError, SndFormatError, ValueError) as exc:
parser.error(str(exc))
print(json.dumps(results, indent=2))
return 0
if __name__ == "__main__":
raise SystemExit(main())
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment