Skip to content

About

No description, website, or topics provided.

Resources

Stars

1 star

Watchers

0 watching

Forks

Repository files navigation

Akai S950 Studio — web version

A browser tool for Akai S900/S950 floppy images. Reads Gotek/HxC .hfe and raw 800K .img sector images: the MFM bitstream, the directory and allocation table, sample headers, programs and keygroups. Browse a disk, hear it, edit it, and write a new image you can copy straight back to your USB stick.

Plain HTML and JavaScript — no build step, no dependencies, and it runs from a file:// page. Everything happens in the browser; nothing is uploaded anywhere.

A program open, on its first keygroup

The keyboard across the top shows every keygroup's range and plays any key at its own pitch. The numbers down the left are the keygroups; everything about the selected one is in the editor beside them, and the waveform is docked along the bottom.

New here? Start with the tutorial — open a disk, change something, and write it back.

Where it lives

https://simozzer.github.io/AkaiS950Web/ published from this repository by .github/workflows/pages.yml on every push to main
https://games-compendium.pages.dev/web/Akais950Studio/ the original home, still running

The two are the same application. The Pages one is built from whatever is on main, so it cannot fall behind the source; the Cloudflare one is copied there by hand and is kept because people have bookmarked it.

Web MIDI is granted per site, so the permission has to be given once on each.

Running it

Open index.html. That is all — it is deliberately written as plain scripts rather than ES modules so it works straight from file:// with no server and no build step.

Then drop .hfe or .img files anywhere on the page, or use Open images.

Serving it

Two things a file:// page cannot have, because browsers only grant them over http or https: Web MIDI, so the MIDI input control stays greyed out, and fetch, which test/selftest.html needs to load a disk image. For either, serve the folder — any static server will do:

node serve.js               http://localhost:8080/   (included; takes a port: node serve.js 8099)
python -m http.server 8000  http://localhost:8000/   (python3 on a Mac or Linux)
npx serve                   prints its own address

Then open the address it prints rather than the file. The self-test wants a disk to work on, named in the query string:

http://localhost:8080/test/selftest.html?disk=DSKA0000-bench.hfe

Layout

The app fills the window: the tree down the left, the detail pane in the middle, and the waveform docked along the bottom so it stays visible while you work, with its transport — play, stop, and the two playback options — in a column beside the canvas rather than in a row beneath it, since the dock is the shortest thing on screen and the height is worth more to the waveform. Only the middle scrolls — the page itself does not.

Directly under the toolbar is a one-line info strip carrying the selected file's name, its actions, its own editable fields and a summary of the read-only facts. A program says everything it needs to there — MIDI program, key to loudness, crossfade, keygroup count, size, load address and which machine wrote it — leaving the whole pane below for the keyboard, the keygroup list and the editor.

Below the keyboard the pane splits: a narrow list of keygroup numbers on the left, and the editor filling the rest. The list carries only the number because every other column it once had was a read-only copy of a field in the editor beside it; hovering a number still names its key range and sample, and a keygroup whose sample is not on this disk stays marked. It sticks to the top as the editor scrolls.

The editor itself is three columns. The envelopes are on the left, with the key range and velocity directly under them — the things that decide when a voice sounds and how hard, which is what the envelopes are answering — and the LFO beside those, still under the envelopes it modulates. Both zones and the flags make the third column.

What it does

  • Decodes HFE images and lists their contents by type
  • Full field detail for samples, programs and keygroups
  • Waveform with the start, loop and end markers, and the loop region shaded
  • Piano keyboard showing every keygroup's range, the selected one highlighted
  • Hovering a key names it and gives its MIDI number
  • Clicking a key plays the keygroup's sample at that key's pitch, using the same varispeed the sampler does
  • A velocity strip on the right of the keyboard row sets how hard that click strikes, which is most of what the filter hears
  • Honours loop points on playback — a looping sample sustains rather than stopping, which the WinForms version cannot do
  • MIDI sounds every keygroup that answers a key, not just the first, and fades them into one another where the program's positional crossfade is on — which is how the multi-sampled pianos are meant to sound
  • Adds samples from any audio file the browser can decode, converted to Akai 12-bit
  • Slices a break into one-shots on detected onsets, mapping them to consecutive keys
  • New image starts an empty 800K disk, to fill and download
  • Creates and deletes programs, adds and removes keygroups, renames and deletes samples
  • Stretches a sample to a new tempo, halves its rate, trims the silence off both ends
  • Finds a loop in a sustained sample, and says how clean the join is
  • Copy to puts the selected sample or program onto another open disk image. A program brings every sample its zones name; a name already taken by something else is renamed rather than overwritten
  • Copy to... under the keygroup list puts a single keygroup onto another program, on this disk or another open one, bringing the samples its zones name with it
  • Export WAV downloads the selected sample as a 16-bit WAV at its own rate, with the loop and the root note in the file's smpl chunk rather than baked into the audio
  • Rebuilds and downloads the image as .hfe or as a raw .img, for FlashFloppy

The drum set and overall-settings files are kept on the disk but not shown. Nothing in either is decoded: the four disks whose drum set is marked in use carry one factory default, byte for byte identical on a disk with five samples and one with twenty-six, so there is nothing to display but undecoded hex.

Some of it in use

Several keygroups at once. Ctrl and shift pick them; every control then writes to all of them, under one undo.
A key range, by pointing at it. Click the low key, then the high one. The same key twice gives a one-key group.
Finding a loop. It reports how clean the join is and says what to expect of it. The loop end is a choice — a held note has a release after its loop. Slicing a break. Cut points move with the sensitivity, and the cost in slots, blocks and sampler memory is shown before you commit.

The screenshots are generated, not posed: node docs/shots.js drives a real browser over the DevTools protocol and writes docs/images, so they can be taken again whenever the interface moves.

Checking it against the original

The format code in akai.js is a direct port of the C# in ..\AkaiS950List, and is held to the same standard: it is checked against the same corpus of disk images.

