diff --git a/sdk/digitakt/research/dtpeek.py b/sdk/digitakt/research/dtpeek.py new file mode 100755 index 000000000..fc45d2342 --- /dev/null +++ b/sdk/digitakt/research/dtpeek.py @@ -0,0 +1,194 @@ +#!/usr/bin/env python3 +"""Read Digitakt mk1 memory over USB through digihealth's diagnostics channel. + +digihealth answers SysEx device 0x7D on USB with three read-only commands: +HELLO, STATS and PEEK (DDR 0x40000000-0x48000000 and SRAM 0x80000000-0x80010000, +at most 1024 bytes per request). This is a macOS/Linux client for it, for +research: dump a range, change one thing on the unit, dump again, diff. + + dtpeek.py ports + dtpeek.py hello + dtpeek.py peek 0x4020bb14 64 + dtpeek.py dump 0x42000000 0x10000 before.bin + dtpeek.py diff before.bin after.bin [--base 0x42000000] + dtpeek.py selftest + +Needs python-rtmidi. Close Elektron Transfer first. Never commit dumps. +""" +import argparse +import struct +import sys +import time + +HDR = bytes([0xF0, 0x00, 0x20, 0x3C, 0x7D, 0x00]) +C_HELLO, C_STATS, C_PEEK = 1, 2, 3 +PEEK_MAX = 1024 +RANGES = [(0x40000000, 0x48000000), (0x80000000, 0x80010000)] + + +def pack7(data): + """MidiRpc packing: before each group of up to 7 bytes, a byte whose bit 6-k is byte k's bit 7.""" + out = bytearray() + for i in range(0, len(data), 7): + group = data[i:i + 7] + top = 0 + for k, b in enumerate(group): + if b & 0x80: + top |= 0x40 >> k + out.append(top) + out.extend(b & 0x7F for b in group) + return bytes(out) + + +def unpack7(data): + out = bytearray() + i = 0 + while i < len(data): + top = data[i] + i += 1 + for k in range(7): + if i >= len(data): + break + out.append(data[i] | (0x80 if top & (0x40 >> k) else 0)) + i += 1 + return bytes(out) + + +class Channel: + def __init__(self, match='Digitakt', timeout=1.0): + import rtmidi + self.timeout = timeout + self.midi_in, self.midi_out = rtmidi.MidiIn(), rtmidi.MidiOut() + self.midi_in.ignore_types(sysex=False) + self.midi_in.open_port(self._port(self.midi_in, match)) + self.midi_out.open_port(self._port(self.midi_out, match)) + self.seq = 0 + + @staticmethod + def _port(io, match): + for n, name in enumerate(io.get_ports()): + if match.lower() in name.lower(): + return n + sys.exit(f'no MIDI port matching {match!r}: {io.get_ports()}') + + def request(self, cmd, args=b''): + self.seq = (self.seq + 1) & 0xFFFF + self.midi_out.send_message(list(HDR + pack7(struct.pack('>HB', self.seq, cmd) + args) + b'\xF7')) + end = time.monotonic() + self.timeout + while time.monotonic() < end: + msg = self.midi_in.get_message() + if not msg: + time.sleep(0.001) + continue + raw = bytes(msg[0]) + if not raw.startswith(HDR) or raw[-1] != 0xF7: + continue + body = unpack7(raw[len(HDR):-1]) + seq, rcmd, status = struct.unpack('>HBB', body[:4]) + if seq == self.seq and rcmd == cmd | 0x80: + if status: + raise RuntimeError(f'command {cmd} refused (status {status})') + return body[4:] + raise TimeoutError('no answer: is a digihealth build running, over USB?') + + def hello(self): + p = self.request(C_HELLO) + version, seconds = struct.unpack('>HI', p[:6]) + return version, seconds, p[6:].split(b'\0')[0].decode(errors='replace') + + def peek(self, addr, length): + if not any(lo <= addr and addr + length <= hi for lo, hi in RANGES): + raise ValueError(f'{addr:#x}+{length:#x} is outside DDR and SRAM') + out = bytearray() + while length: + n = min(length, PEEK_MAX) + p = self.request(C_PEEK, struct.pack('>IH', addr, n)) + got = struct.unpack('>I', p[:4])[0] + if got != addr or len(p) - 4 != n: + raise RuntimeError(f'bad PEEK reply at {addr:#x}') + out += p[4:] + addr += n + length -= n + return bytes(out) + + +def hexdump(data, base): + for i in range(0, len(data), 16): + row = data[i:i + 16] + text = ''.join(chr(b) if 32 <= b < 127 else '.' for b in row) + print(f'{base + i:08x} {row.hex(" "):<47} {text}') + + +def diff(a, b, base): + """Changed byte runs, merged when within 4 bytes of each other.""" + runs, start, last = [], None, None + for i in range(min(len(a), len(b))): + if a[i] != b[i]: + if start is None or i - last > 4: + if start is not None: + runs.append((start, last + 1)) + start = i + last = i + if start is not None: + runs.append((start, last + 1)) + for s, e in runs: + print(f'{base + s:08x} +{e - s:<4} {a[s:e].hex(" ")} -> {b[s:e].hex(" ")}') + print(f'{len(runs)} changed run(s)') + + +def selftest(): + import random + rng = random.Random(1) + for n in (0, 1, 6, 7, 8, 13, 14, 15, 1024): + data = bytes(rng.randrange(256) for _ in range(n)) + packed = pack7(data) + assert all(b < 0x80 for b in packed) and unpack7(packed) == data, n + # A HELLO request, sequence 1: the bytes digihealth's unpack7 expects. + assert pack7(struct.pack('>HB', 1, C_HELLO)) == bytes([0, 0, 1, 1]) + assert pack7(bytes([0x80])) == bytes([0x40, 0x00]) + print('selftest ok') + + +def main(): + ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument('--port', default='Digitakt', help='MIDI port name to match') + sub = ap.add_subparsers(dest='cmd', required=True) + sub.add_parser('ports') + sub.add_parser('hello') + sub.add_parser('selftest') + p = sub.add_parser('peek') + p.add_argument('addr', type=lambda s: int(s, 0)) + p.add_argument('length', type=lambda s: int(s, 0)) + p = sub.add_parser('dump') + p.add_argument('addr', type=lambda s: int(s, 0)) + p.add_argument('length', type=lambda s: int(s, 0)) + p.add_argument('out') + p = sub.add_parser('diff') + p.add_argument('a') + p.add_argument('b') + p.add_argument('--base', type=lambda s: int(s, 0), default=0) + args = ap.parse_args() + + if args.cmd == 'selftest': + return selftest() + if args.cmd == 'diff': + return diff(open(args.a, 'rb').read(), open(args.b, 'rb').read(), args.base) + if args.cmd == 'ports': + import rtmidi + print('in: ', rtmidi.MidiIn().get_ports()) + print('out:', rtmidi.MidiOut().get_ports()) + return + ch = Channel(args.port) + if args.cmd == 'hello': + version, seconds, name = ch.hello() + print(f'{name}: channel version {version}, up {seconds} s') + elif args.cmd == 'peek': + hexdump(ch.peek(args.addr, args.length), args.addr) + elif args.cmd == 'dump': + data = ch.peek(args.addr, args.length) + open(args.out, 'wb').write(data) + print(f'{len(data)} bytes from {args.addr:#x} to {args.out}') + + +if __name__ == '__main__': + main() diff --git a/sdk/digitakt/research/pattern-write.md b/sdk/digitakt/research/pattern-write.md new file mode 100644 index 000000000..061394bc5 --- /dev/null +++ b/sdk/digitakt/research/pattern-write.md @@ -0,0 +1,120 @@ +# Writing trigs into the pattern (Digitakt mk1, OS 1.54) + +Research for a pattern generator mod (a meloDICEr-style random melody generator): how a mod can write trigs, NOTE, VELOCITY and LENGTH into the active pattern, as a user's edit would, from the UI task. + +**Status:** static analysis of the stock OS 1.54 MAIN image, then checked in the digiemu emulator (see [Emulator results](#emulator-results)). Nothing here has run on a unit. Confidence: **H** read in the code, **M** strong inference, **G** guess, **E** confirmed in the emulator. [Hardware checks](#hardware-checks) lists what to confirm with [dtpeek.py](dtpeek.py) before any mod relies on it. OS 1.53 is not mapped yet (see [OS 1.53](#os-153)). + +## Emulator results + +digidice ([sdk/drafts/digidice](../../drafts/digidice/README.md)) and a probe mod were run in digiemu v0.4.1 on OS 1.54 (report: [evidence/emulator.md](../../drafts/digidice/evidence/emulator.md)). + +- **E:** the trig, NOTE, VEL and LEN setters called from `ev_tick` and `ev_key` write the bytes listed under [Track data](#track-data) (flags +0x000, VEL +0x080, LEN +0x0C0, NOTE +0x280). The track's own setters notify; no extra `changed(NULL)` was needed. +- **E:** the sequencer plays the edit at once. Voice starts and the recorded audio follow the written steps and notes, so the "mirror" question below does not affect a generator. +- **E:** on an audio track NOTE 60 is C5 (0), no transpose. On MIDI tracks the same setters and layout work (track 9 written, track 1 unchanged). +- **E:** the LEN index counts eighths of a step up to 4 steps (index 0–31), then quarters to 8 steps (32–47), then halves to 16 steps (48–63). +- **E:** a key event's flags (`ev+16`) carry 1 for a press, 2 with FUNC held, 4 for a double press and 0x10 for a release. Two presses of TRIG 140 ms apart arrive as `19/1` then `19/5`; 960 ms apart, both are `19/1`. Stock OS 1.54 does nothing extra on a double press on TRIG. FUNC+YES and FUNC+NO are stock ("Prj must be re-saved!" on an unsaved project); FUNC+BANK is the metronome. +- **Not confirmed:** the undo snapshot. The arguments in [Recommended call sequence](#recommended-call-sequence-m) (`…, 0, 0, 1`) match no stock caller; the stock ones pass `(1, 0, 1)` (CLEAR PATTERN), `(1, 1, 0)`, `(0, 1, 0)` and `(1, 0, 0)`. Runs with a snapshot hit digiemu's own OS 1.54 heap fault (stock stops the same way after CLEAR PATTERN), so the snapshot could not be judged there. digidice v1 takes none. + +The MAIN image loads at `0x40000400`. Every routine takes its arguments on the stack, `this` first; the result is in d0. + +## Summary + +- The OS already has a setter for each per-step field, and they are the same ones the grid, the TRIG page and live recording use (H). Each one clamps the step, ignores steps past the track's length, writes one byte and notifies. +- NOTE, VEL, LEN, micro timing and condition are plain per-step arrays, not p-locks, so a generator does not use up the pattern's p-lock capacity (H). +- No mutex protects pattern data. The stock UI writes it from the UI task, and so can a mod from `ev_tick` or `ev_key` (M). Do not call the setters from `ev_render_in` or `ev_render_out`: notifying allocates memory and takes queue mutexes (H for what they call). +- A stock undo snapshot can be taken before writing (H for the code). +- digislicer already calls `NOTESET` and `LOCKSET` from the UI task on 1.53 and 1.54 ([os154.h](../modules/digislicer/src/os154.h)), which is precedent for this approach. + +## Reaching a track + +```c +app = 0x40138882(); /* OS_APP: the project singleton */ +pat = 0x40015786(app); /* OS_PATTERN: app + 0xAFC + n * 0x64C, n = the active pattern, 0..127 */ +trk = 0x40012a02(pat, t); /* OS_PTRACK: pat + 0x70 + t * 0x58, t = 0..15 */ +data = (*(void *(**)(void *))(*(u32 *)trk + 40))(trk); /* vtable slot 10: the track's data */ +``` + +`OS_APP`, `OS_PATTERN` and `OS_PTRACK` are digislicer's names. Tracks 0–7 are the audio tracks and 8–15 the MIDI tracks; both use the same data layout (H). + +## Track data + +64 entries per array, indexed by step. -1 means "the track's default". + +| Offset | Type | Field | Setter | Conf. | +| --- | --- | --- | --- | --- | +| +0x000 | u16[64] | step flags; bit 0x0001 = a trig | `0x40026050(trk, step, mask, on)` | H | +| +0x080 | s8[64] | VELOCITY | `0x40024b2a(trk, step, v)`, no clamp | H | +| +0x0C0 | s8[64] | LENGTH, an index into the table at `0x40191590` | `0x40024ba4(trk, step, v)`, no clamp | H | +| +0x100 | s8[64] | micro timing, -23..+23 | `0x40024c1e(trk, step, v + 64)` | H | +| +0x140 / +0x180 / +0x1C0 | 64 each | retrig length / rate / velocity curve | `0x40024d3c` / `0x40024cb2` / `0x40024dc6` | M | +| +0x200 | s8[64] | trig condition, -1 = none | `0x400245b2(trk, step, v)` | H | +| +0x240 | s8[64] | sound lock (audio tracks) | `0x4002483c` | M | +| +0x280 | s8[64] | NOTE, 0..127 | `0x40024942(trk, step, v)` (`OS_NOTESET`) | H | +| +0x2C0 / +0x300 / +0x340 | s8[64] | NOTE 2–4 (MIDI chords) | `0x400249bc` / `0x40024a36` / `0x40024ab0` | H | +| +0x380 / +0x381 / +0x384 | s8 | track default VEL / LEN / NOTE | | H | +| +0x388 | s16 | track length (per-track scale mode) | `0x40024f94` | H | +| +0x38B | s8 | track speed index | `0x4002506c` | H | + +The effective length of a track is `0x40024700(trk)` (H); every setter returns without writing at or past it. `track_t` is 0x38F bytes (H). + +Trig flags (H): `0x400261c2(trk, step, on)` turns a note trig on or off. Turning a new trig on first resets the step's NOTE, VEL and LEN to -1, so set them afterwards. Turning a trig off also clears the step's parameters and p-locks. `0x4002622a(trk, step, 1)` makes a trigless lock trig instead. + +The TRIG page reaches the same setters through `TrigParameterSet::set` `0x4000f79c(set, id, value << 8, step)`: id 18 NOTE, 19 VEL, 20 LEN, 21 micro, 22 condition, 26–28 NOTE 2–4 (H). + +P-locks live elsewhere: `pattern + 0x38F0 + t * 0x1B35`, 108 bytes per step (M for the address arithmetic). The saved pattern holds at most 80 locked (track, parameter) rows (H for the loader). + +## Making playback and the screen see an edit + +Each setter calls the track's `changed` (vtable +0x10) with a `TrackStepChangedInfo`. That posts a "Track::updateMirror" job to a low-priority worker task and notifies the observers (H). After a bulk edit, call `changed(trk, NULL)` once, which covers the whole track, as the stock PASTE TRIGS (`0x40026c16`) does (H). + +**Open question:** what the mirror is. One reading of the code says the sequencer plays the mirror, so an edit is heard once the worker has run. The other says the sequencer plays the runtime data directly (`*(u32 *)0x4199EC38` points into the 128 resident patterns at `0x409BBC18 + n * 0x1EC68`) and the mirror is the serialised save image. The call sequence below is correct either way. Hardware check 4 settles it. + +## Recommended call sequence (M) + +From `ev_tick` or `ev_key` only: + +```c +/* optional: a stock undo snapshot, kind 1 */ +0x4001a2f2(app, n, 0, 0, 1); /* n = 0x40092890(0x401389c4()), clamped to 0..127 */ + +for (s = 0; s < steps; s++) { + if (on[s]) { + 0x400261c2(trk, s, 1); /* note trig; resets NOTE/VEL/LEN to -1 */ + 0x40024942(trk, s, note[s]); + 0x40024b2a(trk, s, vel[s]); /* 0..127 or -1: not clamped */ + 0x40024ba4(trk, s, len[s]); /* a LEN index or -1: not clamped */ + } else { + 0x400261c2(trk, s, 0); /* also clears that step's locks */ + } +} +(*(void (**)(void *, void *))(*(u32 *)trk + 16))(trk, 0); /* changed(NULL) */ +``` + +**Undo:** `0x40017fb8(app, n, 1)` restores a kind-1 snapshot and shows "Undo Pattern". The stock CLEAR PATTERN action (`0x4002c1d6`) first tries to undo kind 1, so the stock clear-pattern combo would undo a dice (G for which keys). Any later copy, paste or clear replaces the snapshot. + +## Not used: injecting key presses + +Synthesizing the key and encoder presses a user would make is possible (the UI queue is `0x40610c28`), but it needs grid recording on and the TRIG page showing, the user sees every step of it, and it cannot fake keys the OS reads straight from the key bitmap (FUNC, PAGE, REC, STOP). Calling the setters directly is quieter and needs less research. + +## Hardware checks + +Needs a build with **digihealth** (it answers the read-only PEEK channel on USB) and `python-rtmidi`. Close Elektron Transfer first. Keep dumps out of the repository. + +```sh +python3 sdk/digitakt/research/dtpeek.py hello +python3 sdk/digitakt/research/dtpeek.py peek 0x4199EC38 4 # the sequencer's pattern pointer +``` + +1. **The active pattern:** the word at `0x4199EC38` equals `0x409BBC18 + n * 0x1EC68` for the selected pattern n (A01 = 0), and the pattern number at that address + 0x1EC64 equals n (its width is unconfirmed). +2. **Step arrays:** on track 1 of an empty pattern, enter one trig on step 3 with NOTE, VEL and LEN changed on the TRIG page. Dump track 1 (`dump 0x38F a.bin`) before and after, then `diff`. Expect flag bit 0x0001 at +0x004, and bytes at +0x83, +0xC3 and +0x283. Note how the LEN index maps to the displayed length. +3. **A trigless lock trig:** its flags read 0x6801. The track length at +0x388 reads 16 in a default pattern. +4. **The mirror:** read the Track handle's +0x20 (the mirror pointer) and dump the mirror before and shortly after an edit, to find out whether the sequencer reads it. +5. **MIDI tracks:** repeat check 2 on track 9 (t = 8). + +## OS 1.53 + +Only digislicer's addresses (`OS_APP`, `OS_PATTERN`, `OS_PTRACK`, `OS_NOTESET`, `OS_LOCKSET`) exist for 1.53, in [os153.h](../modules/digislicer/src/os153.h). The other routines need mapping from a stock 1.53 image. Elektron's download page offers only 1.54 now. A first mod can target 1.54 alone. + +## Method + +The stock `Digitakt_OS1.54.syx` (SHA-256 `f78ba80f…3e3cf6`, as in [machine.json](../../machines/digitakt/machine.json)) was decoded with the vendored elekloader kit (`Syx.section(3)`) and disassembled with GNU objdump (`-m m68k:cfv4e --adjust-vma=0x40000400`). The OS keeps its C++ RTTI names (`Pattern`, `Track`, `TrackStepChangedInfo`, `ValueWithMirror<…>`), which lead to the vtables, the setters and the notification chain. The firmware and the disassembly stay on the developer's computer. diff --git a/sdk/drafts/digidice/LICENSE b/sdk/drafts/digidice/LICENSE new file mode 100644 index 000000000..318e99eb2 --- /dev/null +++ b/sdk/drafts/digidice/LICENSE @@ -0,0 +1,22 @@ +MIT License + +Copyright (c) 2026 todd-neale +Copyright (c) 2026 digidice contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/sdk/drafts/digidice/README.md b/sdk/drafts/digidice/README.md new file mode 100644 index 000000000..41a759d73 --- /dev/null +++ b/sdk/drafts/digidice/README.md @@ -0,0 +1,91 @@ +# digidice + +A random melody generator for the Digitakt mk1, inspired by Vermona's meloDICEr. A double press on TRIG opens a page that rolls a melody into the active track, audio or MIDI, as ordinary trigs with a note, a velocity and a length each. After that it is a normal pattern: play it, edit it, save it or copy it. + +By todd-neale (@todd-neale) · MIT · Digitakt OS 1.54 · version `0.1.0-experimental` · **emulator-tested only, not yet run on a unit** + +**Status: draft.** It lives in `sdk/drafts/` until it has run on a unit and has an `.elemod` release that the browser builder can pin. Then it moves to `sdk/digitakt/modules/digidice` (see [TESTING.md](TESTING.md), "Hardware test plan"). + +![The digidice page after a roll](media/page-knobs.png) + +## Overview + +- **Rhythm:** DENSITY decides how many rhythm slots play, NOTE VALUE the gap between them, VARIATION how often a gap halves or doubles, LEGATO how long each note lasts. +- **Notes:** every note is in the chosen SCALE, from the ROOT up to RANGE scale notes above it. VEL spreads velocities around the track's own VEL. +- **Dice:** YES rolls everything, LEFT rolls only the rhythm and RIGHT only the notes, so a groove can keep its rhythm while its notes change, or the other way round. +- **Reshaping:** a knob turn reshapes the current melody instead of replacing it. Raising DENSITY only adds notes: the notes already there keep their step, pitch, velocity and length. +- **Timing is the Digitakt's own.** digidice writes trigs and keeps no clock, so the melody follows the project tempo, the track's length, speed and swing, like any trigs you enter by hand. + +## Controls + +The page shows the track (T1–T8 audio, M1–M8 MIDI) and its length, a piano roll of the track as it is now, and the eight controls. + +| Knob | Control | Range | What it does | +| --- | --- | --- | --- | +| A | DENSITY | 0–127 | The chance each rhythm slot plays; 0 is silence, 127 every slot | +| B | NOTE VALUE | 1, 2, 3, 4, 6, 8 | The gap between notes in steps: 1 sixteenths, 2 eighths, 4 quarters | +| C | VARIATION | 0–127 | The chance a gap halves or doubles | +| D | LEGATO | 0–127 | Note length as a share of the gap; 127 ties each note to the next | +| E | ROOT | C–B | The key's root note | +| F | SCALE | CHROM, MAJOR, MINOR, DORIAN, MIXO, HARMIN, MAJPEN, MINPEN | The notes allowed | +| G | RANGE | 0–24 | How many scale notes above the root | +| H | VEL | 0–127 | How far velocities spread around the track's VEL | + +| Key | On the page | +| --- | --- | +| TRIG, twice | Opens the page (from any page); closes it again | +| YES | Rolls the rhythm and the notes | +| LEFT | Rolls only the rhythm | +| RIGHT | Rolls only the notes | +| NO | Closes the page | +| PLAY, STOP | As usual, so you can listen while you shape the melody | + +Knob changes are written about 150 ms after the knob stops, and only the steps that changed. A single press on TRIG while the page is open does nothing, so closing with a double press does not open the page again. + +## Usage + +### Tutorial + +1. **Set it up and select it.** Select an audio track that plays a tonal sample (TRK + trig key 1). Press TRIG twice quickly: the digidice page opens and shows the track's current trigs. Nothing has changed yet. +2. **Roll.** Press YES: the piano roll shows a new melody, written into the track. Press PLAY to hear it. +3. **Use one control.** Turn DENSITY (knob A) left for a sparser line, right for a busier one, then press RIGHT a few times to re-roll the notes over the same rhythm. +4. **Hear the result and stop.** Press NO to close the page; the melody keeps playing as ordinary trigs. Press STOP to stop. Open the TRIG page and hold a trig to see its NOTE, VEL and LEN. + +### Ideas + +- Roll a bassline, keep the rhythm that locks with the drums, and re-roll only the notes until it fits. +- Use a MIDI track to drive an external synth, then edit single trigs by hand. +- Set NOTE VALUE to 4 and LEGATO to 127 for slow tied pads; set NOTE VALUE to 1 with VARIATION up for broken sixteenths. + +## Compatibility and limitations + +- **OS 1.54 only.** It calls the OS's own step setters at OS 1.54's addresses ([research](../../digitakt/research/pattern-write.md)). +- **Rolling replaces the track.** A roll or a knob turn rewrites every step of the active track up to its length (at most 64), and clears the parameter locks of steps it turns off. There is no undo: save the pattern first if you want to keep it. Opening the page changes nothing. +- **Settings are not saved.** The controls and seeds return to their defaults when the unit restarts. The melody itself is a normal pattern and saves with the project. +- **Octave.** Notes start from C5 (no transpose on an audio track) plus the root. There is no octave control yet. +- **The page takes the keys.** While it is open, every key but PLAY, STOP and FUNC is digidice's, so tracks cannot be changed from the page. +- **Key combination.** It claims the double press on TRIG (`ui:double TRIG`). Stock OS 1.54 shows the TRIG page for a double press and does nothing else with it. +- **Memory.** 6,611 bytes of core 2.1's 128 KiB shared mod memory, no fast SRAM. It fits beside any two of digihealth, DIGISLICER and NEIGHBOR, but not all three. +- **Timing.** No clock of its own, no render hook: see [sequencing](../../../docs/module-guides/sequencing.md). Swing, track speeds, tempo and pattern changes have not been tested with it yet (TESTING.md). +- **Category.** It is listed with the machines, as VECTOR is on the Octatrack, but it adds no SRC machine and claims no machine slot. The machine checklist items about the machine chooser, SRC SETUP, parameter locks and LFO destinations do not apply. + +## Tests and measurements + +- **Engine:** host tests, 4,975,802 checks, 0 failures under ASan and UBSan ([TESTING.md](TESTING.md)). +- **Emulator (digiemu v0.4.1, OS 1.54):** opening, rolling, the knobs and closing; the writes read back from the pattern; recorded audio plays the rolled notes at the right steps and pitches; it runs beside digihealth and DIGISLICER. Report: [evidence/emulator.md](evidence/emulator.md). +- **Resources:** 5,935 bytes of code and 676 bytes of variables; no fast SRAM; nothing in the audio render. UI-task CPU time not measured. +- **Hardware:** not tested yet. + +## Authorship and licences + +Design and code by todd-neale, with Claude Code. MIT: see [LICENSE](LICENSE). The engine and the mod are original work. No Elektron firmware, extracted code or tables, and no Vermona code or material, are included; OS routines are called by their addresses in the owner's own OS. "meloDICEr" is Vermona's product name, used only to describe the inspiration. Built with [elekloader](https://github.com/irpina/elekloader)'s core and SDK and tested in [digiemu](https://github.com/irpina/digiemu), both by irpina. + +## Screens and audio + +| | | +| --- | --- | +| ![The TRIG page](media/trig-page.png) | The first press of TRIG shows the stock TRIG page; the second opens digidice. | +| ![The digidice page before a roll](media/page-open.png) | The page opened on track 1, showing the track's existing trigs. | +| ![The digidice page after a roll](media/page-knobs.png) | After a roll and knob changes: DENSITY 110, NOTE VALUE 3, DORIAN, RANGE 24. | + +Captured from the emulator's framebuffer (digiemu v0.4.1, OS 1.54). No audio is published; the emulator recording is described in the [report](evidence/emulator.md). diff --git a/sdk/drafts/digidice/TESTING.md b/sdk/drafts/digidice/TESTING.md new file mode 100644 index 000000000..4e52c0925 --- /dev/null +++ b/sdk/drafts/digidice/TESTING.md @@ -0,0 +1,70 @@ +# digidice testing + +Version `0.1.0-experimental`. Source SHA-256 `32466b301a6cd6c17e865642a214716ce7e58fda18504f74f3539c67cdc90a8c`: + +```sh +cat src/digidice.c src/engine.c src/engine.h | shasum -a 256 +``` + +Evidence tier: **emulator**. Nothing has run on a Digitakt yet. + +## Engine host tests + +```sh +cd tests +cc -std=c99 -Wall -Wextra -Werror -fsanitize=address,undefined ../src/engine.c test_engine.c -o test_engine && ./test_engine +``` + +Result: `4975802 checks, 0 failures` (Apple clang, macOS 26.6.2, 16 October 2026). [tests/test_engine.c](tests/test_engine.c) covers: +- determinism; +- every note inside its scale and range across 20,000 random settings; +- trigs only inside the track length; +- the DENSITY extremes and exact spacing; +- VARIATION giving both halves and doubles; +- DENSITY only ever adding notes; +- rhythm-only and note-only rolls keeping the other half; +- the LEGATO and VEL extremes; +- clamping and seed advance. + +A mutation check found VARIATION's doubling untested; the VARIATION test now covers it. + +## Emulator + +Full report, with the commands, versions and measured values: [evidence/emulator.md](evidence/emulator.md). In short, on digiemu v0.4.1 with OS 1.54 and core 2.1: + +| Check | Result | +| --- | --- | +| `emu.fwcheck` container, boot and run | pass; stock passes too | +| Double press on TRIG opens the page; a slow double press does not | pass (key flags `19/5` against `19/1`) | +| Stock OS 1.54 does nothing on a double press on TRIG | pass (TRIG page only) | +| A single TRIG press on the page is ignored; a double press or NO closes it and the OS page returns | pass | +| YES, LEFT and RIGHT rolls change the pattern as shown | pass | +| Knob changes; NOTE VALUE, ROOT and SCALE move one entry a click | pass | +| A MIDI track (M1) | pass; track 1 unchanged | +| Recorded audio with a 440 Hz test sample | the rolled notes at steps 3, 13, 15 and their pitches in C major; the loop repeats after 16 steps | +| Beside digihealth and DIGISLICER | pass; screens after closing are identical to stock's | +| All four of digidice, digihealth, DIGISLICER and NEIGHBOR | refused by the linker: 1,532 bytes over core 2.1's 128 KiB | + +## Sequencing checklist ([sequencing.md](../../../docs/module-guides/sequencing.md)) + +- [x] No timer, counter or phase of its own advances with the music. digidice writes trigs; the sequencer plays them. Its only counter is the UI tick count used to debounce knob writes and seed the first roll. +- [x] Timing is in track steps: NOTE VALUE and LEGATO are steps and eighths of a step, and the LEN index maps to the TRIG page's own lengths. +- [x] Swing, track speed and tempo are the sequencer's, since the trigs are ordinary trigs. **Not yet tested** with swing set, a track speed other than 1x, a tempo change or a pattern change while the page is open. +- [x] Pattern length is read from the track (`EFFLEN`) at every write. +- [x] Nothing is modulated, and nothing runs while the page is closed except a tick counter. + +## Not tested + +- Any run on a unit (author-hardware), and CPU time in the UI task. +- Swing, track speeds, tempo changes, pattern changes while the page is open. +- Saving a rolled pattern in a project and loading it again. +- 32- and 64-step tracks. +- OS 1.53. + +## Hardware test plan + +1. Build stock 1.54 + core 2.1 + digidice and flash it, with the stock `.syx` and a 5-pin MIDI interface ready for recovery (sdk/machines/digitakt/README.md, "Flash and recover"). +2. Open, roll, play and close on an audio and a MIDI track, with a project you have saved. +3. Try swing, a 2x track speed, a 32-step track and a tempo change while the page is open; save and reload the project. +4. Record the model, OS, date, duration and results here and in `evidence.hardware`, and raise the tier to `author-hardware`. +5. Publish the `.elemod` as a release, pin it with `npm run elekloader:update`, move the folder to `sdk/digitakt/modules/digidice` and run `npm run module:doctor -- digidice` until it is green. diff --git a/sdk/drafts/digidice/build.json b/sdk/drafts/digidice/build.json new file mode 100644 index 000000000..4de230452 --- /dev/null +++ b/sdk/drafts/digidice/build.json @@ -0,0 +1,21 @@ +{ + "schemaVersion": 1, + "sources": ["src/digidice.c", "src/engine.c"], + "defsym": {}, + "cflags": [], + "weak": [], + "subscribe": [ + { "event": "ev_key", "fn": "digidice_key", "order": 50 }, + { "event": "ev_enc", "fn": "digidice_enc", "order": 50 }, + { "event": "ev_tick", "fn": "digidice_tick", "order": 50 }, + { "event": "ev_draw", "fn": "digidice_draw", "order": 90 } + ], + "contribute": [], + "collections": {}, + "copied": [], + "regions": [], + "claims": ["ui:double TRIG"], + "requires": [], + "derive": null, + "releases": { "1.54": { "defsym": {}, "cflags": [], "sites": [] } } +} diff --git a/sdk/drafts/digidice/evidence/emulator.md b/sdk/drafts/digidice/evidence/emulator.md new file mode 100644 index 000000000..c10153e81 --- /dev/null +++ b/sdk/drafts/digidice/evidence/emulator.md @@ -0,0 +1,47 @@ +# digidice 0.1.0-experimental: emulator report + +Measured on 16 October 2026 by todd-neale, with Claude Code. Emulator only: nothing here has run on a Digitakt. + +## What was run + +| | | +| --- | --- | +| Source | `src/digidice.c`, `src/engine.c`, `src/engine.h`; `cat src/digidice.c src/engine.c src/engine.h \| shasum -a 256` = `32466b301a6cd6c17e865642a214716ce7e58fda18504f74f3539c67cdc90a8c` | +| Stock OS | Digitakt mk1 OS 1.54, SHA-256 `f78ba80fa7b1da5fb0e1ff61ad61e9e71aafe79f4364fc49679f3651353e3cf6`, kept outside the repository | +| Build | elekloader `8972d70` SDK (`python -m elekloader.sdk.build`, `m68k-linux-gnu-gcc` 12.2, `-mcpu=54455 -O2`) and `elekloader.patch` with the vendored `core-2.1-os1.54.elemod` | +| Emulator | digiemu v0.4.1 (irpina/digiemu, tag `v0.4.1`, commit `f57c2f2`) from source, with its patched Unicorn 2.1.4; OS 1.54 is listed there as untested | +| Session | `emu.fwcheck` (boot, strict boot off, no timing) to a settled snapshot, then `emu.session.Session(..., ddr=128 MiB, hle=False, strict={'stop': False})`, the run stage's options, driven by key scripts | + +The firmware, the builds, the emulator's cards and snapshots and the recorded audio stay on the developer's computer. + +## Sizes + +`digidice 0.4: .run 5935, .fast 0, .bss 676 bytes; 0 sites, 196 relocations; imports nothing` (0.4 is the local build number of this source). Linked with core 2.1 alone: RAM to `0x47be1f44`, 123,068 bytes spare. + +## Results + +1. **Container and boot.** `emu.fwcheck`: container, prepare, bootloader, boot and run pass. Stock 1.54 under the same check also passes. +2. **Opening and closing.** Two presses of TRIG 140 ms apart open the page. The key events read `19/1` then `19/5`: the second carries the double-press flag 4. Two presses 960 ms apart are two single presses. Stock OS 1.54 shows the TRIG page for both and does nothing else on a double press. A single TRIG press on the page is ignored; a double press or NO closes it and the OS page is drawn again. +3. **Writes.** The page's piano roll is drawn from the track's own data (flags at +0x000, NOTE at +0x280), so after each roll and knob turn it shows what the pattern holds. Rolls, DENSITY, NOTE VALUE, SCALE and RANGE changes, rhythm-only and note-only rolls each changed the track as expected. On MIDI track 9 three rolls wrote melodies and left track 1 unchanged. +4. **Audio.** A 0.35 s, 440 Hz test tone was put on the +Drive, loaded with SETTINGS > SAMPLES > incoming > SELECT ALL > LOAD TO PROJ (slot 1) and assigned to track 1. After a roll, PLAY and 4 s of recording at 98 BPM: + + | Onset | After the first | Pitch | Expected | + | --- | --- | --- | --- | + | 22.240 s | 0 | +11.86 semitones from 440 Hz | C an octave above the root: +12 | + | 23.770 s | 1.530 s = 10 steps | +10.94 | B: +11 | + | 24.070 s | 1.830 s = 12 steps | +6.95 | G: +7 | + | 24.680 s | 2.440 s = 16 steps | +11.86 | the loop: +12 | + + The steps are those the page showed (3, 13, 15), the loop is the track's 16 steps at the project tempo, and the pitches are the rolled notes in C major (the analysis reads pitch by autocorrelation, hence the small offsets). Before PLAY the output is silent. +5. **Beside other mods.** digidice links with core 2.1 beside each of digihealth 1.0, DIGISLICER 2.1 and NEIGHBOR 0.6 for 1.54, and beside any two of them (4,164 bytes spare with DIGISLICER and NEIGHBOR). All four together need 1,532 bytes more than core 2.1's 128 KiB, and the linker refuses them. digidice + digihealth + DIGISLICER passes `emu.fwcheck` against stock 1.54; opening, rolling, a knob turn and closing work, and the screens after closing are identical to stock's under the same keys. + +## Not shown + +- Any behaviour on a unit, and the UI task's CPU time. +- Swing, track speeds other than 1x, tempo changes and pattern changes while the page is open. digidice keeps no clock: it writes ordinary trigs, which the sequencer then plays like any others. +- Saving a project with a rolled pattern and loading it again. +- 32- and 64-step tracks (the code handles up to 64; only 16 was run). + +## The emulator's limit on OS 1.54 + +Under digiemu v0.4.1, stock OS 1.54 with no mods corrupts a heap block and stops its user interface after some key sequences: CLEAR PATTERN (FUNC+STOP) did so in `emu.fwcheck`'s own run. When it happens, the trig-key list the LED code reads is freed and the next LED refresh throws `std::out_of_range` from `bitset::set`. Early probes of digidice's step writes ran into the same stop. It was reproduced on stock under the same keys before any conclusion was drawn. The runs above did not hit it. diff --git a/sdk/drafts/digidice/media/page-knobs.png b/sdk/drafts/digidice/media/page-knobs.png new file mode 100644 index 000000000..d6d4db34b Binary files /dev/null and b/sdk/drafts/digidice/media/page-knobs.png differ diff --git a/sdk/drafts/digidice/media/page-open.png b/sdk/drafts/digidice/media/page-open.png new file mode 100644 index 000000000..623cfd9f1 Binary files /dev/null and b/sdk/drafts/digidice/media/page-open.png differ diff --git a/sdk/drafts/digidice/media/thumbnail.svg b/sdk/drafts/digidice/media/thumbnail.svg new file mode 100644 index 000000000..51302e025 --- /dev/null +++ b/sdk/drafts/digidice/media/thumbnail.svg @@ -0,0 +1 @@ +DIGIDICE diff --git a/sdk/drafts/digidice/media/trig-page.png b/sdk/drafts/digidice/media/trig-page.png new file mode 100644 index 000000000..a7b60e22b Binary files /dev/null and b/sdk/drafts/digidice/media/trig-page.png differ diff --git a/sdk/drafts/digidice/modwerk.module.json b/sdk/drafts/digidice/modwerk.module.json new file mode 100644 index 000000000..ccce2e0a0 --- /dev/null +++ b/sdk/drafts/digidice/modwerk.module.json @@ -0,0 +1,99 @@ +{ + "schemaVersion": 3, + "id": "digidice", + "name": "digidice", + "version": "0.1.0-experimental", + "machine": "digitakt", + "category": "machines", + "exclusive": false, + "author": { "github": "todd-neale", "credits": ["todd-neale — digidice design and code"] }, + "maintainers": ["todd-neale"], + "presentation": { + "label": "DIGIDICE", + "family": "Sequencing", + "summary": "A meloDICEr-style random melody generator that rolls melodies into the active track as ordinary trigs.", + "overview": "A double press on TRIG opens a page that rolls a random melody into the active audio or MIDI track, inspired by Vermona's meloDICEr. Four knobs shape the rhythm (density, note value, variation, legato) and four the notes (root, scale, range, velocity spread). YES rolls everything, LEFT only the rhythm and RIGHT only the notes; turning a knob reshapes the current melody instead of replacing it. The result is written into the pattern as plain trigs with NOTE, VEL and LEN, so it plays with the Digitakt's own tempo, swing, track length and speed, and can be edited, saved and copied like any pattern.", + "highlights": [ + "Rolls a melody into the active audio or MIDI track as ordinary, editable trigs.", + "Separate rhythm and note rolls: keep a groove and re-roll its notes, or the other way round.", + "Raising DENSITY only adds notes; the notes already there keep their place and pitch.", + "Every note stays in the chosen scale: chromatic, major, minor, dorian, mixolydian, harmonic minor and two pentatonics." + ], + "usage": [ + "Roll a bassline on an audio track, then re-roll only the notes until it fits the drums.", + "Generate a melody on a MIDI track to drive an external synth, then edit single trigs by hand." + ] + }, + "access": { + "location": "A double press on TRIG, from any page, opens the digidice page for the active track.", + "steps": [ + "Select the track with TRK and a trig key (audio tracks 1–8 or MIDI tracks 9–16).", + "Press TRIG twice quickly. The first press shows the TRIG page as usual; the second opens digidice.", + "Press YES to roll a melody into the track, and PLAY to hear it.", + "Turn the knobs to reshape it; LEFT rolls the rhythm, RIGHT the notes.", + "Press NO, or TRIG twice again, to close the page." + ], + "screenshots": ["media/trig-page.png", "media/page-open.png", "media/page-knobs.png"] + }, + "controls": [ + { "name": "DENSITY", "default": 80, "doc": "Knob A, 0–127: the chance each rhythm slot plays; 0 is silence, 127 every slot", "labels": null }, + { "name": "NOTE VALUE", "default": 2, "doc": "Knob B: the gap between notes in steps (1, 2, 3, 4, 6 or 8)", "labels": ["1", "2", "3", "4", "6", "8"] }, + { "name": "VARIATION", "default": 0, "doc": "Knob C, 0–127: the chance a gap halves or doubles", "labels": null }, + { "name": "LEGATO", "default": 64, "doc": "Knob D, 0–127: note length as a share of the gap; 127 ties to the next note", "labels": null }, + { "name": "ROOT", "default": 0, "doc": "Knob E: the root note of the key, C to B", "labels": ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"] }, + { "name": "SCALE", "default": 1, "doc": "Knob F: the notes allowed", "labels": ["CHROM", "MAJOR", "MINOR", "DORIAN", "MIXO", "HARMIN", "MAJPEN", "MINPEN"] }, + { "name": "RANGE", "default": 7, "doc": "Knob G, 0–24: how many scale notes above the root", "labels": null }, + { "name": "VEL", "default": 0, "doc": "Knob H, 0–127: how far velocities spread around the VEL of the track", "labels": null } + ], + "compatibility": { + "releases": ["1.54"], + "requires": [], + "conflicts": [], + "limitations": [ + "OS 1.54 only.", + "Rolling replaces every trig on the active track up to its length (at most 64 steps), and their parameter locks. There is no undo: save the pattern first if you want to keep it.", + "Settings and seeds are not saved: they return to their defaults when the unit restarts.", + "Notes start from C5 (no transpose on an audio track) plus the root; there is no octave control yet.", + "While the page is open, every key but PLAY, STOP and FUNC belongs to it, so tracks cannot be changed from the page.", + "With core 2.1's 128 KiB of mod memory, digidice fits beside any two of digihealth, DIGISLICER and NEIGHBOR, but not all three." + ] + }, + "platform": { "kind": "elemod", "core": "2.1", "build": "build.json", "events": ["ev_draw", "ev_enc", "ev_key", "ev_tick"], "claims": ["ui:double TRIG"] }, + "resources": { + "memoryBytes": 6611, + "fastBytes": 0, + "load": { + "display": "No audio-render work: UI task only", + "method": "static", + "conditions": "digidice subscribes to no render event and runs only in the UI task: drawing while its page is open, and the step setters for at most 64 steps after a roll or a knob turn. CPU time in the UI task has not been measured." + } + }, + "evidence": { + "tier": "emulator", + "moduleVersion": "0.1.0-experimental", + "sourceSha256": "32466b301a6cd6c17e865642a214716ce7e58fda18504f74f3539c67cdc90a8c", + "reports": ["evidence/emulator.md"], + "hardware": null, + "verifiedBy": null + }, + "tests": { "report": "TESTING.md", "summary": "Engine host tests pass (4,975,802 checks). In the digiemu emulator on OS 1.54: the page opens, rolls and closes; writes land in the pattern; the recorded audio plays the rolled notes at the right steps and pitches; it runs beside digihealth and DIGISLICER. Not yet run on a unit." }, + "license": { "spdx": "MIT", "file": "LICENSE", "declaration": "Original work by todd-neale and digidice contributors, MIT. No Elektron firmware and no Vermona material is included; meloDICEr is Vermona's product name, used only to describe the inspiration." }, + "media": [ + { "path": "media/thumbnail.svg", "kind": "thumbnail", "caption": "digidice thumbnail", "alt": "A die beside a row of note bars at different heights", "credit": "todd-neale", "license": "MIT", "source": "Original artwork for digidice." }, + { + "path": "media/trig-page.png", "kind": "screenshot", "caption": "The TRIG page after the first press of TRIG; a second press opens digidice", "alt": "The Digitakt's TRIG page with NOTE, VEL, LEN and PROB", "credit": "todd-neale", "license": "MIT", + "source": "Framebuffer capture from the digiemu emulator (irpina/digiemu v0.4.1) running OS 1.54 with core 2.1 and digidice.", + "capture": { "release": "1.54", "moduleVersion": "0.1.0-experimental", "imageSha256": "240564a12114eec65788403f8722fea3f5a48e4807eaf415aacaf45b07d04789", "setup": "digiemu v0.4.1, OS 1.54 + core 2.1 + digidice, factory project, a 440 Hz test sample on track 1; captured 80 ms after the first press of TRIG, 4x integer scale." } + }, + { + "path": "media/page-open.png", "kind": "screenshot", "caption": "The digidice page, opened on track 1: the piano roll shows the track's existing trigs", "alt": "The digidice page: a title bar, a piano roll of the track and the eight controls with their values", "credit": "todd-neale", "license": "MIT", + "source": "Framebuffer capture from the digiemu emulator (irpina/digiemu v0.4.1) running OS 1.54 with core 2.1 and digidice.", + "capture": { "release": "1.54", "moduleVersion": "0.1.0-experimental", "imageSha256": "400b560e7a231e4790bc2a1e624f37f990ab9a67bfedbd0f7b43b5418e986aac", "setup": "digiemu v0.4.1, OS 1.54 + core 2.1 + digidice, factory project; captured right after a double press on TRIG, before any roll, 4x integer scale." } + }, + { + "path": "media/page-knobs.png", "kind": "screenshot", "caption": "After a roll and knob changes: DENSITY 110, NOTE VALUE 3, DORIAN, RANGE 24", "alt": "The digidice page with a rolled melody of three notes and changed control values", "credit": "todd-neale", "license": "MIT", + "source": "Framebuffer capture from the digiemu emulator (irpina/digiemu v0.4.1) running OS 1.54 with core 2.1 and digidice.", + "capture": { "release": "1.54", "moduleVersion": "0.1.0-experimental", "imageSha256": "629c21fb724a48a7c09889dcc987ac65a44b340a6066882c90dcd6e77a35b88d", "setup": "digiemu v0.4.1, OS 1.54 + core 2.1 + digidice, factory project; YES, then knobs A, B, F and G turned, captured 0.9 s after the last turn, 4x integer scale." } + } + ] +} diff --git a/sdk/drafts/digidice/src/digidice.c b/sdk/drafts/digidice/src/digidice.c new file mode 100644 index 000000000..3145a37eb --- /dev/null +++ b/sdk/drafts/digidice/src/digidice.c @@ -0,0 +1,331 @@ +/* SPDX-License-Identifier: MIT */ +/* digidice: a meloDICEr-style random melody generator for the Digitakt mk1 + * (OS 1.54). A double press on TRIG opens its page on the active track, + * audio or MIDI (the first press shows the TRIG page as usual): + * + * A DENSITY B NOTE VALUE C VARIATION D LEGATO + * E ROOT F SCALE G RANGE H VEL + * YES rolls everything, LEFT the rhythm, RIGHT the notes; NO or a + * double press on TRIG closes. + * + * The melody is written into the pattern as ordinary trigs with NOTE, VEL + * and LEN, through the OS's own step setters, from the UI task (ev_tick). + * Opening the page changes nothing; a roll or a knob turn does. Knob turns + * are written about 150 ms after the knob stops, and only the steps that + * changed. Timing is the instrument's own: the track's length and speed, + * the tempo and swing. */ +#include "engine.h" + +typedef signed char s8; +typedef unsigned char u8; +typedef unsigned short u16; +typedef int s32; +typedef unsigned int u32; + +typedef u32 (*fn0_t)(void); +typedef u32 (*fn1_t)(u32); +typedef u32 (*fn2_t)(u32, u32); +typedef s32 (*set_t)(u32 trk, s32 step, s32 v); +typedef void (*fill_t)(void *bmp, s32 x0, s32 y0, s32 x1, s32 y1, s32 colour); +typedef void (*text_t)(void *bmp, const void *font, s32 x, s32 y, s32 maxlen, const char *fmt, ...); + +/* OS 1.54 (sdk/digitakt/research/pattern-write.md) */ +#define APP ((fn0_t)0x40138882) +#define TSTATE ((fn1_t)0x40014d86) /* (app) -> the TrackState */ +#define ACTIVE ((fn1_t)0x4001d27e) /* (TrackState) -> the active track, 0-15 */ +#define KB_ON ((fn1_t)0x4001d4f0) /* (TrackState) -> the keyboard is on (a byte) */ +#define PATTERN ((fn1_t)0x40015786) /* (app) -> the pattern */ +#define PTRACK ((fn2_t)0x40012a02) /* (pattern, t) -> its track t */ +#define TRIGSET ((set_t)0x400261c2) /* a note trig on (resets NOTE/VEL/LEN) or off */ +#define NOTESET ((set_t)0x40024942) +#define VELSET ((set_t)0x40024b2a) /* not clamped */ +#define LENSET ((set_t)0x40024ba4) /* an index into the LEN table, not clamped */ +#define EFFLEN ((fn1_t)0x40024700) /* (trk) -> the effective length */ +#define FILLRECT ((fill_t)0x400c1bce) /* colour 0 clears, 1 sets, -1 inverts; y = 0 at the bottom */ +#define TEXTF ((text_t)0x400c27a4) +#define FONT5 ((const void *)0x40200ebc) + +/* Track data (vtable slot 10): u16 flags[64] (bit 0 a trig), NOTE at + * +0x280, the track's VEL at +0x380 and NOTE at +0x384. */ +#define D_NOTE 0x280 +#define D_TVEL 0x380 +#define D_TNOTE 0x384 + +#define K_DOUBLE 4 /* the event's flags: a double press */ +#define K_PLAY 10 +#define K_STOP 11 +#define K_YES 12 +#define K_NO 13 +#define K_TRIG 19 +#define K_LEFT 16 +#define K_RIGHT 17 +#define K_FUNC 1 + +#define NOTE_BASE 60 /* the root's octave: C5 (0) on an audio track */ +#define DEBOUNCE 5 /* ticks (30 a second) after the last knob turn */ + +static dd_settings set = { 80, 1, 0, 64, 0, 1, 7, 0 }; +static u32 rseed, nseed; +static s32 open, dirty, quiet, repaint; +static u32 swallow, ticks; +static dd_step out[DD_STEPS]; + +/* What was last written to each step, to write only what changed. */ +static struct { s8 on, note, vel, len; } done[DD_STEPS]; +static s32 done_valid; + +static u8 *tdata(u32 trk) +{ + u32 (*get)(u32) = *(u32 (**)(u32))(*(u32 *)trk + 40); + return (u8 *)get(trk); +} + +static s32 active_track(void) +{ + s32 t = (s32)ACTIVE(TSTATE(APP())); + return t < 0 || t > 15 ? -1 : t; +} + +static u32 track_handle(s32 t) +{ + return t < 0 ? 0 : PTRACK(PATTERN(APP()), (u32)t); +} + +static s32 track_length(u32 trk) +{ + s32 n = (s32)EFFLEN(trk); + return n < 1 ? 1 : n > DD_STEPS ? DD_STEPS : n; +} + +/* A length in eighths of a step -> the nearest TRIG page LEN index: eighths + * up to 4 steps, quarters to 8, halves to 16 (checked against OS 1.54). */ +static s32 len_index(s32 e) +{ + if (e < 1) + e = 1; + if (e <= 32) + return e - 1; + if (e <= 64) + return 31 + (e - 32 + 1) / 2; + if (e > 128) + e = 128; + return 47 + (e - 64 + 2) / 4; +} + +static void write_track(void) +{ + s32 t = active_track(), s, len; + u32 trk = track_handle(t); + u8 *d; + s32 vbase; + if (!trk) + return; + len = track_length(trk); + d = tdata(trk); + vbase = (s8)d[D_TVEL]; + if (vbase < 1 || vbase > 127) + vbase = 100; + dd_generate(&set, rseed, nseed, len, (u8)vbase, out); + for (s = 0; s < len; s++) { + s8 on = (s8)out[s].on, note = 0, vel = 0, l = 0; + if (on) { + s32 n = NOTE_BASE + out[s].note; + note = (s8)(n > 127 ? 127 : n); + vel = (s8)out[s].vel; + l = (s8)len_index(out[s].len); + } + if (done_valid && done[s].on == on && done[s].note == note + && done[s].vel == vel && done[s].len == l) + continue; + if (on) { + TRIGSET(trk, s, 1); + NOTESET(trk, s, note); + VELSET(trk, s, vel); + LENSET(trk, s, l); + } else { + TRIGSET(trk, s, 0); + } + done[s].on = on; + done[s].note = note; + done[s].vel = vel; + done[s].len = l; + } + done_valid = 1; +} + +static void roll(s32 rhythm, s32 notes) +{ + if (!rseed) /* the first roll: seeds from the clock */ + rseed = ticks * 2654435761u + 1, nseed = ticks * 40503u + 7; + if (rhythm) + rseed = dd_next_seed(rseed); + if (notes) + nseed = dd_next_seed(nseed); + dirty = 1; + quiet = DEBOUNCE; /* a roll is written on the next tick */ +} + +static void page_open(void) +{ + open = 1; + done_valid = 0; /* the track may have changed since */ + dirty = 0; +} + +int digidice_key(void *brain, void *ev) +{ + s32 key = *(s32 *)((u8 *)ev + 12), fl = *(s32 *)((u8 *)ev + 16); + s32 press = (fl & 1) && !(fl & 8); + u32 bit = key >= 0 && key < 32 ? 1u << key : 0; + (void)brain; + if (!(fl & 1)) { /* a release: swallow the ones we took */ + if (swallow & bit) { + swallow &= ~bit; + return 1; + } + return 0; + } + if (!open) { + if (key == K_TRIG && press && (fl & K_DOUBLE) && active_track() >= 0) { + page_open(); + swallow |= bit; + return 1; + } + return 0; + } + /* The page is open: the transport and FUNC work as usual, the rest is ours. */ + if (key == K_PLAY || key == K_STOP || key == K_FUNC) + return 0; + swallow |= bit; + if (!press) + return 1; + switch (key) { + case K_YES: roll(1, 1); break; + case K_LEFT: roll(1, 0); break; + case K_RIGHT: roll(0, 1); break; + case K_NO: + open = 0; + repaint = 1; + break; + case K_TRIG: /* a single press is ignored */ + if (fl & K_DOUBLE) + open = 0, repaint = 1; + break; + } + return 1; +} + +static s32 clampi(s32 v, s32 lo, s32 hi) +{ + return v < lo ? lo : v > hi ? hi : v; +} + +int digidice_enc(void *brain, void *ev) +{ + s32 enc = *(s32 *)((u8 *)ev + 12), dv = *(s32 *)((u8 *)ev + 16); + s32 one = dv > 0 ? 1 : dv < 0 ? -1 : 0; /* lists move one entry a click */ + (void)brain; + if (!open) + return 0; + switch (enc) { + case 1: set.density = (u8)clampi(set.density + dv, 0, 127); break; + case 2: set.spacing = (u8)clampi(set.spacing + one, 0, DD_SPACINGS - 1); break; + case 3: set.variation = (u8)clampi(set.variation + dv, 0, 127); break; + case 4: set.legato = (u8)clampi(set.legato + dv, 0, 127); break; + case 5: set.root = (u8)((set.root + 12 + one) % 12); break; + case 6: set.scale = (u8)clampi(set.scale + one, 0, DD_SCALES - 1); break; + case 7: set.range = (u8)clampi(set.range + dv, 0, DD_RANGE_MAX); break; + case 8: set.vel_spread = (u8)clampi(set.vel_spread + dv, 0, 127); break; + default: return 1; + } + if (!rseed) + roll(0, 0); /* turning a knob first also needs seeds */ + dirty = 1; + quiet = 0; + return 1; +} + +void digidice_tick(void *ctrl) +{ + ticks++; + if (repaint) { /* closed: let the OS draw its own page again */ + repaint = 0; + *((u8 *)ctrl + 0x20) = 1; + } + if (!open) + return; + if (dirty && ++quiet >= DEBOUNCE) { + dirty = 0; + write_track(); + } + *((u8 *)ctrl + 0x20) = 1; /* keep the page drawn */ +} + +static const char *const root_names[12] = { + "C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B" +}; + +/* The track as it is now: a bar per trig, its height the note, scaled to + * the lowest and highest notes shown. */ +static void draw_roll(void *bmp, u32 trk) +{ + u8 *d = tdata(trk); + s32 len = track_length(trk), s, tnote = (s8)d[D_TNOTE], lo = 127, hi = 0; + for (s = 0; s < len; s++) + if (((u16 *)d)[s] & 1) { + s32 n = (s8)d[D_NOTE + s]; + if (n < 0) + n = tnote; + lo = n < lo ? n : lo; + hi = n > hi ? n : hi; + } + for (s = 0; s < len; s++) { + s32 x0 = s * 128 / len, x1 = (s + 1) * 128 / len - 2, n, h; + if (!(((u16 *)d)[s] & 1)) + continue; + n = (s8)d[D_NOTE + s]; + if (n < 0) + n = tnote; + h = hi > lo ? (n - lo) * 16 / (hi - lo) : 8; + if (x1 < x0) + x1 = x0; + FILLRECT(bmp, x0, 37 + h, x1, 38 + h, 1); + } + FILLRECT(bmp, 0, 35, 127, 35, 1); +} + +static void cell(void *bmp, s32 col, s32 y, const char *label, const char *fmt, s32 v, const char *sv) +{ + s32 x = col * 32 + 2; + if (sv) + TEXTF(bmp, FONT5, x, y + 7, 8, "%s", sv); + else + TEXTF(bmp, FONT5, x, y + 7, 8, fmt, v); + TEXTF(bmp, FONT5, x, y, 8, "%s", label); +} + +void digidice_draw(void *bmp, void *ctrl) +{ + s32 t; + u32 trk; + (void)ctrl; + if (!open) + return; + t = active_track(); + trk = track_handle(t); + FILLRECT(bmp, 0, 0, 127, 63, 0); + TEXTF(bmp, FONT5, 2, 57, 30, "DIGIDICE %s%d LEN %d", t < 8 ? "T" : "M", + t < 8 ? t + 1 : t - 7, trk ? track_length(trk) : 0); + FILLRECT(bmp, 0, 56, 127, 63, -1); + if (trk) + draw_roll(bmp, trk); + cell(bmp, 0, 22, "DENS", "%d", set.density, 0); + cell(bmp, 1, 22, "NOTE", "%d", dd_spacing_steps(set.spacing), 0); + cell(bmp, 2, 22, "VAR", "%d", set.variation, 0); + cell(bmp, 3, 22, "LEG", "%d", set.legato, 0); + cell(bmp, 0, 8, "ROOT", 0, 0, root_names[set.root % 12]); + cell(bmp, 1, 8, "SCALE", 0, 0, dd_scale_name(set.scale)); + cell(bmp, 2, 8, "RANGE", "%d", set.range, 0); + cell(bmp, 3, 8, "VEL", "%d", set.vel_spread, 0); + TEXTF(bmp, FONT5, 2, 0, 32, "YES ROLL < RHYTHM > NOTES"); +} diff --git a/sdk/drafts/digidice/src/engine.c b/sdk/drafts/digidice/src/engine.c new file mode 100644 index 000000000..1f089345e --- /dev/null +++ b/sdk/drafts/digidice/src/engine.c @@ -0,0 +1,142 @@ +/* SPDX-License-Identifier: MIT */ +/* digidice engine: see engine.h. No libc, no floating point, no 64-bit + * arithmetic: it runs unchanged on the Digitakt's ColdFire and on a host. */ +#include "engine.h" + +/* Gap between rhythm slots, in steps: 1/16, 1/8, dotted 1/8, 1/4, dotted 1/4, 1/2. */ +static const uint8_t spacings[DD_SPACINGS] = { 1, 2, 3, 4, 6, 8 }; + +typedef struct { + const char *name; + uint8_t count; + uint8_t steps[12]; /* semitones above the root */ +} scale_t; + +static const scale_t scales[DD_SCALES] = { + { "CHROM", 12, { 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11 } }, + { "MAJOR", 7, { 0, 2, 4, 5, 7, 9, 11 } }, + { "MINOR", 7, { 0, 2, 3, 5, 7, 8, 10 } }, + { "DORIAN", 7, { 0, 2, 3, 5, 7, 9, 10 } }, + { "MIXO", 7, { 0, 2, 4, 5, 7, 9, 10 } }, + { "HARMIN", 7, { 0, 2, 3, 5, 7, 8, 11 } }, + { "MAJPEN", 5, { 0, 2, 4, 7, 9 } }, + { "MINPEN", 5, { 0, 3, 5, 7, 10 } }, +}; + +/* Salts keep each decision's random value independent of the others. */ +enum { SALT_PLAY = 1, SALT_VARY, SALT_DIR, SALT_VEL, SALT_NOTE }; + +/* A 32-bit integer mix (Wellons' lowbias32). */ +static uint32_t mix(uint32_t x) +{ + x ^= x >> 16; + x *= 0x7feb352dU; + x ^= x >> 15; + x *= 0x846ca68bU; + x ^= x >> 16; + return x; +} + +/* A random value for decision `salt` at index `i`, fixed by the seed. Keyed + * by position, not drawn from a stream, so changing one control does not + * reshuffle the decisions of the others. */ +static uint32_t rnd(uint32_t seed, uint32_t i, uint32_t salt) +{ + return mix(seed ^ mix(i * 0x9e3779b9U + salt)); +} + +/* True with probability amount/127: 0 never, 127 always. */ +static int chance(uint32_t r, uint8_t amount) +{ + return r % 127U < amount; +} + +static uint8_t clamp8(int v, int lo, int hi) +{ + return (uint8_t)(v < lo ? lo : v > hi ? hi : v); +} + +void dd_clamp(dd_settings *s) +{ + s->density = clamp8(s->density, 0, 127); + s->spacing = clamp8(s->spacing, 0, DD_SPACINGS - 1); + s->variation = clamp8(s->variation, 0, 127); + s->legato = clamp8(s->legato, 0, 127); + s->root = clamp8(s->root, 0, 11); + s->scale = clamp8(s->scale, 0, DD_SCALES - 1); + s->range = clamp8(s->range, 0, DD_RANGE_MAX); + s->vel_spread = clamp8(s->vel_spread, 0, 127); +} + +const char *dd_scale_name(int scale) +{ + return scale >= 0 && scale < DD_SCALES ? scales[scale].name : ""; +} + +int dd_spacing_steps(int spacing) +{ + return spacing >= 0 && spacing < DD_SPACINGS ? spacings[spacing] : 0; +} + +uint32_t dd_next_seed(uint32_t seed) +{ + uint32_t next = mix(seed + 0x9e3779b9U); + return next != seed ? next : next + 1; +} + +/* The gap after rhythm slot i: the basic spacing, halved or doubled by VARIATION. */ +static int slot_gap(const dd_settings *s, uint32_t seed, uint32_t i) +{ + int gap = spacings[s->spacing]; + if (!chance(rnd(seed, i, SALT_VARY), s->variation)) + return gap; + if (rnd(seed, i, SALT_DIR) & 1) + return gap * 2 > 16 ? gap : gap * 2; + return gap > 1 ? gap / 2 : gap * 2; +} + +/* The note at step t: a scale degree from 0 to RANGE above the root. */ +static uint8_t step_note(const dd_settings *s, uint32_t seed, uint32_t t) +{ + const scale_t *sc = &scales[s->scale]; + uint32_t degree = rnd(seed, t, SALT_NOTE) % (uint32_t)(s->range + 1); + return (uint8_t)(s->root + 12 * (degree / sc->count) + sc->steps[degree % sc->count]); +} + +static uint8_t slot_vel(const dd_settings *s, uint32_t seed, uint32_t i, uint8_t base) +{ + int offset = (int)(rnd(seed, i, SALT_VEL) % 255U) - 127; /* -127..127 */ + return clamp8(base + offset * s->vel_spread / 127, 1, 127); +} + +int dd_generate(const dd_settings *settings, uint32_t rhythm_seed, uint32_t note_seed, + int length, uint8_t vel_base, dd_step out[DD_STEPS]) +{ + dd_settings s = *settings; + int t, next, notes = 0; + uint32_t i; + + dd_clamp(&s); + if (length < 1) + length = 1; + if (length > DD_STEPS) + length = DD_STEPS; + for (t = 0; t < DD_STEPS; t++) + out[t] = (dd_step){ 0, step_note(&s, note_seed, (uint32_t)t), 0, 0 }; + + for (t = 0, i = 0; t < length; t = next, i++) { + int gap = slot_gap(&s, rhythm_seed, i); + next = t + gap; + if (!chance(rnd(rhythm_seed, i, SALT_PLAY), s.density)) + continue; + if (next > length) + gap = length - t; + out[t].on = 1; + out[t].vel = slot_vel(&s, rhythm_seed, i, vel_base); + out[t].len = (uint16_t)(gap * DD_LEN_UNIT * s.legato / 127); + if (out[t].len < 1) + out[t].len = 1; + notes++; + } + return notes; +} diff --git a/sdk/drafts/digidice/src/engine.h b/sdk/drafts/digidice/src/engine.h new file mode 100644 index 000000000..bdeecc1c2 --- /dev/null +++ b/sdk/drafts/digidice/src/engine.h @@ -0,0 +1,53 @@ +/* SPDX-License-Identifier: MIT */ +/* digidice: a random melody generator for the Digitakt mk1, inspired by + * Vermona's meloDICEr. The engine is plain C with no OS or libc calls: it + * turns eight settings and two seeds into a trig, a note, a velocity and a + * length for each step. The mod writes the result through the OS's own + * setters (sdk/digitakt/research/pattern-write.md). */ +#ifndef DIGIDICE_ENGINE_H +#define DIGIDICE_ENGINE_H + +#include + +#define DD_STEPS 64 +#define DD_SPACINGS 6 /* NOTE VALUE: 1, 2, 3, 4, 6 or 8 steps */ +#define DD_SCALES 8 +#define DD_RANGE_MAX 24 /* scale degrees above the root */ +#define DD_LEN_UNIT 8 /* lengths are in eighths of a step */ + +/* The eight controls, each already inside its range. */ +typedef struct { + uint8_t density; /* 0..127: chance a rhythm slot plays; 0 = silence, 127 = every slot */ + uint8_t spacing; /* 0..DD_SPACINGS-1: the basic gap between notes */ + uint8_t variation; /* 0..127: chance a slot's gap halves or doubles */ + uint8_t legato; /* 0..127: length as a share of the gap; 127 ties to the next slot */ + uint8_t root; /* 0..11: C..B */ + uint8_t scale; /* 0..DD_SCALES-1, see dd_scale_name */ + uint8_t range; /* 0..DD_RANGE_MAX: scale degrees above the root */ + uint8_t vel_spread; /* 0..127: how far velocities spread around vel_base */ +} dd_settings; + +typedef struct { + uint8_t on; /* a note trig */ + uint8_t note; /* semitones above the C below the root: root + interval */ + uint8_t vel; /* 1..127 */ + uint16_t len; /* in DD_LEN_UNIT ths of a step, at least 1 */ +} dd_step; + +/* Fill out[0..DD_STEPS-1]. Steps at or past `length` (1..64) are off. + * The rhythm (which steps play, their lengths and velocities) depends only + * on rhythm_seed; the note at each step only on note_seed, so either seed + * can be rolled while the other half stays. Returns the number of notes. */ +int dd_generate(const dd_settings *s, uint32_t rhythm_seed, uint32_t note_seed, + int length, uint8_t vel_base, dd_step out[DD_STEPS]); + +/* The next seed for a roll. Never returns the seed it was given. */ +uint32_t dd_next_seed(uint32_t seed); + +/* Clamp every field into its range. */ +void dd_clamp(dd_settings *s); + +const char *dd_scale_name(int scale); +int dd_spacing_steps(int spacing); + +#endif diff --git a/sdk/drafts/digidice/tests/test_engine.c b/sdk/drafts/digidice/tests/test_engine.c new file mode 100644 index 000000000..c95ff5760 --- /dev/null +++ b/sdk/drafts/digidice/tests/test_engine.c @@ -0,0 +1,235 @@ +/* SPDX-License-Identifier: MIT */ +/* Host tests for the digidice engine. Build and run: + * cc -std=c99 -Wall -Wextra -Werror -fsanitize=address,undefined \ + * engine.c test_engine.c -o test_engine && ./test_engine */ +#include +#include +#include +#include "../src/engine.h" + +static int failures, checks; + +#define CHECK(cond, ...) do { checks++; if (!(cond)) { failures++; \ + printf("FAIL %s:%d: ", __FILE__, __LINE__); printf(__VA_ARGS__); printf("\n"); } } while (0) + +static const dd_settings defaults = { 80, 0, 0, 64, 0, 1, 7, 0 }; + +static int in_scale(int scale, int semis) +{ + /* Rebuild membership from the generator's own output range: intervals mod 12. */ + static const int masks[DD_SCALES] = { + 0xFFF, /* chromatic */ + (1<<0)|(1<<2)|(1<<4)|(1<<5)|(1<<7)|(1<<9)|(1<<11), + (1<<0)|(1<<2)|(1<<3)|(1<<5)|(1<<7)|(1<<8)|(1<<10), + (1<<0)|(1<<2)|(1<<3)|(1<<5)|(1<<7)|(1<<9)|(1<<10), + (1<<0)|(1<<2)|(1<<4)|(1<<5)|(1<<7)|(1<<9)|(1<<10), + (1<<0)|(1<<2)|(1<<3)|(1<<5)|(1<<7)|(1<<8)|(1<<11), + (1<<0)|(1<<2)|(1<<4)|(1<<7)|(1<<9), + (1<<0)|(1<<3)|(1<<5)|(1<<7)|(1<<10), + }; + return (masks[scale] >> (semis % 12)) & 1; +} + +static int scale_size(int scale) +{ + return scale == 0 ? 12 : scale >= 6 ? 5 : 7; +} + +static void test_determinism(void) +{ + dd_step a[DD_STEPS], b[DD_STEPS]; + dd_generate(&defaults, 1, 2, 64, 100, a); + dd_generate(&defaults, 1, 2, 64, 100, b); + CHECK(memcmp(a, b, sizeof a) == 0, "same seeds and settings differ"); + dd_generate(&defaults, 3, 2, 64, 100, b); + CHECK(memcmp(a, b, sizeof a) != 0, "a new rhythm seed changed nothing"); +} + +static void test_ranges(void) +{ + dd_settings s; + dd_step out[DD_STEPS]; + uint32_t seed = 7; + int n, t, len, cases = 0; + + for (n = 0; n < 20000; n++) { + seed = dd_next_seed(seed); + s.density = seed & 127; + s.spacing = (seed >> 7) % DD_SPACINGS; + s.variation = (seed >> 10) & 127; + s.legato = (seed >> 17) & 127; + s.root = (seed >> 24) % 12; + s.scale = (seed >> 4) % DD_SCALES; + s.range = (seed >> 12) % (DD_RANGE_MAX + 1); + s.vel_spread = (seed >> 20) & 127; + len = 1 + (int)((seed >> 26) % 64); + dd_generate(&s, seed, ~seed, len, (uint8_t)(1 + (seed % 127)), out); + for (t = 0; t < DD_STEPS; t++) { + int interval = out[t].note - s.root; + int octaves = interval / 12, degree; + CHECK(interval >= 0, "note below the root"); + CHECK(in_scale(s.scale, interval), "note %d outside scale %d", out[t].note, s.scale); + degree = octaves * scale_size(s.scale); + CHECK(degree <= s.range, "note beyond RANGE"); + if (!out[t].on) + continue; + CHECK(t < len, "trig at step %d past length %d", t, len); + CHECK(out[t].vel >= 1 && out[t].vel <= 127, "velocity %d", out[t].vel); + CHECK(out[t].len >= 1 && out[t].len <= 16 * DD_LEN_UNIT, "length %d", out[t].len); + CHECK(t * DD_LEN_UNIT + out[t].len <= len * DD_LEN_UNIT || out[t].len == 1, + "note at %d overruns the pattern", t); + } + cases++; + } + CHECK(cases == 20000, "ran %d cases", cases); +} + +static void test_density_edges(void) +{ + dd_settings s = defaults; + dd_step out[DD_STEPS]; + int t; + + s.density = 0; + CHECK(dd_generate(&s, 5, 6, 64, 100, out) == 0, "DENSITY 0 is not silent"); + s.density = 127; + s.variation = 0; + for (s.spacing = 0; s.spacing < DD_SPACINGS; s.spacing++) { + int gap = dd_spacing_steps(s.spacing); + dd_generate(&s, 5, 6, 64, 100, out); + for (t = 0; t < 64; t++) + CHECK(out[t].on == (t % gap == 0), "spacing %d, step %d", gap, t); + } +} + +/* VARIATION 127 replaces every gap by its half or its double, and both occur. */ +static void test_variation(void) +{ + dd_settings s = defaults; + dd_step out[DD_STEPS]; + uint32_t seed; + int t, last, halves = 0, doubles = 0; + + s.density = 127; + s.spacing = 1; /* every 2 steps */ + s.variation = 127; + for (seed = 1; seed < 100; seed++) { + dd_generate(&s, seed, 1, 64, 100, out); + for (t = 1, last = 0; t < 64; t++) { + if (!out[t].on) + continue; + CHECK(t - last == 1 || t - last == 4, "gap %d with VARIATION 127", t - last); + halves += t - last == 1; + doubles += t - last == 4; + last = t; + } + } + CHECK(halves > 0 && doubles > 0, "halves %d, doubles %d", halves, doubles); +} + +/* Raising DENSITY only adds notes: earlier notes keep their place, note, + * velocity and length. */ +static void test_density_monotonic(void) +{ + dd_settings s = defaults; + dd_step lo[DD_STEPS], hi[DD_STEPS]; + uint32_t seed; + int d, t; + + for (seed = 1; seed < 200; seed++) { + s.variation = (uint8_t)(seed % 128); + for (d = 0; d < 127; d++) { + s.density = (uint8_t)d; + dd_generate(&s, seed, seed * 3, 64, 90, lo); + s.density = (uint8_t)(d + 1); + dd_generate(&s, seed, seed * 3, 64, 90, hi); + for (t = 0; t < 64; t++) + if (lo[t].on) + CHECK(hi[t].on && hi[t].note == lo[t].note && hi[t].vel == lo[t].vel + && hi[t].len == lo[t].len, "seed %u density %d step %d", seed, d, t); + } + } +} + +/* Rolling the notes keeps the rhythm; rolling the rhythm keeps each step's note. */ +static void test_partial_rolls(void) +{ + dd_step a[DD_STEPS], b[DD_STEPS]; + uint32_t r = 11, n = 12; + int t, k, moved = 0, renoted = 0; + + for (k = 0; k < 100; k++) { + dd_generate(&defaults, r, n, 64, 100, a); + dd_generate(&defaults, r, dd_next_seed(n), 64, 100, b); + for (t = 0; t < 64; t++) { + CHECK(a[t].on == b[t].on && a[t].vel == b[t].vel && a[t].len == b[t].len, + "a note roll changed the rhythm at %d", t); + renoted += a[t].on && a[t].note != b[t].note; + } + dd_generate(&defaults, dd_next_seed(r), n, 64, 100, b); + for (t = 0; t < 64; t++) { + CHECK(a[t].note == b[t].note, "a rhythm roll changed the note at %d", t); + moved += a[t].on != b[t].on; + } + r = dd_next_seed(r); + n = dd_next_seed(n); + } + CHECK(renoted > 0, "note rolls never changed a note"); + CHECK(moved > 0, "rhythm rolls never moved a trig"); +} + +static void test_legato_and_velocity(void) +{ + dd_settings s = defaults; + dd_step out[DD_STEPS]; + int t; + + s.density = 127; + s.spacing = 3; /* every 4 steps */ + s.legato = 127; + dd_generate(&s, 1, 1, 64, 100, out); + for (t = 0; t < 64; t += 4) + CHECK(out[t].len == 4 * DD_LEN_UNIT, "LEGATO 127 does not tie: %d", out[t].len); + s.legato = 0; + dd_generate(&s, 1, 1, 64, 100, out); + for (t = 0; t < 64; t += 4) + CHECK(out[t].len == 1, "LEGATO 0 is not the shortest: %d", out[t].len); + s.vel_spread = 0; + dd_generate(&s, 1, 1, 64, 100, out); + for (t = 0; t < 64; t += 4) + CHECK(out[t].vel == 100, "VEL 0 moved the velocity: %d", out[t].vel); +} + +static void test_clamp_and_seeds(void) +{ + dd_settings s = { 255, 200, 255, 255, 40, 99, 99, 255 }; + dd_step out[DD_STEPS]; + uint32_t seed = 0; + int k; + + dd_clamp(&s); + CHECK(s.density == 127 && s.spacing == DD_SPACINGS - 1 && s.root == 11 + && s.scale == DD_SCALES - 1 && s.range == DD_RANGE_MAX, "clamp"); + s = (dd_settings){ 255, 200, 255, 255, 40, 99, 99, 255 }; + CHECK(dd_generate(&s, 1, 2, 999, 100, out) >= 0, "out-of-range input"); + CHECK(dd_generate(&s, 1, 2, -5, 100, out) <= 1, "length below 1"); + for (k = 0; k < 100000; k++) { + uint32_t next = dd_next_seed(seed); + CHECK(next != seed, "seed did not advance"); + seed = next; + } +} + +int main(void) +{ + test_determinism(); + test_ranges(); + test_density_edges(); + test_variation(); + test_density_monotonic(); + test_partial_rolls(); + test_legato_and_velocity(); + test_clamp_and_seeds(); + printf("%d checks, %d failures\n", checks, failures); + return failures != 0; +}