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.
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.
| 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.
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.
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
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.
- 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
smplchunk rather than baked into the audio - Rebuilds and downloads the image as
.hfeor 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.
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.
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:
- every image decodes with no bad CRCs and no missing sectors;
- re-encoding every sector with its own data reproduces the original HFE byte for byte — the identity round-trip that validates the MFM writer;
- 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
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"
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.imagealready holds, so writing it is a copy and converting an HFE to it is lossless —test/imgtest.jschecks 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.
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 |
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 |
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 |
pages.yml |
runs the three tests that need nothing but the repo, builds the source bundle, and publishes the app to GitHub Pages |
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 |
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.
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.
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.
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.
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.
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.
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.
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.
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.
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 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.
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.
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.
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.
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
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.
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.
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.
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.
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.
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.
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.
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.
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.
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 = 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%.
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.
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.
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.
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.
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.
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.
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
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.
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 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