powershell -File tools/make-expected.ps1 E:\     # what the C# sees, as JSON
node test/verify.js E:\                         # what the JavaScript sees, diffed against it

Three things are proved:

  1. every image decodes with no bad CRCs and no missing sectors;
  2. re-encoding every sector with its own data reproduces the original HFE byte for byte — the identity round-trip that validates the MFM writer;
  3. the decoded contents match the C# manifest file by file and sample by sample, including a checksum of every sample's decoded audio.

Last run:

disks decoded        : 102
files                : 1712
samples              : 1120
bad-CRC sectors      : 0
missing sectors      : 0
identity round-trips : 102 checked, 0 differing
mismatches vs C#     : 0
elapsed              : 13.7 s

The UI has its own test

test/selftest.html drives the real interface the way a person would — it drops a disk image on the page, clicks through the tree, and checks that the panes actually filled in and the canvases actually drew. It also runs the identity round-trip a second time, in the browser.

node serve.js 8099
# then open http://localhost:8099/test/selftest.html?disk=test.hfe

with a disk image copied into this folder as test.hfe. It reports PASS or FAIL in the page title, so it can be run headless:

msedge --headless --dump-dom "http://localhost:8099/test/selftest.html?disk=test.hfe"

Containers

Both are read; both can be written.

  • HFE — the Gotek/HxC container. Written by patching the sectors of the HFE the disk was opened from, in place: every Akai sector is 1024 bytes, so nothing moves and the MFM bitstream, gaps and sync marks around it are left exactly as they were.
  • IMG — the bare 800K sector image: 80 cylinders x 2 sides x 5 sectors x 1024 bytes, 819,200 bytes. This is what Disk.image already holds, so writing it is a copy and converting an HFE to it is lossless — test/imgtest.js checks that byte for byte, audio included, on the whole corpus.

.img to .hfe works too: with no bitstream to patch, the container is built from scratch — header, track list, and for every one of the 160 track/sides the gaps, sync fields, index and address marks, CRCs and clock bits.

The layout was not assumed from the IBM System 34 spec but measured from the library, where all 101 disks agree exactly: one identical 512-byte header, 25000 bytes per track, sectors 1..5 with no skew, the first ID address mark at bit 4928 of every side, and the gap fill running on through the 44 bytes of padding at the end of each side's last 256-byte chunk. Because of that, the encoder is held to reproduction rather than mere validity: test/imgtest.js rebuilds every disk from its sectors alone and requires the result to match the original file byte for byte. All 101 do.

Files

The root holds the app and nothing else: open index.html and that is the whole of it. Everything that checks the app, or was used to work the format out, lives beside it.

index.html, style.css the page
app.js the interface: tree, canvases, editors, Web Audio
akai.js the format: HFE, MFM decode and encode, filesystem, samples, programs. No DOM, no Node — the same file runs in both
audio.js decoding, rate conversion, 12-bit quantising, WSOLA time stretch, the loop finder
serve.js static server, for testing or hosting

test/ — what checks it

selftest.html end-to-end test of the interface, in a browser
fsck.js structural checker for one image: CRCs, chains, the arena, program headers, zone pointers
verify.js checks the port against the corpus, the way the C# tool sees it
arenatest.js churns keygroups across the corpus, checking the arena never drifts
deltest.js deletes a sample from every image and checks what is left
progtest.js creates and deletes a program on every image, and checks the round trip
slicetest.js slices a break on every image with room for it
imgtest.js converts every image to raw .img and checks nothing is lost
looptest.js the loop finder, against the loops the library shipped with
veltest.js velocity moving the cutoff, and agreeing with the desktop version on where it lands
wavtest.js the WAV export on every sample: chunk structure, and the loop landing where the player puts it
copytest.js copying across every pair of disks: what arrives, and that nothing already on the target moved
keygrouptest.js copying one keygroup onto another program, across disks and within one
trimtest.js trimming both ends, and that a loop is never cut into
newdisk.js builds a disk from nothing, fills it and writes it both ways — the one test that needs no images
vcftest.js measures the VCF — flat passband, −3 dB at cutoff, 36 dB/octave — and checks the calibrator recovers a mapping it is not given
keycaltest.js checks the key-tracking calibrator recovers a fraction it is not told
miditest.js reads the calibration MIDI file back and checks it matches the plan
lfotest.js builds an S950 whose LFO behaves in a way chosen in advance, plays the whole run through it and checks the analysis finds that behaviour without being told it

tools/ — what made it

benchplan.js the calibration run, described once, so the disk, the MIDI file and the analysis agree
benchdisk.js checks — or rebuilds — the CALIB programme against that plan
makemidi.js writes the run as a Standard MIDI File
benchcal.js reads one take of the run and settles everything it can
emulate.js renders the run through the emulation, as a WAV
vcfcal.js derives the real cutoff mapping from recordings of the hardware
keycal.js what key-to-filter tracking means, measured from three notes
lfoplan.js the LFO calibration run, described once
lfodisk.js builds the LFOCAL disk from nothing: three generated tones and 28 keygroups
lfomidi.js writes that run as a Standard MIDI File
lfocal.js demodulates a take of it and reports what the LFO actually does
envplan.js … envplan5.js the five filter-envelope runs, each described once. The old ones stay so a take already recorded remains analysable after the plan has moved on
envdisk.js builds an ENVCAL disk from nothing: white noise from a fixed seed, and a programme whose every keygroup comes from the plan
envmidi.js writes one of those runs as a Standard MIDI File
envcal.js reads a take and settles what it can — --json writes the numbers out
envrender.js renders the same run through the emulation, so the analysis can be put through a take whose answers are already known
envfit.js turns the two result files into the envelope table, cancelling the analysis's own bias
probe.js, ram.js, zones.js, repair.js one-off diagnostics from working the format out
makezip.js builds the downloadable bundle — the page and what it loads, read out of index.html rather than listed by hand
make-expected.ps1 dumps what the C# sees, for test/verify.js to diff against

.github/workflows/ — what publishes it

pages.yml runs the three tests that need nothing but the repo, builds the source bundle, and publishes the app to GitHub Pages

docs/ — what explains it

tutorial.md the walkthrough: open a disk, change something, write it back
S950-Disk-Format.pdf how the format works, in sixteen pages
S950-Disk-Format.html the source it is printed from
format-check.js re-counts every figure in that document against the library
shots.js regenerates the screenshots by driving a real browser

Starting a disk from nothing

New image makes an empty 800K disk in the page. Fill it like any other and press Download image.

An empty disk really is 800 blocks of zeros. The only structures in the reserved blocks are the directory and the allocation table, and both are empty when zero: across all 101 library disks the 960 bytes after the table are zero, so there is no boot record, label or signature to reproduce.

What it does not write are the OVERALL SE and DRUM SET files that 99 of the 101 carry. Those hold settings rather than structure — and the overall file opens with a sample name, which a disk with no samples cannot honour. Two library disks have neither and read perfectly, which is the evidence that their absence is allowed; the sampler writes its own when you save settings on it.

test/newdisk.js builds one, fills it, writes it as both containers and reads each back, checking the directory has no holes, the zone pointer is the sample position, the program header restates its own layout, and the audio survives the round trip byte for byte.

What is not proven: a disk made this way has never been put in front of a real S950. Everything it contains follows rules confirmed on hardware, and fsck.js finds nothing wrong with it, but that is not the same as a machine loading it. Try one before you rely on it.

Editing

Select a program, then a keygroup number from the list on the left, and the editor fills the space beside it. The first keygroup is selected for you.

Several keygroups can be selected at once — ctrl-click adds one and takes it out again, shift-click takes the run from the last one clicked, exactly as a file list does. The editor then shows the lead, the one clicked last, marked in the list with a bar and in bold while the rest of the selection is tinted; the keyboard shades them all and says how many are selected.

Every control in the editor then writes to all of them: a slider, an envelope corner, the VCF amount, a flag, a zone's sample, and the key range taken from the keyboard. It is one undo however many it touched, and the status line and the undo entry both name the count — To loudness on 4 keygroups — so a change made to more than you meant is obvious and one keystroke from gone. Delete takes the whole selection, and the editor shows what it is editing at the top: 8 of 10 · editing 5 selected.

What the editor holds:

  • Both envelopes, VCA and VCF, dragged by their corners — the peak sets attack, the corner sets decay and sustain together, the last sets release. The VCF amount is a vertical slider beside its envelope.
  • Parameter sliders for the velocity switch and sensitivities, LFO, zone tuning and the flags, each with its exact value beside it. They are things you sweep by ear rather than numbers you arrive knowing, so a slider writes as it moves: you hear the change on the next note, the whole drag counts as one undo, and the editor is only rebuilt when the handle is let go. The key range has no control of its own here: it is set on the keyboard, and the keyboard's caption gives the selected keygroup's range in both MIDI numbers and note names.
  • From keyboard, beside the Key range heading, sets the range by pointing at it rather than by typing two MIDI numbers: the next click on the keyboard is the low key and the one after it the high, with the keys shaded as you move between them. The same key twice gives a one-key group, the two clicks can come in either order, and Esc cancels. Both keys go in under a single undo.
  • Add keygroup copies the selected one so it arrives playable; Delete keygroup removes it. Both resize the program file, relink the keygroup chain and shift the RAM arena, exactly as the WinForms version does.

Finding a loop

The S950 holds a loop as an end point and a length, and plays end - length .. end round and round. The library bears that out: of its 324 looped samples the loop start field is simply 0 in 250 of them, and the length is what says where the loop begins. So the only thing to find is how far back it restarts.

Find loop, on a sample, looks for it. The join the ear hears is the one from the loop end back to that point, so what it looks for is the point whose approach matches the approach to the end — a normalised cross-correlation, coarse over a properly decimated copy and then exact around the best few candidates, kept apart so a short clean loop cannot hide under a long mediocre one.

The loop end is a choice

A loop does not have to run to the end of the sample, and assuming it did was wrong. BOWEDBASS on disk 51 is the case that shows it: every one of its nine samples loops before the end, leaving between 0.12 and 0.48 s of tail — the release you hear when the key comes up. Loop to the end of the sample and that release is played round and round, and there is none of it left to hear.

So the end is offered three ways — where the sustain ends, the end of the sample, and the end marker it has now — and the waveform is clickable: put the end where you want it and the loop is found backwards from there.

The sustain end is a starting point, and honestly only that. It is the last frame of a short-time RMS envelope within 2 dB of the loudest, which finds the knee where a held note starts to let go. Of the library's 324 looped samples only 30 stop short by more than 50 ms, and on those this lands about 200 ms later than the person did, at every threshold tried: they left a margin so the loop would not eat into the release. That is a musical judgement, not an acoustic edge, so the tool marks the knee and leaves the judgement to whoever is listening. It is not offered at all on a sample whose level falls from the attack onwards — a plucked or struck note has no sustain to end, and taking the knee there would leave nothing to loop.

The other thing the finder cannot know is asked for too: how short a loop is still musical.

It reports the match, −1 to 1, and says plainly what to expect of it: inaudible above 0.95, audible below about 0.8, and hopeless on material with no repeating part in it — applause and running water have no clean loop, and the number says so rather than pretending. Preview auditions the loop before it is written.

test/looptest.js measures it against the loops somebody chose by ear on the machine, using the same join measure for both. Across the library it averages 0.881 where the shipped loops average 0.793 — better on 110 samples, as good on 158, worse on 30, and those 30 are noise where both answers score near zero.

node test/looptest.js <directory of .hfe> [modes]

A note on the loop mode, which the tool now changes properly: how many 10-byte descriptors a sample takes in the table after the keygroup arena depends on it — a looping sample takes one more than a one-shot — so changing the mode moves the descriptor pointer of every sample after it. Writing the byte alone, which is what this did before, broke that chain on 95 of the 96 library disks it was tried on. test/fsck.js checks the chain now, and looptest.js modes flips a mode on every disk and confirms it holds.

Add sample reads an audio file and writes it into the selected disk. The browser decodes it — WAV, MP3, FLAC, AAC, Ogg, whatever it supports, which is more than the desktop version manages without ffmpeg — and an OfflineAudioContext does the mixdown to mono and the rate conversion. It is normalised, quantised to 12 bits, and refused outright if it will not fit in the free blocks.

Edits change the image held in the page. Download image asks what to call the file and writes it out; nothing is altered in place.

The extension follows the image rather than what you type: an HFE saves as .hfe and a raw sector image as .img, whether you typed the right one, the wrong one or none at all. Characters a filename cannot hold are replaced, and leaving the box empty keeps the suggested name.

Sample operations, on the waveform pane: Trim silence, Halve rate, Fit to tempo (WSOLA time stretch, pitch preserved), Find loop, and Rename on the file heading — which retargets every keygroup zone that named the sample.

How hard the keyboard strikes

The strip on the right of the keyboard row sets the velocity a click carries, 1 to 127, and it matters more than it looks. Velocity opens the filter: at the measured 8.34 octaves across the full range the pivot is 65, so the fixed strike of 100 both programs used to play sat about 1.2 octaves above it. On a programme written with any velocity-to-filter at all, that is the difference between hearing the filter and not. It lifts the level too, at 0.63 dB per step, so a softer strike is quieter as well as darker.

It still starts at 100, so nothing sounds different until the strip is moved. MIDI input was always given the real velocity and is unchanged.

veltest.js checks that velocity moves the cutoff, that it moves the right way, and that this version and the desktop one land on the same frequency for the same strike - they are the same instrument or they are not.

A filter that seems to do nothing

If it does, the programme is probably written wide open rather than the filter being broken. The cutoff ceiling is 0.37 x the rate the audio leaves at - the filter is also the reconstruction filter - and the curve reaches it well before the stored value reaches 99. A sample played well below its root has a correspondingly low ceiling: pitched down two octaves, a ceiling of 1500 Hz is all there is, and any stored value from about 50 upward is already sitting on it.

So a filter that will not close is usually a programme whose stored cutoff is above its own ceiling, and the fix is a lower stored value rather than a louder complaint.

Copying between disks

Copy to in the file row lists every other disk that is open. Nothing is written until the whole set fits, and a confirmation first itemises exactly what would land.

A program is never copied alone: its zones name their samples by name, so every sample they name comes with it, or it would arrive silent. That makes a copy quietly larger than it looks, which is why the confirmation spells it out.

Nothing on the target is replaced. A name already taken by a different file is renamed - BASSLOOP arrives as BASSLOOP2 - and the copied program's zones are repointed at the new name, so it still plays what it came with while the target keeps what it had. A name taken by the same file is recognised and skipped, so copying a program twice costs nothing the second time.

Two numbers in a sample's header belong to the disk rather than the sample - its address in the sampler's RAM and its loop-descriptor pointer - and rebuildPointers touches neither, so planCopy recomputes them from the last sample already on the target. Everything else carries over untouched: rate, tuning, loop markers, mode and direction.

Copy to... under the keygroup list does the same for a single keygroup, onto any other program that is open. Unlike a file copy this may end on the disk it started on - moving a keygroup between two programs of one disk is an ordinary thing to want - and it brings only the one or two samples that keygroup's own zones name, rather than the whole program's. It lands at the end of the target program; a keygroup's place in the chain carries nothing the sampler reads, since the key range decides what sounds.

copytest.js copies every sample and program across every pair of disks and checks rather more about what must not change than about what does - every pre-existing file byte for byte, and rebuildPointers on the reloaded result finding nothing left to fix. The desktop version does the same work from the same disks; AkaiS950Tests\CopyCheck.cs over in the VirtualS950 repository is the other half.

Exporting a sample as a WAV

Export WAV writes the selected sample as a 16-bit mono WAV at its own rate and hands it to the browser as a download. The whole sample comes out: the audio is not trimmed to the markers and the loop is not baked into it. Both travel in the file's smpl chunk instead, along with the root note taken from the sample's tuning, so a sampler or DAW that reads that chunk picks the loop up by itself and one that does not still gets the whole sound. Nothing is discarded either way, so the markers can still be moved afterwards.

The loop is the tail running back from the end marker by the loop length, not the whole marked span — the same reading the player uses. The clamping that goes with it is the common path rather than an edge case:

6 disk(s), 61 sample(s), 43 looping, 37 with a loop longer than the sample
990 checks, ALL PASSED

Thirty-seven of the sixty-one declare a loop length longer than the sample it belongs to, so a loop start worked out by subtraction alone lands before the start of the sound. wavtest.js works out where the loop should land independently rather than by calling sampleWav, because two pieces of code agreeing when they are the same code proves nothing.

The desktop version writes this file too, from the same disks, and the two are separate implementations. All 61 samples in the shipped library come out byte for byte identical from both — AkaiS950Tests\WavCheck.cs over in the VirtualS950 repository is the other half of wavtest.js.

Trimming silence

It takes the silence off both ends, and moves the markers with the audio. Silence means below 8 of the 12-bit range — about −48 dB — so a tail that fades into noise rather than to nothing is left alone, on the grounds that a fade is audio and the tool should not guess otherwise.

The end is the delicate one, because the tail of a sample is where a loop lives. A cut that reached into a loop would leave the sampler looping over audio that is no longer there, so the cut stops at the loop end and the confirmation says when it did.

Shortening a file also moves what comes after it. Samples sit back to back in the sampler’s RAM in directory order, and their loop descriptors sit in one table the same way, so a trim moves every sample after the one trimmed. The zone pointers do not move: those encode a sample’s position in directory order, and a trim changes no order at all.

test/trimtest.js checks all of it — first on made-up samples where the answer is known to the word, then by trimming for real across the library. 81 of the 101 disks have something to trim, 67 of those move the samples that follow them in RAM, and all 81 come through with the RAM chain, the descriptor chain, the directory and every zone pointer intact, saved and reloaded.

It compares before with after rather than asking whether a disk is perfect: seven of the disks it touches already have a loop descriptor pointer that does not follow the one before it, and a test that ignored that would blame the trim for them.

Programs get their own panel: MIDI program, key to loudness, positional crossfade, and rename.

Deleting a sample

Delete on a sample's info strip, behind a confirmation that names every keygroup zone that will be emptied. Three things move together, or the disk is left inconsistent:

  • zones naming the sample are cleared to the unused placeholder;
  • zones naming a later sample have their pointer pulled back one record — those pointers are a sample's position in directory order, and every later sample has just moved down one;
  • later samples shift down in sample RAM by the space this one occupied.

Then its blocks return to the free pool and its directory slot is cleared.

test/deltest.js checks this against the whole corpus: it deletes a sample from the middle of every image and verifies the disk afterwards — every other file byte-identical and still readable, the free count exactly right, the RAM chain unbroken, no zone left naming a missing sample, and the result surviving a save and reload.

disks tested       : 97
samples deleted    : 97
zones emptied      : 166
pointers adjusted  : 788
blocks recovered   : 5543
problems           : 0

Undo

Undo and Redo in the toolbar, or Ctrl+Z and Ctrl+Y, keeping 24 steps. A step is a whole disk image — 800 KB each, so 24 is about 19 MB, cheap enough not to think about and exact whatever the edit touched, including the ones that resize a file and move things in RAM.

Dragging an envelope corner is one step, not one per mouse move.

Calibrating the filter and the envelopes

The filter and envelope model is measured against the machine rather than guessed at. Everything the measurement needs is generated from one file, tools/benchplan.js, so the disk, the MIDI file and the analysis cannot drift apart - which matters, because the analysis identifies each clip by its position in the run and nothing else. A keygroup set to the wrong filter is not an error; it is a wrong answer delivered confidently.

Every test is a keygroup of one programme, CALIB, on its own key. The obvious arrangement - one programme per test, selected by program change - does not survive Ableton Live, which treats program change as a per-track I/O setting rather than clip data, so an imported file arrives as bare notes and silently tests one programme twenty-two times. Changing test therefore means playing a different note, which every sequencer can do.

All the measurements divide by NOISE, a sample this project generates and which sits bit-exact in the disk image. The source spectrum, the level of each clip and anything the recording chain added all cancel, leaving only the sampler's own shaping.

node tools/benchdisk.js check DSKA0000-bench.hfe   # the disk matches the plan
node tools/makemidi.js                            # writes tools/AkaiCalibration.mid
node test/miditest.js                             # the MIDI file matches the plan

benchdisk.js build <in> <out> writes the programme the plan describes, if the two ever part company.

Recording the sampler

Load the disk, select CALIB, point a sequencer at the S950 on MIDI channel 1, record the audio output and play AkaiCalibration.mid. It runs for 109 seconds. Then:

node tools/benchcal.js take.wav

which reports every constant the take settles, and says so plainly where a measurement is against a stop and means nothing.

Rendering the emulation

The same run, through the model instead of the machine. tools/emulate.js renders it offline to a 32-bit float WAV - nothing quantised and nothing clipped, however loud the run is, since the measurements are all ratios and a clipped take would be measuring its own clipping.

node tools/emulate.js emulated.wav
node tools/benchcal.js emulated.wav

Run the two reports side by side and hardware and emulation differ only where the model is wrong. It caught a real error in the cutoff curve.

What it does not check is the playback path. tools/emulate.js re-implements the envelope in a loop of its own, so it settles the model's constants and not the thing you actually hear - Web Audio's scheduling, the voice handling, the release. The page used to carry a Record button for exactly that, capturing what it played over a MIDI loopback for tools/benchcal.js to read; it was removed once the model stopped moving. Recording the page through the operating system, or restoring the button from the history, is the way back to that check if the playback path ever comes under suspicion again.

Calibrating the filter envelope

A second rig, and a separate one, because the first could not answer what was asked of it. benchplan.js measures things that stand still — a cutoff, a level — and an envelope does not stand still. Worse, its sample is three seconds long, so every envelope in the run had to be fast, a fast sweep can only be followed in short windows, and a short window cannot measure a corner. Three runs went that way and the shape never came out of them.

envplan4.js onwards loops the sample, so a note lasts as long as it is held. That one change is what the rest rests on: with fourteen-second notes the envelope can be slow, and a slow sweep is a static corner measured twenty times over rather than a moving one chased with noisy windows.

node tools/envdisk.js build tools/ENVCAL5 --plan envplan5.js
node tools/envmidi.js tools/AkaiEnvCalibration5.mid --plan envplan5.js

Write the .hfe to a Gotek, select ENVCAL, record the output and play the MIDI file. Then:

node tools/envcal.js take.wav tools/ENVCAL5.img --plan envplan5.js

Measuring the measurement first

Every run is rendered through the emulation before it is recorded, and the render is analysed by the same code:

node tools/envrender.js rendered.wav tools/ENVCAL5.img --plan envplan5.js
node tools/envcal.js   rendered.wav tools/ENVCAL5.img --plan envplan5.js

The answers there are known exactly, so whatever the analysis reports that the model does not do is the analysis being wrong. That is not a formality — it caught four faults that would every one of them have been blamed on the sampler:

  • the splitter reports a note about 1.4 s after it starts, and the onset search only looked a second either side, so its whole window sat inside the note with no silence to measure against. Everything was read 2.36 s late, and two attacks came back as identical flat lines because both were over before the trace began;
  • twenty-two frequency bands under 150 Hz cannot be averaged over a quarter of a second, so the levelling carried more scatter than the 3 dB it was looking for;
  • three seconds of noise is flat on average and emphatically not flat in any given quarter second — and because the sample loops, the same unlucky stretch misreads identically every three seconds. Dividing by the source at the matching loop position took a corner that read 168–2356 Hz down to 2291–2303;
  • one section set its sustain to 0 and so tested nothing at all: the amount scales the envelope's output, and an envelope resting at zero gives zero however large the amount.

The bias that remains is measured rather than argued away. envfit.js reads the render's results and the hardware's together, subtracts what the analysis does to a known answer, and prints the table — so a constant reaches the engines by arithmetic rather than by being read off a printout and retyped.

What it settled

The envelope time curve, at nine settings, where the model had one measured point and an assumed 10000:1 span across the whole range. The real span is nearer 1000:1. Attack, decay and release are three separate readings of that one curve and agree to 1.07x where they overlap, and the VCA decay confirms it independently from the same take.

That the release is a rate, not a duration — the byte sets how fast the envelope falls, so a release from half depth is over in half the time. Nothing had caught it because every release ever measured started from a sustain of 99 and fell the whole depth, which is the one case where the two rules predict the same thing.

That the VCA attack is a counter: 5.4/n seconds for whole n, at all thirteen settings, to within 0.7%. Which is why stored 70 and 75 return identical attacks to four digits, and why the attack stops getting slower at 2.70 s.

Calibrating the LFO

A keygroup stores an LFO as three numbers — delay (byte 15), rate (16) and depth (17) — plus a desync flag and two bytes saying how far the modwheel and aftertouch may add to the depth. Every one of them is 0..99, and none of them is in any unit at all.

The emulation has no LFO yet, and it is not getting one from guesses. This is the same shape of apparatus as the filter run — a plan, a disk, a MIDI file, an analysis — aimed at turning those five bytes into seconds, hertz and cents.

node tools/lfodisk.js build                 # writes tools/LFOCAL.img and .hfe
node tools/lfodisk.js check tools/LFOCAL.img
node tools/lfomidi.js                       # writes tools/AkaiLfoCalibration.mid
node test/lfotest.js                        # the whole chain, end to end

The disk is built from nothing rather than from a library image, so it carries no audio but its own and can be rebuilt byte for byte by anyone. It is not in the repo for that reason. The run lasts four minutes and sixteen seconds.

What it plays

A steady looped tone, not noise: the filter run measures a spectrum, where noise is ideal, and an LFO moves pitch, which noise does not have. 400 Hz at 20 kHz makes a period exactly 50 words and 200 periods exactly 10,000, so the loop is the whole sample and joins onto itself with no discontinuity at all — a loop that clicked twice a second would put a spike into the pitch track every time round and be read as modulation.

Three of them, band-limited by construction:

SAW 16 harmonics. What the ladders play — strong, evenly spaced partials
SINE the fundamental alone, so a wobble in the level is plainly a wobble in the level
PULSE a quarter-width rectangle. A different spectrum entirely, played at a setting the sawtooth also plays

Each test sits on its own key and sounds at that key’s own pitch, 141 Hz to 599 Hz across the run. Nothing is transposed — see below for what happened when it was.

What it asks

Eight rungs of rate, six of depth, five of delay; one slow deep clip held sixteen seconds to see the waveform; a sine to say whether the LFO moves pitch or level; a pulse at a depth the sawtooth also plays, as a check that a reading belongs to the machine and not to the waveform; a wheel climbing through nine steps of one held note; and two pairs of overlapping voices, with and without desync.

The desync pair is the speculative one. The flag is set in 1652 keygroups of 1908, so whatever it does is the normal case — the guess is that it gives each voice its own LFO rather than sharing one. Two notes started a second and a half apart say whether their wobbles line up. They are two octaves apart, which is not a musical choice: the tracker isolates a tone by averaging over exactly one period of it, and that puts a null on every multiple of its own frequency, so two octaves up lands the second voice exactly on one.

How the pitch is read

By spectrum it would be hopeless — a few tens of cents at a few cycles a second, which no useful window length resolves. So the tone is demodulated, the way an FM receiver does it: multiply by a cosine and a sine at the tone’s own frequency, average each product over precisely one period of that tone, and take the angle of the pair. A boxcar average of length T nulls every multiple of 1/T, so averaging over one period removes every harmonic exactly — which is why a 36-harmonic sawtooth reads as cleanly as a sine.

Two things in that were wrong at first, and the test caught both:

  • The loudest thing in the clip is not the tone. Frequency modulation spreads a tone into sidebands, and near a modulation index of 2.4 the centre of it vanishes entirely, so the peak of the spectrum sat three hertz off and the averaging landed between the harmonics instead of on them. The mean of the instantaneous frequency is the carrier, so it demodulates once to find out where the tone really is and then again there.
  • A depth read as the highest point of the folded cycle is two samples out of thousands, and every smoothing in the chain rounds a corner off them: a triangle at 12 Hz lost a seventh of its peak that way, which is the difference between a depth law that looks linear and one that does not. It is read at the fundamental instead, with the tracker’s own response — every boxcar in it, known exactly — divided back out, and converted to a peak through whichever waveform the shape section identified.

The rate is looked for in the pitch and in the level, because taking it from the pitch alone would find nothing at all in an LFO that turned out to move the level instead, and "found nothing" is the wrong answer to that question.

Recording it

Load LFOCAL, select it, point a sequencer at the S950 on MIDI channel 1, record the audio and play AkaiLfoCalibration.mid. Then:

node tools/lfocal.js take.wav

which prints every reading with how well it was determined beside it, draws the LFO waveform as it folded, and ends with a block to paste into audio.js once there is an LFO there for it to go in.

What the first take settled

The first recording was mostly wasted, for a reason worth writing down. Every keygroup transposed its zone back towards the sample’s root so that all 26 clips would sound at the same pitch — tidy, and unnecessary, since every measurement here is a ratio. It asked for transposes of −14 to +14 semitones. Across the library’s 3816 zones the transpose spans −4 to +3 and is zero in 3416 of them, so the run was relying on a field far outside anything the corpus demonstrates.

The machine did something drastic with it. 25 of the 26 clips came back at the wrong pitch, and the two clean enough to read were both exactly three octaves from where the key alone would have put them — a sampler against its stop. The one clip that played what it was asked to play was the one on the root key, whose transpose was zero.

Two things came out of it anyway.

The rate is linear in the byte, which is not what was expected — the filter’s cutoff is exponential, so the analysis fitted this in log2 hertz and tried nothing else. The eight rungs fall on a straight line in hertz to better than a fiftieth of a hertz:

rate = 1.782 + 0.08930 x byte   Hz      r2 0.99998

  byte   0   measured  1.788   line says  1.782
  byte  10   measured  2.682   line says  2.675
  byte  25   measured  3.992   line says  4.014
  byte  40   measured  5.361   line says  5.353
  byte  55   measured  6.678   line says  6.693
  byte  70   measured  8.054   line says  8.032
  byte  85   measured  9.366   line says  9.372
  byte  99   measured 10.622   line says 10.622

A wobble keeps its frequency whatever pitch it sits on, which is why these survived the mess. And they are confirmed from outside it: the one clip that played correctly carries byte 60 and measured 7.152 Hz, where the line drawn through the other eight says 7.139. In log2 hertz the same eight points fit at r2 0.944, so the law is not the filter’s law. lfocal fits both now and lets the data choose.

And from that one good clip, at rate 60, depth 99, delay 0:

carrier 249.94 Hz where 250 was asked for — so the tone, the loop, the 12-bit packing and the root pitch are all right
rate 7.152 Hz, explaining 1.00 of the track over 33 cycles
depth 99 ±149 cents, symmetric: −149.7 to +146.1
shape sine, r 0.999 — not the triangle a guess would have reached for
level moved 0.22 dB, so it is vibrato and nothing else

Everything else — the depth law, the delay law, the modwheel, desync — needs the re-take.

What the second take settled

The transpose was gone and it showed: all 26 clips landed within 30 cents of the pitch the plan asked for, and the run lined up to 6 ms. But 29% of its samples sat at or beyond full scale — every clip clipped, the worst of them flat for 87% of its length.

That turns out to matter far less than it should. A clipper is memoryless, so it leaves the zero crossings exactly where it found them, and frequency demodulation reads zero crossings. The rate and the depth came through; everything to do with level did not.

The rate law, confirmed independently. The second take, recorded from a different disk with different keys and different pitches, gives

rate = 1.785 + 0.08918 x byte   Hz      r2 0.99998

against the first take’s 1.782 + 0.08930. Two takes, nothing in common but the machine, agreeing to 0.2%. The same eight points fit an exponential at r2 0.944, so the law is not the filter’s law — lfocal fits both now and prints which is straighter.

The depth law. Depths 20, 40 and 60 give

1.555 cents per unit   r2 1.0000   ->   depth 99 is 153 cents

which lands on the first take’s single clean clip, where depth 99 measured ±149 cents. Depths 80 and 99 read low and are excluded — those are the clips whose level swings 20 to 32 dB, which is the clipping.

The waveform is a sine, r 0.998 over 41 cycles, with the first take’s clean clip agreeing at r 0.999. Not the triangle a guess would have reached for — and the analysis had been quietly assuming one in the modwheel section, a 23% error that only showed up once the machine had been asked.

The timbre check passed: a pulse and a sawtooth at the same depth read 92.09 and 91.96 cents. The reading is of the machine, not of the waveform.

Still open: the delay (only byte 99 shows one at all), the modwheel curve (r2 0.875 with one rogue point), and desync, where the upper voice of each pair came back barely modulated and may not have sounded at all.

What the LFO does

The emulation plays it. audio.js carries these constants and play() builds a sine on the playback rate from them — Web Audio’s detune is already in cents, which is the unit they were measured in — with the delay as a fade rather than a wait and the modwheel adding to the depth over MIDI. There is an LFO box beside the filter one to turn it off, and no oscillator is built at all for a keygroup whose depth is zero and whose wheel is down, which is most of the library. test/lfotest.js checks the emulation against the readings below, so an edit to any of these constants has to argue with the machine.

The third take was clean: peaks at −20 dBFS, nothing clipped, all 26 clips within a semitone of the pitch asked for, the run lined up to 7 ms, and every rung of the rate ladder explaining 0.99 or better of its own track. This is what it says.

The fourth take, with every key at or below the sample root and the delay rungs re-spaced, settled the last of it. What follows is from that take unless a figure says otherwise; the earlier ones agree with it everywhere they overlap.

Rate — linear in the byte

rate = 1.785 + 0.08918 x byte   Hz      r2 0.99998

So byte 0 is 1.79 Hz and byte 99 is 10.61 Hz. Fitted in log2 hertz the same eight points give r2 0.944, so this is not the filter’s exponential law — and the first take, from a different disk with different keys and pitches, gave 1.782 + 0.08930. Two takes agreeing to 0.2%.

Depth — proportional, and about a semitone and a half at full

  byte     0     20     40     60     80     99
  cents  0.04  29.87  60.95  92.18 122.39 150.21     1.526 cents per unit, r2 0.9998

Peak deviation, so depth 99 swings ±150 cents — three semitones peak to peak. The first take’s one readable clip measured ±149 at the same setting.

Waveform — a sine

r 1.000 against a sine template over 41 cycles, with a triangle second at 0.993. Symmetric too: −153.3 to +148.2 cents. This is worth stating plainly because the analysis had been quietly assuming a triangle in its modwheel arithmetic, which is a 23% error, and nothing but asking the machine would have found it.

It is vibrato and nothing else

At full depth the level moves 0.23 dB peak to peak, and 0.01 dB of that at the LFO’s own frequency. The pitch moves 150 cents. The earlier take appeared to show 20–32 dB of level movement, which was entirely its own clipping.

Delay — a fade-in, and a reciprocal law

It does not wait and then start; it fades in. And the fade time is not linear in the byte, nor exponential:

  byte              0      50      75      85      92      96      99
  fade (seconds) 0.084   0.159   0.314   0.502   0.934   1.891   7.503

  against the byte         r2 0.24
  against 1/(100 - byte)   r2 0.99999   ->   seconds = 7.50 / (100 - byte)

A control that idles for three quarters of its travel and then runs away. Byte 50 fades in in a sixth of a second; byte 99 takes seven and a half.

The first ladder to measure this was evenly spaced — 0, 25, 50, 75, 99 — and four of its five rungs landed inside a third of a second, which is no way to characterise anything. Re-spaced at 85, 92 and 96 the law predicted 0.48, 0.89 and 1.78 seconds before the take and measured 0.502, 0.934 and 1.891 after it. That is the difference between a curve fitted through a point and a law that holds.

The climb is a straight line, which is worth knowing because it is what the emulation schedules: at byte 99 the fifth, quarter, half and nine-tenth marks came at 0.354, 2.173, 4.126 and 7.503 seconds, against 0.42, 2.08, 4.17 and 7.50 for a ramp.

Modwheel — proportional, and it is the same LFO

  wheel    0    16    32    48    64    80    96   112   127
  cents  0.00 10.04 17.06 28.53 35.96 46.96 55.20 64.22 71.45    r2 0.999

72.1 cents across the whole wheel at byte 22 = 99. At byte 22 = 50 the same full wheel gives 36.1 cents — a ratio of 0.506 where exactly proportional would be 0.505. So byte 22 scales the wheel’s contribution proportionally, and the wheel adds about half of what the keygroup’s own depth 99 does.

Desync — the flag does what its name says

It took three takes to ask the question properly. The upper voice of each pair sat two octaves above the sample’s root, and the S950 brought it back 12 dB below its partner and too rough to demodulate — a 50-word sine stepped at five and a half words a sample is not much of a sine. Moved below the root, where the sampler only ever reads the same words more slowly, both voices came back at full depth and the answer fell out at once.

The measurement is not a number but a behaviour, so it is watched over time: two notes a second and a half apart, and the phase between their wobbles read second by second.

  bit clear          bit set
  7.142 / 7.165 Hz    7.142 / 7.414 Hz     the two voices’ rates
  80 79 82 81 79 81   -162 -79 9 98 -179 -94    degrees apart, one second at a time

With the bit clear the two voices run at the same rate to four figures and hold their phase to within 3 degrees over six seconds: one oscillator, shared across the programme. With it set they run at 7.15 and 7.40 Hz — 3.5% apart — and drift a full turn apart in those same six seconds: one oscillator each, free-running. The 0.25 Hz difference predicts 90 degrees of drift a second; the measurement shows 88.

The emulation models the sharing, since that is the structural fact. It does not model the 3.5%: one observation of one pair is not a distribution, and inventing a spread to make the free-running voices drift convincingly would be making it up.

One thing left unexplained. Two voices on the shared oscillator ought to be in phase, and they sit a stable 80 degrees apart — 31 ms at this rate. A fixed time offset would point at the order in which the machine services its voices, and the way to tell is to repeat the pair at a second LFO rate: a fixed time offset keeps its milliseconds and changes its degrees.

Reading the takes back

A take belongs to the plan it was recorded from, since the analysis identifies a clip by where it falls in the run. The numbers above came from a take made at 1c88f45; reading it with a later plan produces nonsense, quietly. To re-read an old take, put its plan back first:

git show 1c88f45:tools/lfoplan.js > tools/lfoplan.js

The guards that were missing

None of that was noticed by the analysis. It found something within its search band in every clip, demodulated whatever that was, and reported depths of 6270 cents with no more hesitation than it reports a good one. A tool that cannot tell a measurement from a misunderstanding is worse than no tool, because it is believed.

So the report now opens with a PITCH section: every clip, what it was asked to sound at, what it did sound at, and whether its level held steady. Clips that fail are named, excluded from every fit, and the reader is told plainly that nothing taken from them means anything. test/lfotest.js plays a clip three octaves out to prove the guard catches it, and one half a semitone out to prove it does not catch that.

The second take added another: clipping, which is invisible in every number the tool prints. The report now says what fraction of the take is against the stop, names the worst clip, and works out how far down to turn it — from the worst clip rather than the average, since a take that is half silence hides its own peaks.

One more thing the real machine caught. The modwheel section reads a fundamental and has to convert it to a peak, which needs the waveform; it assumed a triangle, which was a guess wearing the clothes of a constant. The machine runs a sine, and the difference is 23%. It now takes the shape the shape section found.

Knowing it works before there is a take

test/lfotest.js builds an S950 that does not exist: a rate that doubles every 20 units, a depth of 0.55 cents per unit, a delay of 45 ms per unit and a triangle. None of those is a guess at what a real S950 does — they are numbers chosen to be nothing like round and nothing like each other, so a reading that comes out close cannot be close by luck. The tone is rendered the way the sampler renders it: the very words lfodisk.js writes to the disk, read out of a loop at a rate the LFO moves.

It plays the whole 256-second run through that machine, writes a WAV, and hands lfocal the file and nothing else. It recovers 0.550 cents per unit, doubling every 20.0 units, 0.0450 seconds per unit, and the word triangle.

The format, written down

The format itself is written up in S950-Disk-Format.pdf: sixteen pages on the container, the MFM encoding, the directory and allocation table, the sample and program records, and the arena they share. Everything in it was worked out by reading the library and confirmed on hardware; this code is that document in executable form.

It is mostly numbers taken from the corpus — non-zero in 200 of 1110 samples, 255 in all 341 S900 programs — and numbers age. docs/format-check.js recounts them against the disks:

node docs/format-check.js <directory of .hfe>

Thirty-eight claims, and a FAIL means the document has drifted rather than the disks. Mind the two bases it counts on: figures given of 1899 exclude DSKA0049, the one image whose contents differ between the two sticks, and the document says so.

The PDF is printed from docs/S950-Disk-Format.html, so corrections go in the HTML and it is printed again:

chrome --headless --no-pdf-header-footer --print-to-pdf=docs/S950-Disk-Format.pdf docs/S950-Disk-Format.html

About

No description, website, or topics provided.

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages