{
  "$comment": "User-facing AND internal changes, newest first. Every entry: title (one line) FIRST, then version (CalVer YYYY.M.N it ships in, authored as \"NEXT\"), date, area, type, userFacing, details (full), and optionally tickets (an array of bug-board numbers this entry closes, e.g. [83]). The title leads DELIBERATELY: it is the only line unique to an entry, and the release pipeline's stamp is a one-line change to the version right under it - without a unique line above it, rebasing a new entry over that stamp lands the number on the wrong entry (see docs/release-pipeline.md). The welcome screen shows userFacing entries released since the version last run; tickets are never shown to users - the release pipeline reads them to walk those tickets along the board as the release progresses (tools/release/move-tickets.py). See CLAUDE.md > Change log for the authoring rules. Ships with the product and is copied to the CDN on release.",
  "entries": [
    {
      "title": "The Welcome window is frosted glass over the moving background, which is now a little brighter",
      "version": "2026.9.23",
      "date": "2026-10-06",
      "area": "UI",
      "type": "Improvement",
      "userFacing": true,
      "details": "The Welcome window is no longer a solid panel: the moving picture behind the app now shows through its body and button strip, much more blurred and darkened, with a faint grain, like frosted glass, so the window feels part of the scene while its text stays easy to read. The moving background around the windows is also a little brighter: it had been dimmed to a quarter of its brightness, and now shows at 40%. In the light theme the glass is a light frosted panel. Help > About keeps its solid look."
    },
    {
      "title": "The moving background is a little softer",
      "version": "2026.9.22",
      "date": "2026-10-06",
      "area": "UI",
      "type": "Improvement",
      "userFacing": true,
      "details": "Now that the moving picture sits behind the whole app, it is blurred a step more, so it reads as a soft backdrop around your windows rather than a picture competing with them. The shapes are still there; the cost of drawing it is unchanged."
    },
    {
      "title": "The moving background now sits behind the whole app, not just the welcome screen",
      "version": "2026.9.21",
      "date": "2026-10-06",
      "area": "UI",
      "type": "Improvement",
      "userFacing": true,
      "details": "The soft moving picture behind the welcome screen used to give way to a flat gradient as soon as you opened a song. It now stays for the whole session, so you see it around the sequencer when it isn't maximised, and between and around floating windows such as module panels. It costs nothing while windows cover it completely (as a maximised sequencer does): it simply stops drawing until some of it is visible again."
    },
    {
      "title": "The welcome screen's moving background is smoother, lighter, and now shows on Linux too",
      "version": "2026.9.20",
      "date": "2026-10-05",
      "area": "UI",
      "type": "Improvement",
      "userFacing": true,
      "details": "The soft, moving picture behind the welcome screen used to be a video, which only played on Windows (DirectX) and in the browser: on Linux, macOS and the OpenGL Windows build you got a flat gradient instead. It is now a set of pre-made frames, every frame of the original at its full frame rate, blended smoothly from one to the next. It looks the same everywhere, including Linux and macOS, and it costs less: it no longer keeps a video decoder running, it only redraws itself up to 15 times a second whatever your frame rate, it stops while the window is minimised, and it uses less memory. In the browser it loads in the background, so the page never waits for it."
    },
    {
      "title": "Freyja's banner fades out smoothly, and the mouse pointer's edges are no longer too dark",
      "version": "2026.9.19",
      "date": "2026-10-05",
      "area": "UI",
      "type": "Bug fix",
      "userFacing": true,
      "details": "Freyja's banner art and the app's own mouse pointers were stored in a form that the drawing code darkened a second time. Wherever they turn see-through, they came out darker than drawn: the right end of Freyja's banner faded into the background too soon (the trees and wolf on that side all but vanished), and every pointer's soft outline and shadow came out darker and thinner than designed. Both are now stored the standard way, and they draw as authored. Pictures you add yourself (module art, panel images) were never affected.",
      "tickets": [
        697
      ]
    },
    {
      "title": "Lines, wires, waveforms and curves are now smooth",
      "version": "2026.9.18",
      "date": "2026-10-05",
      "area": "UI",
      "type": "Improvement",
      "userFacing": true,
      "details": "Lines in the app had no antialiasing at all: module wires, automation lanes, scope, envelope, LFO and spectrum curves, waveforms and knob pointers were drawn as hard-edged pieces snapped to whole pixels, so diagonals and curves came out as staircases with a notch at every joint, and a see-through wire went darker wherever two pieces overlapped. Every line is now one smooth, joined stroke with a soft edge one screen pixel wide, at any display scaling; waveforms (in both drawing modes) have soft edges, and so do knob pointers and modulation arcs, the Pixel knob skin's pointer included. Lines that run exactly flat or upright are kept on the pixel grid so they stay sharp. It costs a few hundredths of a millisecond a frame."
    },
    {
      "title": "A beta you lose access to now offers to switch you back to the release",
      "version": "2026.9.17",
      "date": "2026-10-04",
      "area": "Desktop",
      "type": "Bug fix",
      "userFacing": true,
      "details": "Since you can update from a release onto a beta, losing beta access (a pledge ending, or two weeks without reaching ezmuze central) left you on a beta whose supporter screen offered only Sign in, Get beta access and Quit, so the only way back was to find the release installer on the website. Every version of that screen now also offers Switch to the release, which names the release, asks once (songs a beta saved in a newer format may not open in it), then downloads it and restarts into it in place. Your songs, settings and sign-in are kept. Offline, the button waits for a connection and the screen links to the release downloads; a copy that was not installed by the installer gets Download the release instead; and on beta.ezmuze.studio it is Go to the release, which takes the tab to ezmuze.studio. Under the hood the switch is a Velopack downgrade, and the beta's own package is removed first so Velopack cannot quietly reinstall the beta on the next launch.",
      "tickets": [
        693
      ]
    },
    {
      "title": "Freyja's bottom row is one tidy strip, and its LFO waves are named in full",
      "version": "2026.9.16",
      "date": "2026-10-04",
      "area": "Modules",
      "type": "Improvement",
      "userFacing": true,
      "details": "Freyja's panel had an empty band between the Performance and Warp row and the LFO Shape section, left for Click and Noise controls Freyja does not have. Performance, Warp and LFO Shape now sit side by side in one row, so the band is gone and the panel is 236 points shorter (1450 x 675, was 911). The LFO's Wave is a dropdown, like Warp's Mode, so Triangle, Square and Sample & hold are no longer clipped to Tria..., Squa... and Sam..., and the Sync switch lines up with it. It reaches installed copies of Freyja with its next store version."
    },
    {
      "title": "Freyja's panel wears its banner art across the top",
      "version": "2026.9.15",
      "date": "2026-10-04",
      "area": "Modules",
      "type": "Improvement",
      "userFacing": true,
      "details": "Freyja's text header (the name, a five-colour stripe and a subtitle) is replaced by the instrument's own banner art: the FREYJA wordmark, mountains, aurora and a rainbow waveform over a lake, across the panel's full width at its own proportions and fading out softly at the right. Everything below it moved down unchanged, so the panel is 161 points taller (1450 x 911). Like Bifrost's, the banner is built into the app, so the panel shows it whether or not the store package carries it. It reaches installed copies of Freyja with its next store version."
    },
    {
      "title": "Wavetables keep their own level on every note, and their top end above middle C",
      "version": "2026.9.14",
      "date": "2026-10-04",
      "area": "Sound engine",
      "type": "Bug fix",
      "userFacing": true,
      "details": "Wavetables now play at the level they are stored at on every note, and keep their top end above middle C. From middle C up the oscillator used to swap a table for a copy cut to its first 14 harmonics (6 from F#5) and re-normalised every frame to a 0.95 peak, so an imported Vital, Serum or Phase Plant table that was quieter than that jumped in level at C4 (+13.6 dB for one peaking at 0.2), an FM modulator's depth jumped with it, and every bright sound lost everything above its 14th harmonic from C4 up. Anti-aliasing is now one band-limited copy of the table per octave, each keeping every harmonic below 0.45 of the sample rate at the top of its octave and fading into the next over the last quarter of it, at the table's own level; the spectral morph band-limits the same way, so turning a morph on no longer changes the level either. Factory sounds above middle C get brighter and more even. Measured old against new for every shipped bank, the Grrowl pack, the demos and a 703-preset Vital corpus (AC RMS through a 20 Hz high-pass, on a chord and on notes 48, 60 and 72): 63 presets that one output level holds within half a dB of their old render are re-levelled with it: Bifrost (Clav, Delay Lead, Delay Pluck, Fold Lead, Ping Delay Stab, Prism, Squashed, Tri Rhodes); Grrowl pack (Answer Back, Robot Voice, Screech, Talk Box Neuro, Two Speed Growl); Bank Manager (Bass And Keys, Choir Wash, Growl Below, Octave Lead, Press For Bell); Bueller (Baroque, Bent Pad, Bowed Air, Clavinet, Combo Organ, Glass Pad, Phased Brass, Square Vibrato, Sync Scream, Tension, Trumpets, Violins); Freyja (Bell Keys, Bright Strings, Chorus Wash, Clav, Electric Keys, Harp, Hollow Bass, Hollow Pad, Pluck Bass, Poly Brass, Pulse Poly, Soft Lead, Stepped, Toy Piano); Kenobi (Analog Organ, Choir Pad, Pulse Pluck, Rubber Bass, Sample and Hold, Square Lead); Laufeyson (Harpsi, High Pass Stab, Metal Ring, Sample and Hold); Rat 030 (Clicky, Detuned Acid, Distorted Square, Low Drone, Square Acid, Square Echo, Square Slides, Square Sweep, Sub Square). Laufeyson's own default Output Level goes from 0.13 to 0.125, because its eight-note chord now reached full scale (Init is 0.34 dB quieter; Two Saws and Square and Sub keep 0.13). 57 more change only above middle C, where they now have more top end, and are left as they are. Twelve change character and are deliberately left as the fix makes them: Bank Manager Vowel Lead, High Shimmer, Grand And Air, Choir And Glass, Three Zones and Bell Cloud; Bifrost Music Box, Vowel Cloud, Metal Pluck and Broken Radio; Grrowl Growly Vowel and Bell Screech. Their old sound above middle C came from the re-normalise lifting what the 14-harmonic cut left; Vowel Lead, for one, used to drop 10 dB at C4 and now rises through it smoothly. Imported vocal and speech tables whose energy sits above the 32nd harmonic get quieter high up, as they would in Vital: past C5 that content is above Nyquist. The demo songs are unchanged within 0.03 dB. Imported tables take about 3.4 times their own size for the mips (2 times before); the factory tables take less than before.",
      "tickets": [
        688
      ]
    },
    {
      "title": "Check for updates offers the newest beta too when you have beta access, clearly marked as a beta",
      "version": "2026.9.13",
      "date": "2026-10-03",
      "area": "Desktop",
      "type": "Bug fix",
      "userFacing": true,
      "details": "The update check only ever looked at the channel the running build came from, so a release build never mentioned betas even to someone signed in with beta access. Now, when your ezmuze central account has beta access (checked with central, or a recent cached yes while offline), the check looks at both channels and offers whichever is newer, the latest beta or the latest release. A beta offer says so everywhere it appears: the dialog is titled Beta update available, the version reads e.g. 2026.9.12 (beta), a line says betas get new features first and may be less stable, and the button reads Update to beta or Download beta. The welcome-screen banner does the same. Signed out, or without beta access, you see releases exactly as before. Beta builds are now offered a newer release as well as the next beta. The same version on both channels is offered as the release, nothing older than what you run is ever offered, and the check runs again when you sign in or out. Which versions exist is now read from each channel's update feed, because no change-log entry carries a release's own number. Desktop only: the browser build always runs whatever its site serves, ezmuze.studio for releases and beta.ezmuze.studio for betas.",
      "tickets": [
        692
      ]
    },
    {
      "title": "A file dropped on a window goes to that window, not to the song behind it",
      "version": "2026.9.13",
      "date": "2026-10-03",
      "area": "UI",
      "type": "Bug fix",
      "userFacing": true,
      "details": "Dropping a sample on an open module panel, the preset browser or any other window over the sequencer used to put it on the timeline hidden underneath, as a new Audio channel and a clip. Now each window says which files it takes, and a drop goes to the window drawn on top where you let go. The sequencer places audio where it lands on the timeline and imports MIDI, SoundFonts, Vital banks, module bundles, packages and songs from other programs; the pattern explorer imports into the wave bank; the wave and sample pickers add audio to their bank; the Lua component editor takes any file; Module properties and Publish module take an image as the art; the UI editor takes images into the image library; the welcome screen opens songs from other programs. A window that cannot use the file says so: \"Chip - Chip doesn't know what to do with .wav files.\" Dropping several files at once names every type the window refused in one message. On macOS and Linux, which do not report where a drop lands, the mouse position at the drop is used, so a sample let go over the timeline is placed there too; the browser build sends the drop's own position. Built on a new GustUI drop-target registration (FileDropTrait and FileDropRouter).",
      "tickets": [
        683
      ]
    },
    {
      "title": "CI: the welcome video's content manifest regenerated, and a flaky voice-tap test fixed",
      "version": "2026.9.12",
      "date": "2026-10-03",
      "area": "Build",
      "type": "Internal",
      "userFacing": false,
      "details": "The welcome-video change hand-edited the 1.mp4 hash in place, but content.manifest is sorted by hash, so ContentFreshnessTests read it as stale on both the web and DX heads; it is now regenerated with tools/UpdateContentManifest. FollowNewestVoiceTests' component-tap test rendered a whole bar in one span after asking for the tap once. A tap nobody has read for a second stops being fed, and on a busy CI runner that render took longer than a second, so the feed stopped part way through and the 'latest' window came from wherever it stopped (with the test host starved onto one core it failed 6 runs of 6, the tap dropped before its first sample). The test now renders in 50 ms spans and asks for the tap between them, as an open display does every frame: 8 of 8 pass under the same starvation. The app was never affected; a display reads its tap every frame."
    },
    {
      "title": "The welcome screen's background video is a fraction of the size, with the same soft look",
      "version": "2026.9.12",
      "date": "2026-10-03",
      "area": "UI",
      "type": "Improvement",
      "userFacing": true,
      "details": "The out-of-focus concert video behind the welcome screen was a 3.5 MB 720p clip. It is now a 256x144 clip of 148 KB, so the download is smaller and each frame takes far less memory and work to decode and put on screen. Stretched over a whole window, a clip that small would show its pixels as a blocky grid, so it is drawn through the same blur as the backdrop behind a waiting dialog: the frame is stretched into a picture a quarter of the window's size, blurred there, and that is what fills the window. It looks as soft as it did before. The blur runs only while the video is on screen, and on a 4K window the welcome screen draws no slower than it did."
    },
    {
      "title": "The preset browser has an Up button and a search box, and its lists fill the window",
      "version": "2026.9.12",
      "date": "2026-10-03",
      "area": "UI",
      "type": "Improvement",
      "userFacing": true,
      "details": "The \"..\" row at the top of a folder was easy to miss, so it has gone. To go back up, use the Up button at the start of the toolbar, as in the file browser, or press Alt+Up. Up is greyed out when you are already at the top. A new search box in the toolbar filters as you type and lists every match from every folder at once. It matches a preset's name, the folder it is filed in and who made it, and each result shows its folder beside its name. While you search, the folder list is dimmed. Press Escape or clear the box to return to the folder you were in. Select all, Rename and Delete act on the results you can see. The folder and preset lists now reach the bottom and right edges of the window rather than stopping short. You can also drag the browser to any size and maximise it, and the lists grow with it. Long lists and searches across big libraries stay quick because only the rows on screen are built.",
      "tickets": [
        689
      ]
    },
    {
      "title": "Control API: author_presets and delete_preset, for republishing a store module's presets",
      "version": "2026.9.12",
      "date": "2026-10-03",
      "area": "Modules",
      "type": "Internal",
      "userFacing": false,
      "details": "A module that left the app for the store is never seeded again, so a preset re-voiced in code (Bifrost's Twin Peak, Rat 030's re-levelled and re-voiced squares) had no way into the author's library, which is what the publish wizard ships as the Factory bank. author_presets {module, dryRun} writes the repo's bank (StoreModuleSources now carries each store module's preset builder) into the library and reports what it adds, what it changes, what differs only in a selector's stored position count (the warp selectors grew from 5 to 11, and applying a preset writes that count, so a bank left at 5 shrinks the dropdown) and what the library has that the repo does not; it never deletes. delete_preset {id} removes one library preset, because a stray test preset for a store module ships in its next version. StoreModuleSourcesTests holds every bank to its own module's id."
    },
    {
      "title": "Bend, Sync, Fold and Mirror warps are now centred too",
      "version": "2026.9.11",
      "date": "2026-10-03",
      "area": "Sound engine",
      "type": "Bug fix",
      "userFacing": true,
      "details": "The four older warp modes could leave an oscillator's wave sitting above or below zero, just as the six new ones did before the last beta: Bend, for instance, dwells at the start of the table. That offset takes up headroom and thumps when notes start and stop. Every warp mode now has it taken back out, and the level kept within the table's own is worked out from the wave actually played, so a centred wave is not turned down for no reason. A warp at amount 0 sounds exactly as before. A few presets were adjusted so they sound as they did: Bifrost's Twin Peak was re-voiced, and Grrowl's Dominator and Triplet Growl and Rat 030's Bent Square were re-levelled. Your own sounds that use Bend, Sync, Fold or Mirror may come out slightly quieter (or, where the offset was pushing a drive, slightly louder), usually by well under half a decibel.",
      "tickets": [
        686
      ]
    },
    {
      "title": "The six new warp modes no longer push the sound off centre",
      "version": "2026.9.10",
      "date": "2026-10-03",
      "area": "Sound engine",
      "type": "Bug fix",
      "userFacing": true,
      "details": "Squeeze, Formant, Quantize, Pulse, Asym and Flip could leave an oscillator's wave sitting above or below zero: Pulse, for instance, holds the start of the table for most of each cycle, and Flip turns part of it over. That offset is inaudible on its own but takes up headroom, so a chord could clip sooner, and it made a thump when notes started and stopped. The oscillator now takes the offset back out and keeps the result within the table's own level, and the wavetable display draws the centred wave. Bend, Sync, Fold and Mirror are unchanged for now, so existing sounds that use them play exactly as before.",
      "tickets": [
        686
      ]
    },
    {
      "title": "Vital, Phase Plant and Serum imports now carry FM, ring mod, the new warp modes, spectral morph and filter routing onto Bifrost",
      "version": "2026.9.10",
      "date": "2026-10-03",
      "area": "Modules",
      "type": "Improvement",
      "userFacing": true,
      "details": "Importing a Vital bank, or a REAPER project with Vital, Phase Plant or Serum on a channel, now uses what Bifrost v8 added instead of leaving it out. Vital: oscillators that FM or ring-modulate each other land on Bifrost's FM and RM dials, with the oscillators reordered when the modulation runs backwards (Bifrost's B hears A, and C hears A or B); its Sync, Formant, Quantize, Bend, Squeeze and Pulse distortions become the matching warp modes; ten of its seventeen spectral morphs become Bifrost's morph, amount included; each oscillator and the sample go to Filter 1, Filter 2, both or around the filters as they did in Vital, and the two filters run in series or in parallel as Vital had them. Modulation routed onto a morph, warp or FM amount now carries too. Phase Plant: a generator modulating another at audio rate becomes FM (phase, frequency or pitch) or ring mod (level), and a silent modulator gets its own oscillator at level 0; generators, noise and the sampler reaching only a second filter go to Filter 2, in parallel when that filter sits on its own path. Serum: FM, RM and AM from the other oscillator become Bifrost's links (swapping A and B when A is the one modulated), and Asym, Flip, PWM, Quantize and the sync-window modes map to their warp modes. FM depth was measured against Vital and Phase Plant, and Bifrost's FM dial now reaches Vital's deepest FM at full scale. The import notes say plainly what is still approximated or left out: Vital's Vocode, Spectral Time Skew and other morphs Bifrost lacks, FM or ring mod from Vital's sample, Direct Out, and Serum's remap modes."
    },
    {
      "title": "LFO, envelope and modulation displays follow the note you just played, including from the keyboard",
      "version": "2026.9.10",
      "date": "2026-10-03",
      "area": "UI",
      "type": "Bug fix",
      "userFacing": true,
      "details": "When you played an instrument live from the computer keyboard or a MIDI keyboard, with the song stopped, the LFO cards on its modulation rail showed no moving playhead, and knobs an LFO or envelope was routed to showed no movement, although you could hear the modulation: live playing runs the instrument on its own, and those displays only ever asked the song's playback. They now follow whatever is sounding. And with several notes sounding, every one of those displays, and a scope on a part inside the instrument, now shows the note you played most recently. Before, they showed whichever note happened to be on the first voice, often an older or already released one. Letting go of a key does not make them jump back to an older note. The envelope graph's live dot also works on instruments whose envelope is set by knobs, such as Bifrost, where it never appeared.",
      "tickets": [
        685
      ]
    },
    {
      "title": "Bifrost v8: oscillators that modulate each other, six new warp modes, spectral morph, parallel filters and a live scope",
      "version": "2026.9.10",
      "date": "2026-10-03",
      "area": "Modules",
      "type": "New feature",
      "userFacing": true,
      "details": "Bifrost's three oscillators can now play off each other: Osc B takes FM, ring mod and hard sync from Osc A, and Osc C takes them from A or B. Each oscillator gains a spectral Morph with nine types (Low Pass, High Pass, Harmonic Stretch, Inharmonic Stretch, Formant Shift, Smear, Random Amp, Phase Disperse, Shepard) and its own amount, which you can modulate like any knob, and the wavetable display draws the result. Warp is now a menu of eleven modes, six of them new: Squeeze, Formant, Quantize, Pulse, Asym and Flip. Every source (A, B, C, Sub, Noise, Sample) can go to Filter 1, Filter 2 or straight out, and Filter Routing runs Filter 2 after Filter 1 (Serial) or beside it (Parallel). A new Output section shows a scope and a spectrum of everything Bifrost is playing, the whole chord, live whenever the panel is open. All of it rests at off, so every existing Bifrost sound is unchanged. The warp modes, ring mod, sync and morph live in the shared wavetable oscillator, so other synths built on it can offer them too. Also fixed: a panel switch bound to a value that is not itself an on/off switch read as on in both positions and wrote a huge number when clicked."
    },
    {
      "title": "Chip, Kenobi, Bueller, Freyja, Rat 030 and LadderOne get six new warp modes",
      "version": "2026.9.10",
      "date": "2026-10-03",
      "area": "Modules",
      "type": "Improvement",
      "userFacing": true,
      "details": "The Warp section on these six synths now offers all eleven of the oscillator's warp modes: Squeeze, Formant, Quantize, Pulse, Asym and Flip join Off, Bend, Sync, Fold and Mirror. Its Mode control is now a dropdown in the same place, because eleven names do not fit a row of buttons. Existing songs and presets sound exactly as before. Chip has the change as soon as you update the app. Kenobi, Bueller, Freyja, Rat 030 and LadderOne come from the store, so their dropdown arrives with their next store version; until then their old row of buttons already reaches all eleven modes, squeezed into narrow buttons. Laufeyson has no Warp section (its Sync dial owns the oscillator's warp) and is unchanged."
    },
    {
      "title": "The wave picker takes MP3, Ogg, FLAC and AIFF, has a Load file button, and no longer freezes on a long file",
      "version": "2026.9.9",
      "date": "2026-10-03",
      "area": "UI",
      "type": "Improvement",
      "userFacing": true,
      "details": "The wave picker now accepts MP3, Ogg, FLAC and AIFF files as well as WAV, dropped onto it or chosen with a new Load file button. A file dropped onto an open picker now goes into the picker; before, it fell through to the song behind it and made a new audio channel. Long files are decoded in the background on desktop, so a four-minute MP3 no longer freezes the app for two seconds (on the web the decode still runs on the page's only thread). Long files dropped on the timeline decode in the background too. Also fixed: writing a preset pack dropped every preset's attached sample and imported wavetable, so a pack of converted Vital patches came out without their samples."
    },
    {
      "title": "Bifrost v8 panel, banner and sample layer built in the app, ahead of its store release",
      "version": "2026.9.9",
      "date": "2026-10-03",
      "area": "Modules",
      "type": "Internal",
      "userFacing": false,
      "details": "Built in the repo, not yet published to the store (v8 will be a breaking version and is getting more before it goes out): the three oscillators stacked down the left with the filters, envelopes, tail, macros and Performance strip beside them; the product banner across the top (BifrostBanner.png, compiled in as a panel image); a Sample section under Osc C that is always shown, picking from the wave bank and copying the sample into the Sample Player so presets and packs carry it; the Sample Player in the code graph for every Bifrost, with older devices upgraded on pick, song load or a preset that carries a sample. New remote-control action author_graph puts the code graph onto the installed module for publishing. The panel needs an app that has the sample picker, which is why this lands in a beta first. The user-facing entry, closing #660, comes with the v8 publish."
    },
    {
      "title": "The store says why Get did not work, and offers to sign you in again when that is the reason",
      "version": "2026.9.8",
      "date": "2026-10-02",
      "area": "UI",
      "type": "Bug fix",
      "userFacing": true,
      "details": "When ezmuze central stopped accepting your sign-in (it expired, or newer sign-ins elsewhere pushed it out), pressing Get in the store looked like nothing: a loading line flashed, the same Get button came back, and the reason had faded away in the status bar behind the store window. That is what made Keyzoo 20/20 seem impossible to install. A failed Get now stays on the shelf in red, with a toast that waits to be dismissed. When the cause is the sign-in, a Sign in again button sits under the message and reloads the store once you are back in. The shelf says the same as soon as it opens, because an expired sign-in also makes the modules you own show Get. Every failure is logged with its cause.",
      "tickets": [
        680
      ]
    },
    {
      "title": "Long tooltips wrap to a readable width and always stay on screen",
      "version": "2026.9.8",
      "date": "2026-10-02",
      "area": "UI",
      "type": "Bug fix",
      "userFacing": true,
      "details": "Tooltips drew every line at whatever width it came to, so a store module tile's tooltip, which carries the whole description, ran as one line right across a 4K screen and off the edge. Every tooltip now wraps at about 400 px (never more than 40% of the window), keeps its own line breaks, flips to the left of or above the pointer at the window's edges and stays wholly on screen; anything taller than half the window ends in '...'. Short tooltips look exactly as before. Also fixed: moving the pointer straight from one element with a tooltip to another (adjacent tiles or toolbar buttons) no longer leaves the second one without its tooltip. The rules are in GustUI's TooltipLayout and design-guide.md.",
      "tickets": [
        681
      ]
    },
    {
      "title": "Re-importing your own store module's source now shows and publishes its new panel",
      "version": "2026.9.8",
      "date": "2026-10-02",
      "area": "Modules",
      "type": "Bug fix",
      "userFacing": true,
      "details": "Importing an .ezmodsrc stored its panel as not edited here, and an installed store package's panel wins over any panel that is not a person's edit. So when the author of a store module re-imported its source with a new panel, both the panel shown and the one the publish wizard bundled were still the installed package's, which is how Keyzoo 20/20 v3 went out with its old panel. An import by the module's author (the store's signed author grant on an installed copy, InstalledModules.IsAuthorOf) now marks the panel as their edit, so it beats the package's. Anyone else's import of a bundle still arrives as a plain seed that the installed package's panel outranks.",
      "tickets": [
        675
      ]
    },
    {
      "title": "Applying a preset no longer wipes a module's own imported wavetable or Lua file",
      "version": "2026.9.8",
      "date": "2026-10-02",
      "area": "Modules",
      "type": "Bug fix",
      "userFacing": true,
      "details": "A preset that carried no component state for a slot (one that only sets values, written outside the app or saved before a table was imported) cleared that slot's imported wavetable and Lua file. For a user module whose table is part of its own definition, every such preset turned the oscillator back into the built-in sine. Applying a preset now restores a slot the preset says nothing about to the module definition's own table and script (PresetMath.DefinitionComponentStates / RestoreDefinitionState), in the preset browser, the module editor, the store preview render and a store device's saved preset. A device with no definition behind it (a factory module) is still cleared, as before, and a preset that carries its own state still wins. Undo restores exactly what the device had before.",
      "tickets": [
        674
      ]
    },
    {
      "title": "The publish wizard's preview no longer comes out silent for a module that nests another one",
      "version": "2026.9.8",
      "date": "2026-10-02",
      "area": "Modules",
      "type": "Bug fix",
      "userFacing": true,
      "details": "Render preview built its list of module definitions from the module itself and the open song. A module that keeps its sound in a nested definition (Keyzoo 20/20's oscillators are in its nested Waves module) therefore rendered silence unless a channel holding it happened to be in the open song, and the wizard said there was nothing to upload. The preview now gets the module's nested definitions as well: the ones gathered for the bundle it is about to publish, or for an installed module, the closure from the library and installed packages (ModulePreviewRender.Render's nested list). Keyzoo 20/20 in an empty song now previews at the same level it does from a channel (raw peak 0.49).",
      "tickets": [
        673
      ]
    },
    {
      "title": "PXL4 from the store plays its 8 voices again, instead of arriving as a monosynth",
      "version": "2026.9.8",
      "date": "2026-10-02",
      "area": "Modules",
      "type": "Bug fix",
      "userFacing": true,
      "details": "PXL4's first version was published from an instance before a module's definition carried its voice count, so every version since says 1 voice in its definition and 8 only in its package manifest. Opening an installed package took the definition's number, so every PXL4 device made from it, the author's and a buyer's, had 1 voice, and the publish wizard's Graph check read '1 voice' and could call a panel-only version breaking. Opening an installed package now reconciles the two the way reading a bundle file always did (ModuleBundle.TakeManifestVoices): a definition that says mono takes the manifest's count when the manifest says more. The publish wizard's voice count also ranks an instance that was made from the installed package below the package itself, since it can only echo the package's definition (ModulePublish.PickVoices). An instance made from your own copy of a module still wins, as before.",
      "tickets": [
        671
      ]
    },
    {
      "title": "The publish wizard refuses a module whose own sample files are missing from the package",
      "version": "2026.9.8",
      "date": "2026-10-02",
      "area": "Services",
      "type": "Bug fix",
      "userFacing": true,
      "details": "A module that carries its own samples could be published without them: Keyzoo 20/20 v2 went to the store as 6.5 KB, its twelve sample players pointing at files the package did not have, and every note was silent. Neither the wizard nor the store noticed, although the app itself refused to import those same bytes. The wizard now checks the exact package it is about to upload: every embedded file a module uses must be its own, every file it lists must be in the package, and every file it has must have its data. Anything missing is a refusal on a new 'Embedded files' row, naming the file. The store makes the same check. A new version that ships no presets where the version before shipped some now says so plainly on the Factory bank row, as a warning.",
      "tickets": [
        672
      ]
    },
    {
      "title": "Rule: a song-format bump needs a row in the store's format table",
      "version": "2026.9.8",
      "date": "2026-10-02",
      "area": "Services",
      "type": "Internal",
      "userFacing": false,
      "details": "The store works out which studio a module needs (the 'needs ezmuze studio X' badge) from ModuleFormat.FirstReadBy in ezmuze-central, a table of each song format and the first beta that read it. It stopped at v37 while the studio wrote v40, so every module version uploaded in v38-v40 got no floor. Central now has the rows (v38 2026.8.281, v39 2026.8.360, v40 2026.8.364), a backfill for the versions stored without one, and a deploy-time test that fails while the current format has no row. Song.CurrentFormatVersion and docs/release-pipeline.md now state the rule.",
      "tickets": [
        670
      ]
    },
    {
      "title": "Keyboard jamming has an on/off switch (Ctrl+J), so letter shortcuts and played notes no longer collide",
      "version": "2026.9.7",
      "date": "2026-10-01",
      "area": "UI",
      "type": "New feature",
      "userFacing": true,
      "details": "A new keyboard button on the toolbar, after Record, switches keyboard jamming (playing notes on the letter keys) on and off; Ctrl+J and Edit > Turn keyboard jamming off/on do the same, and the status bar says which. It is on to begin with and remembered. While it is on, the letters play notes from an open instrument panel, the piano roll or the module builder as before, and the letter shortcuts that share those keys (S split, M marker, Shift+M section, L loop the section, M add a cue in the Clip editor, S snap in the drum lanes) stand aside: pressing one plays its note and the status bar says how to turn jamming off, instead of the note and the edit both happening. Their menu items and buttons always work. While it is off, no letter plays a note and those shortcuts work. MIDI keyboards are not affected.",
      "tickets": [
        679
      ]
    },
    {
      "title": "Record is now Ctrl+R, so R plays a note when you jam on the keyboard",
      "version": "2026.9.7",
      "date": "2026-10-01",
      "area": "Sequencer",
      "type": "Bug fix",
      "userFacing": true,
      "details": "Record was a bare R, and R is also a note (F) on the QWERTY musical-typing keyboard. An open instrument panel plays that keyboard even while the sequencer has the keys, which it does as soon as you click a channel's arm dot, so playing a riff with an F in it started or punched out a recording. Record is now Ctrl+R (REAPER's key) in the sequencer, its tooltip and the help screen; holding Ctrl keeps the R from playing a note. In the browser, Ctrl+R no longer reloads the page.",
      "tickets": [
        678
      ]
    },
    {
      "title": "Sequence melody: turn a melodic loop into a played part in the Clip editor, seeing and editing its notes first",
      "version": "2026.9.7",
      "date": "2026-10-01",
      "area": "UI",
      "type": "New feature",
      "userFacing": true,
      "details": "The wave bank's Convert to instrument has moved into the Clip editor, beside Drum chop, as Sequence melody (the keyboard button, the Sequence melody menu, or 'Sequence the melody…' on a sample clip's menu). It shows the notes it would play the loop as, over the waveform: each note's start, its end and its name. Drag a start or an end, click the lane to add a note, drag one off the lane to remove it, and right-click a note to move it up or down a semitone or an octave. Sliders tune the detection (Sensitivity, Confidence and Off-grid for struck notes; Min note and Pitch hold for held ones; and which reader to trust), Play melody plays what you will get, and Make melody makes one sample on a key-tracking voice and a pattern that plays it where the clip did, on a new channel below or into a channel an earlier conversion made, replacing the clip in one undo step. The notes and settings last only while the mode is on. Convert to instrument in the wave bank is now a shortcut into it. Under the hood the melodic decomposer's swept constants became settings (the defaults unchanged), detection runs off the game thread with every answer cached by its settings, and the notes are laid out by the clip's own pieces, so loop passes, trims and stretches are followed.",
      "tickets": [
        677
      ]
    },
    {
      "title": "Ten store instruments' panels: values and labels no longer run into each other",
      "version": "2026.9.6",
      "date": "2026-10-01",
      "area": "Modules",
      "type": "Bug fix",
      "userFacing": true,
      "details": "Modyssey, Bank Manager, Laufeyson, Kenobi, Cloudform, Freyja, PXL4, Bueller, Rat 030 and Rhodine each have a new compatible version in the store whose panel fits its words: envelope readouts such as '600 ms' sat in knobs narrower than the text and ran into the next one, Bank Manager's key ranges ('F#-1+35c') and captions ('HIGH KEY') overflowed, selector rows drew their buttons over their captions, and PXL4's SID knobs could not hold '2.58 kHz'. Knobs were widened and rows rebalanced (Bank Manager, Laufeyson and PXL4 grew a little wider); no text got smaller. Songs on 'latest compatible' pick the new panels up on their own. Every store panel is now held to PanelTextFitTests.",
      "tickets": [
        669
      ]
    },
    {
      "title": "Bifrost's panel breathes: macros beside the Tail, and the envelope and Performance knobs no longer overlap",
      "version": "2026.9.5",
      "date": "2026-10-01",
      "area": "Modules",
      "type": "Improvement",
      "userFacing": true,
      "details": "Bifrost v7 in the store (a compatible version, so songs pick it up on their own). The Modulation list beside the Tail is gone: the mod rail along the bottom lists every routing, with its depth and a delete, so it showed nothing the rail does not. The four macros took its place, as large knobs, which freed their strip for Performance, Click, Noise and LFO shape, now spread across the full width; and the envelope column grew, with A/D/S/R knobs as tall as their graphs. Before, the envelope knobs (60 wide on a 50 px pitch) and the strip's knobs (92 wide on 84) overlapped, so readouts ran together ('0.17 ms767 ms'). Bifrost is back in the panel geometry test. In the app, panels gained 'shown while a component exists' (an empty ShownWhenParam), and Bifrost's panel has a Sample section built on it for patches imported with a sample layer (#660); it is held back from the published panel (BifrostUi.SampleLayerShips) until an app that hides it on a plain Bifrost has shipped, because today's apps would show it as five dead controls.",
      "tickets": [
        661
      ]
    },
    {
      "title": "LadderOne's Osc 2 wave selector shows Saw, Pulse and Sub",
      "version": "2026.9.5",
      "date": "2026-10-01",
      "area": "Modules",
      "type": "Bug fix",
      "userFacing": true,
      "details": "The Osc 2 wave selector on LadderOne's panel drew a single '-' cell, so the second oscillator's waveform could not be picked from the panel. The selector drives a three-way switch whose input is a plain number, so it had no names to show; it now carries its own (Saw, Pulse, Sub, in the switch's order). Arrives with LadderOne v4 in the store. For the store version, author_face now also gives a store module's selectors the position names its code graph has and the installed copy lacks (StoreModuleSources.CopyChoiceNames).",
      "tickets": [
        665
      ]
    },
    {
      "title": "LadderOne's envelope values and Warp mode no longer draw over each other",
      "version": "2026.9.5",
      "date": "2026-10-01",
      "area": "Modules",
      "type": "Bug fix",
      "userFacing": true,
      "details": "LadderOne's envelope readouts ran together ('1 ms350 ms30%300 ms') because each value was wider than its knob, and the Warp MODE caption sat on its Off/Bend/Sync/Fold/Mirror buttons. The envelope column is 50 px wider with knobs on a 52 px pitch; the strip's segmented selectors (Warp Mode, Noise Type, LFO Wave, shared by the synth family) no longer reserve a value row, so their cells clear their captions; 'Osc2 oct/st', which was cut short, reads 'Osc2 tune'. Arrives with LadderOne v4. A new test, PanelTextFitTests, measures the text a panel draws (every value readout across its knob's travel, every caption) in the panel's own font against its control; store panels are held to it, and the ones that do not pass yet are listed against #669.",
      "tickets": [
        668
      ]
    },
    {
      "title": "Modules with an imported SoundFont can be published: give the font a licence from its SoundFont player",
      "version": "2026.9.5",
      "date": "2026-10-01",
      "area": "Modules",
      "type": "Bug fix",
      "userFacing": true,
      "details": "A module whose SoundFont player played an imported .sf2 could never be published: the store said the font carried no data, and refused its credit whatever its licence was. Two causes. The bundle writer moves a font's bytes out of the module's definition into a separate file, and the store's check only looked in the definition; and the credit the app wrote for a font was a usage-only one the store does not accept. Now an imported font has a licence of its own: in the module editor, right-click the SoundFont player and choose Credit and licence of \"<font>\"..., the same dialog embedded files use (CC0, CC BY, CC BY-SA or your own recording publish; Not set does not). The bundle's credit is built from it, and the publish wizard's Embedded audio check (the store's own rule, run over the package it is about to upload) passes for a licensed font, refuses an unlicensed one by name, and now says where to set the licence. Needs the matching store update, which ships first.",
      "tickets": [
        666
      ]
    },
    {
      "title": "The remote-control API answers at localhost on Linux and Mac, not only at 127.0.0.1",
      "version": "2026.9.5",
      "date": "2026-10-01",
      "area": "Desktop",
      "type": "Bug fix",
      "userFacing": true,
      "details": "On the Linux and Mac builds every request to http://localhost:<port>/ answered 404 while http://127.0.0.1:<port>/ worked. Off Windows, .NET's HttpListener matches a request to a registered prefix by the host name in its Host header, and only http://127.0.0.1:<port>/ was registered, so the request never reached the app. Those builds now also register http://localhost:<port>/. The listener binds a named prefix to the first address the system resolves it to (::1 on a stock /etc/hosts, which is also where a client resolving localhost goes first), so it stays loopback only; a client that forces IPv4 while sending Host: localhost still gets the 404, and 127.0.0.1 keeps working everywhere. If the IPv6 bind fails the app falls back to 127.0.0.1 alone. Windows is unchanged. Verified on the Linux build under Docker: 127.0.0.1 and localhost both answer, and a request with any other Host is still refused.",
      "tickets": [
        667
      ]
    },
    {
      "title": "Choosing a window from View that a pinned window covers now says why it stayed behind",
      "version": "2026.9.4",
      "date": "2026-10-01",
      "area": "UI",
      "type": "Improvement",
      "userFacing": true,
      "details": "With the sequencer pinned to front, choosing a module panel or the preset browser from View left it behind the sequencer, and nothing said why: it looked as if View did nothing. A pinned window staying on top is the rule (the owner's call: pinned stays on top), so the window still stays behind it, but the status bar now says so, for example 'Chip - Chip is behind the pinned Sequencer - ...: unpin it to bring Chip - Chip forward', and the pinned window's pin button flashes three times so it can be found. It is said when a front-pinned window covers at least half of the chosen one; a pinned palette clipping a corner says nothing. In GustUI: ModalWindowElement.BehindPinnedNotice, CoveringPinned and FlashPin.",
      "tickets": [
        647
      ]
    },
    {
      "title": "Chip's Stack card shows its main sound controls instead of about 76 knobs",
      "version": "2026.9.4",
      "date": "2026-10-01",
      "area": "Modules",
      "type": "Improvement",
      "userFacing": true,
      "details": "Chip marked none of its controls In Panel, so its Stack card showed every input nothing drove: about 76 knobs, most of them internal plumbing such as table constants, envelope taps and unison spreads. Chip now has a face like the store synths: the two oscillator waves, pulse width, Osc2 detune and level, ring mod, noise level and crush, the filter's cutoff, resonance and envelope amount, and the amp envelope (15 controls, plus the card's own Voices). Everything else is still on its panel and in Advanced view. A Chip added from now on gets it; a Chip already in a song picks it up only if its card was never customised. The face is display only: no sound changes, and the demo parity tests still pass.",
      "tickets": [
        663
      ]
    },
    {
      "title": "Opening another song closes the wave picker, the preset browser and the other windows of the old one",
      "version": "2026.9.4",
      "date": "2026-10-01",
      "area": "UI",
      "type": "Bug fix",
      "userFacing": true,
      "details": "Opening a different song closed the old song's editors but not every window that belonged to it. A wave picker opened from a module's Choose sample... stayed in the app behind the new sequencer, still bound to the old song, and took every file dropped on the app: a .mid dropped on the Pattern explorer failed with 'Not a RIFF/WAVE file' (#662). A module's preset browser stayed open, and listed under View, for a device the new song did not have (#664). Opening a song and closing the sequencer now share one list of the song's windows (EzmuzeStudioGame.CloseSongWindows): the editors as before, plus the wave picker, the content pack picker, the preset browser, a module's Properties, the wavetable designer, the Lua component editor, the loudness report, the sample recorder, Song settings and the Quantize/Swing/Humanize dialog, each of which holds the old song, one of its devices or one of its patterns.",
      "tickets": [
        662,
        664
      ]
    },
    {
      "title": "Undoing Convert to instrument into an existing channel puts that channel back exactly, in one Ctrl+Z",
      "version": "2026.9.4",
      "date": "2026-10-01",
      "area": "Sequencer",
      "type": "Bug fix",
      "userFacing": true,
      "details": "Converting a melodic loop into a channel an earlier conversion made, then pressing Ctrl+Z, took two presses and still left the channel broken: its voice pointed at a wave bank slot that no longer existed and had lost its old root note, so it went silent. The conversion changed the voice's sample, root, fit and one-shot settings directly, outside the undo history, and recorded the new wave, pattern and clips as separate steps. Planning now changes nothing: the voice's new settings come back as ordinary undoable parameter edits, and the whole conversion (the new wave, the new channel or those settings, the pattern and every clip) is one edit, so one Ctrl+Z restores the song exactly and Ctrl+Y puts the conversion back. MelodicLoopConversion.Plan.ToEdit; a new test converts, undoes and compares the saved song with the original.",
      "tickets": [
        659
      ]
    },
    {
      "title": "Notes you play sound sooner on the Windows OpenGL, Mac and Linux versions",
      "version": "2026.9.3",
      "date": "2026-10-01",
      "area": "Sound engine",
      "type": "Improvement",
      "userFacing": true,
      "details": "A key on a MIDI keyboard or the QWERTY layout, or a click in the piano roll, now sounds about 20-30 ms later on the OpenGL builds instead of about 125 ms, as it already did on the DirectX build: the live audition plays 10 ms chunks there too, and a session that ever runs dry still queues one more chunk rather than clicking. It needed a fix in the audio backend, whose count of queued buffers only caught up once a frame: our KNI fork now counts them as they finish. To get it, the Windows OpenGL and Linux builds moved onto the KNI fork the DirectX and Mac builds already used, instead of the stock KNI package; the same SDL2 and OpenAL libraries ship, from the fork, and drawing is unchanged. Measured on Windows OpenGL: 134 ms before, about 20 ms after, no dropouts in 720 notes; Linux (headless): 124 ms before, 27 ms after.",
      "tickets": [
        613
      ]
    },
    {
      "title": "Kenobi, Bueller and LadderOne panels show the LFO and filter controls they were missing, and Plucktopus's card knobs work",
      "version": "2026.9.2",
      "date": "2026-10-01",
      "area": "Modules",
      "type": "Bug fix",
      "userFacing": true,
      "details": "Store updates, not an app change: Kenobi v3 (LFO Sync and LFO Division on the Modulation section), Bueller v3 (the vibrato LFO's Wave, Sync and Division), LadderOne v3 (Filter Slope under Kind) and Plucktopus v4 (its Stack card's pick knobs are now Click Level and Click Tone, the dials that actually drive the pick; the old Pick Level/Pick Tone pair did nothing). All four are compatible versions, so songs on 'latest compatible' pick them up without asking. In the repo, the panels are authored in KenobiUi/BuellerUi/LadderOneUi (LadderOne joined StoreModuleSources so author_panel can reach it), and a new StoreModuleSourcesTests pair fails any store panel that leaves a wired control off or shows one wired to nothing; the store panels are also held to FactoryPanelLayoutTests' geometry now. Cloudform's and Laufeyson's published panels were checked against the source and already carried every control. Bifrost's sample layer (#660) and its panel overlaps (#661) are known gaps the tests list.",
      "tickets": [
        631
      ]
    },
    {
      "title": "The Grrowl preset pack is made for the current Bifrost, so its store page no longer says it was made for an earlier version",
      "version": "2026.9.2",
      "date": "2026-10-01",
      "area": "Modules",
      "type": "Bug fix",
      "userFacing": true,
      "details": "Grrowl v2 in the store: the fifty basses checked against Bifrost v3 and later (whose oscillator routing changed the interface in v3) and published against that interface, with the levels retuned when Bifrost's missing wavetables came back. In the app, publish_preset_pack takes 'flc' to publish a new VERSION of a pack rather than a second product (the wizard keeps the listing's name and description, which a version would otherwise overwrite), an author may republish the presets their own installed pack lent (anybody else's are still refused), apply_preset applies a preset by name or id from the control API, and module_ui opens an installed store module on a fresh profile instead of calling it not installed.",
      "tickets": [
        634
      ]
    },
    {
      "title": "Set the licence of a sample inside your module, so the store can accept it",
      "version": "2026.9.2",
      "date": "2026-10-01",
      "area": "Modules",
      "type": "Bug fix",
      "userFacing": true,
      "details": "A file added to a module with Add a file... had no licence, and there was no way to give it one, so a module built on samples could never be published: the store refuses any sound it cannot redistribute. Right-click the file's row in the module editor and choose Credit and licence... to set its licence (CC0, CC BY, CC BY-SA or My own recording), author and source URL; the row now shows the licence, and the dialog says straight away whether the store will accept it. The publish wizard's Embedded audio check now runs the store's own rule over the exact package it is about to upload, so it refuses what the upload would refuse, in the same words, before anything is sent.",
      "tickets": [
        630
      ]
    },
    {
      "title": "The Compressor can duck to another channel: turn on Side-chain and pick the Key on the Stack",
      "version": "2026.9.2",
      "date": "2026-10-01",
      "area": "Sound engine",
      "type": "New feature",
      "userFacing": true,
      "details": "The suite Compressor now has a Side-chain switch. Set the channel's Key on the Stack's top strip (the kick, say), turn Side-chain on, and the Compressor turns this channel down when the key is loud instead of when the channel itself is: the classic pad pumping to the kick, without reaching for the legacy Sidechain or Compressor. The SC high-pass filters the key, so a kick's sub can be ignored. Side-chain is Off by default, so songs made before it sound exactly the same. Measured on a rendered song: a pad keyed by a kick drops about 21 dB under each hit and is untouched between them.",
      "tickets": [
        640
      ]
    },
    {
      "title": "File > Export > Song as ezmuze project saves the song as a real .ezproj file (a download on the web)",
      "version": "2026.9.2",
      "date": "2026-10-01",
      "area": "Web",
      "type": "Bug fix",
      "userFacing": true,
      "details": "In the web app, the Browser storage service said \"Use Download to get a real file\", but there was no Download anywhere. File > Export now has Song as ezmuze project (.ezproj)..., which downloads the open song as a project file in the browser and offers a save dialog on the desktop. It is a copy: where Ctrl+S saves does not change. The Browser storage description now points at it.",
      "tickets": [
        655
      ]
    },
    {
      "title": "Pressing Okay in Preferences no longer switches off a --control-port session's remote control",
      "version": "2026.9.2",
      "date": "2026-10-01",
      "area": "Desktop",
      "type": "Bug fix",
      "userFacing": true,
      "details": "A session started with --control-port lost its remote control API the moment anyone pressed Okay (or Apply) in Preferences, because Okay re-applied the stored Preferences > Advanced setting, which is Off unless you turned it on there, and stopped the server until a restart. Apply and Okay now only touch the remote control server when you actually change the enabled switch or the port; a real change still takes effect immediately.",
      "tickets": [
        651
      ]
    },
    {
      "title": "The Stack-face docs agree with the app: a Dial does not narrow a card",
      "version": "2026.9.2",
      "date": "2026-10-01",
      "area": "Modules",
      "type": "Internal",
      "userFacing": false,
      "details": "With a Dial in a module and nothing marked In Panel, the Stack card shows the Dial plus every other unwired control. That is the locked rule (design-guide 2026-08-28: a face is its controls PLUS anything still unreachable), but docs/stack-design.md section 2 still described the older controls-only rule. The doc now carries the amendment, ExposedParamPorts says so, and a test pins it: the Dial joins the card, the port it drives leaves it, and marking is what narrows it. The docs portal pages (make-synth-deeper, make-components) say the same.",
      "tickets": [
        650
      ]
    },
    {
      "title": "Chip's Osc2 Detune reads in semitones",
      "version": "2026.9.2",
      "date": "2026-10-01",
      "area": "UI",
      "type": "Bug fix",
      "userFacing": true,
      "details": "Osc2 Detune read as a note name, C-1 at its centre and C-1-1200c at its minimum, on the panel, the UI editor and the Stack card. The dial is added to the oscillator's pitch alongside the note, so it is a transpose: it now reads -12 st to +12 st, and typing \"+7 st\" sets it. The same holds for any dial wired into a pitch input that something else also feeds. A note name off the end of the keyboard also stopped growing a thousand cents: it reads as the end note.",
      "tickets": [
        649
      ]
    },
    {
      "title": "Envelope cards on the mod rail show their knobs",
      "version": "2026.9.2",
      "date": "2026-10-01",
      "area": "UI",
      "type": "Bug fix",
      "userFacing": true,
      "details": "With the mod rail open, Chip's Noise Env, Amp Env and Click Env cards (and any envelope source) were wide empty boxes: the rail made room for the envelope's own graph and skipped its knobs, but it can only draw an LFO's editor, so nothing appeared. A source now gets an editor on the rail only when the rail can draw that editor; every other source gets its row of knobs (Attack, Decay, Release, Curve, Delay, Hold for the ones nothing else drives). Amp Env shows only Curve, Delay and Hold, because Chip's own dials drive the rest.",
      "tickets": [
        648
      ]
    },
    {
      "title": "Chip's Osc1 Wave and Osc2 Wave read Pulse, Saw and Triangle",
      "version": "2026.9.2",
      "date": "2026-10-01",
      "area": "Modules",
      "type": "Bug fix",
      "userFacing": true,
      "details": "Chip's wave selectors read as bare numbers on the Stack card (0 and 1) and as unlabelled segments on its panel, because they feed a select into a table number rather than a choice port they could borrow names from. They now carry their own names, Pulse, Saw and Triangle, on the card, the panel and the dropdown. Along the way: any selector that names its own positions and drives no choice port now reads its value as the index it is, so its names line up with the sound (Freyja's Range at 1 read 16' instead of 8', for one). Chip devices already in a song keep their numbers until the device is added again.",
      "tickets": [
        658
      ]
    },
    {
      "title": "The Stack closes when an undo removes the channel it shows",
      "version": "2026.9.2",
      "date": "2026-10-01",
      "area": "UI",
      "type": "Bug fix",
      "userFacing": true,
      "details": "Undoing the add of a channel the Stack was showing (a new return channel, say) took the channel out of the song but left the Stack open on it, still titled with its name and showing its cards. Channel-list edits now rebuild the rows in place rather than the whole view, so nothing checked the Stack any more. It now closes when its channel has left the song, and follows the channel when an edit replaces the channel object with one of the same id. A Stack showing any other channel is untouched.",
      "tickets": [
        657
      ]
    },
    {
      "title": "Convert to instrument no longer offers a drum kit as the channel to play into",
      "version": "2026.9.2",
      "date": "2026-10-01",
      "area": "UI",
      "type": "Bug fix",
      "userFacing": true,
      "details": "The wave picker's Convert to instrument... listed every channel with a wave-bank voice as a place to put the new instrument, a Capacitor drum kit and ordinary wave-bank channels included, and choosing one rewrote that channel's first sampler: the kit's pads stopped working as before, and a wave-bank channel, which picks its sample by note, would have played a different sample for every note. It now lists New channel, plus only the channels that are already a played sampler (the ones an earlier conversion made), so nothing is silently overwritten.",
      "tickets": [
        653
      ]
    },
    {
      "title": "The Samples tab's source list no longer runs one source into the next",
      "version": "2026.9.2",
      "date": "2026-10-01",
      "area": "UI",
      "type": "Bug fix",
      "userFacing": true,
      "details": "In the pattern explorer's Samples tab, a source with a status under its name (\"not signed in\", \"offline\") was drawn in a row only one line tall, so the status spilled out of the row and ran into the next source's name, as \"ezmuze central / not signed in\" did into \"Freesound\". Those rows are now two lines tall, with a small gap between sources.",
      "tickets": [
        656
      ]
    },
    {
      "title": "Control API: explorer_tab brings the pattern explorer's tab to the front",
      "version": "2026.9.2",
      "date": "2026-10-01",
      "area": "UI",
      "type": "Bug fix",
      "userFacing": false,
      "details": "With the pattern explorer sharing a window with another tab (a channel's Stack, the Clip editor), POST /action explorer_tab answered ok while the other tab stayed in front, so the explorer's tiles were not in the element tree and clicks aimed at them missed. Opening the explorer when it is already open now shows its tab in whichever window hosts it, which is what explorer_tab (and the app's own calls after an import or a Stack action) relied on. docs/remote-control-api.md says so.",
      "tickets": [
        622
      ]
    },
    {
      "title": "Pattern tiles draw their waveform and preview on every channel, imported MIDI included",
      "version": "2026.9.2",
      "date": "2026-10-01",
      "area": "UI",
      "type": "Bug fix",
      "userFacing": true,
      "details": "In the pattern explorer, the tiles of a channel whose generator has automation lanes (The Dark's Plink, for one) drew no waveform and did nothing when clicked, and so did a pattern just imported from a MIDI file. A tile showed the waveform the timeline had rendered, and the timeline renders each placement separately when automation makes them differ, so no picture of the pattern on its own was ever made; an imported pattern is not placed anywhere yet, so nothing asked for one at all. The explorer now asks for each tile's pattern itself, with its audio, so the tile draws and the click previews it. Playback is unchanged.",
      "tickets": [
        654
      ]
    },
    {
      "title": "The floating wave picker draws every row in full after scrolling",
      "version": "2026.9.2",
      "date": "2026-10-01",
      "area": "UI",
      "type": "Bug fix",
      "userFacing": true,
      "details": "Scrolling the floating wave picker (Choose sample... on Capacitor, for one) could leave some rows drawn with only their number, name and waveform: no play button, no R button, no detail line. It was GustUI's cache of which parts of an element are on screen, which did not notice when the element itself moved, so a row that had been half outside the list kept drawing only the half it could see then. The cache now notices. Any scrolled list could show it; the wave picker was where it was seen.",
      "tickets": [
        652
      ]
    },
    {
      "title": "The store tells you where an installed loop pack's loops are",
      "version": "2026.9.1",
      "date": "2026-10-01",
      "area": "UI",
      "type": "Bug fix",
      "userFacing": true,
      "details": "An installed loop pack's store page said its loops were \"in the pattern explorer, under Loop packs\", and the install message pointed at the pattern explorer too; there is no Loop packs label anywhere. A pack's loops arrive as the patterns of a channel added from it, so both now say so: add a channel with + > From Pack, and the loops are in that channel's Patterns tab.",
      "tickets": [
        635
      ]
    },
    {
      "title": "Plucktopus's Stack card source points its pick knobs at the dials that drive the pick",
      "version": "2026.9.1",
      "date": "2026-10-01",
      "area": "Modules",
      "type": "Bug fix",
      "userFacing": false,
      "details": "The Stack card face for Plucktopus listed \"Pick Level\" and \"Pick Tone\", two dials its graph carries but never wired, so those card knobs did nothing; the pick is the shared click layer, which the panel already drives through \"Click Level\" and \"Click Tone\". The face now names those. It reaches the store's Plucktopus only when its author re-applies the face (author_face) and publishes a new version; until then the store package still carries the old marks. A new test fails any store module whose card has a control wired to nothing."
    },
    {
      "title": "The conversion report for an ezmuze 3 song lists what could not be brought over",
      "version": "2026.9.1",
      "date": "2026-10-01",
      "area": "Sequencer",
      "type": "Bug fix",
      "userFacing": true,
      "details": "Importing an ezmuze 3 song could leave things behind without saying what: channels left out because their loop pack is not licensed to you (only a count on the status line), loops no installed pack has (silent), and TriggerFX that were skipped (not mentioned). The conversion report now has a line for each, under the channel it belongs to, and TriggerFX under their own heading. When anything was lost, a \"Song imported\" notice offers the report as soon as the song opens; it is also in Song settings > Conversion report at any time.",
      "tickets": [
        629
      ]
    },
    {
      "title": "The control API's glue question uses the clips you name",
      "version": "2026.9.1",
      "date": "2026-10-01",
      "area": "Services",
      "type": "Bug fix",
      "userFacing": false,
      "details": "POST /action glue with ask:true and a clips list asked the mixed-selection question of whatever was already selected, so naming a channel's pattern and sample clips was answered \"the selection has no channel with both pattern and sample clips\" although Ctrl+G on the same clips asked it. The ask path now selects the named clips first, exactly as the glue itself does.",
      "tickets": [
        639
      ]
    },
    {
      "title": "Dropping a store package (.ezmod) onto the window installs it",
      "version": "2026.9.1",
      "date": "2026-10-01",
      "area": "Modules",
      "type": "Bug fix",
      "userFacing": true,
      "details": "A sealed store package dragged onto the window was turned away (on Windows, with the no-entry cursor) or treated as audio and refused, although the store has always said a dropped package installs. Dropping one now runs the very install the store's Get runs, with the same checks and no others: the package has to be signed by ezmuze central and open with the signed-in account's key, so only a module licensed to you installs. Signed out, a package bought on another account, or a listen-only package is refused with the reason and nothing is installed.",
      "tickets": [
        627
      ]
    },
    {
      "title": "Saving a song opened from an ezmuze 3 file asks where to save it, and never overwrites the original",
      "version": "2026.9.1",
      "date": "2026-10-01",
      "area": "Sequencer",
      "type": "Bug fix",
      "userFacing": true,
      "details": "After File > Open of an ezmuze 3 .eez or .eee file, a plain Save (Ctrl+S) wrote the new ezmuze studio format straight over the original file, which ezmuze 3 then could not read; the same went for a legacy song opened from ezmuze central. The imported song now starts with no file of its own, so Save asks where to put it, exactly as Save as does, suggesting a .ezproj. The original legacy file is never touched, and it stays in your recent projects, where opening it imports it afresh.",
      "tickets": [
        626
      ]
    },
    {
      "title": "Synths added from the store play chords again instead of one note at a time",
      "version": "2026.9.1",
      "date": "2026-10-01",
      "area": "Modules",
      "type": "Bug fix",
      "userFacing": true,
      "details": "A store module added to a song (Kenobi, Plucktopus, Rhodine and the rest) arrived with Voices set to 1 whatever its author published, so chords played one note at a time. The voice count lives on each device in the song, and the store's add path never copied the published one across the way the factory and your own modules always have. New store devices now start with the polyphony their package declares. A device already in a song keeps its saved Voices setting; raise it on the Stack card.",
      "tickets": [
        633
      ]
    },
    {
      "title": "Starting a new song from a pack in File > New project asks about unsaved changes first",
      "version": "2026.9.1",
      "date": "2026-10-01",
      "area": "UI",
      "type": "Bug fix",
      "userFacing": true,
      "details": "File > New project > (a pack) replaced the open song straight away, so any unsaved work in it was lost without a word. It now asks the same Save / Discard / Cancel question as Empty project, Open and opening a demo. The welcome screen's pack templates go through the same guard; there it never shows, because no song is on screen.",
      "tickets": [
        628
      ]
    },
    {
      "title": "Warp Mode on Chip, Kenobi and the other synths is a named choice: Off, Bend, Sync, Fold, Mirror",
      "version": "2026.9.1",
      "date": "2026-10-01",
      "area": "UI",
      "type": "Bug fix",
      "userFacing": true,
      "details": "The Stack showed Warp Mode as a number (0 for Off). A selector borrows the option names of the choice it drives, but only when it drove exactly one, and Warp Mode drives every oscillator's warp at once. A selector whose destinations all offer the same options now reads those names; one driving choices that differ still shows a number. Saved songs are unaffected: the mode is stored as its index, as before.",
      "tickets": [
        636
      ]
    },
    {
      "title": "The Sends card shows a new return's send straight away",
      "version": "2026.9.1",
      "date": "2026-10-01",
      "area": "UI",
      "type": "Bug fix",
      "userFacing": true,
      "details": "After Sends + > New return channel (reverb), the return channel and the send were both made, but the Sends card kept saying No sends until you selected the channel again. Adding the return is one undo step that adds a channel and a send together, and the sequencer rebuilt its rows for the new channel without telling the Stack about the send. The Stack now refreshes after any such combined edit, as long as the channel it shows is still in the song.",
      "tickets": [
        643
      ]
    },
    {
      "title": "Dropping several audio files makes Audio 1, Audio 2, Audio 3 instead of three Audio 2s",
      "version": "2026.9.1",
      "date": "2026-10-01",
      "area": "Sequencer",
      "type": "Bug fix",
      "userFacing": true,
      "details": "A new channel made by dropping an audio file was numbered by counting the channels that have devices. An audio channel has none, so the number never moved and every dropped file landed on another channel called Audio 2. New Audio and Waves channels are now numbered one past the highest number already in use under that name.",
      "tickets": [
        642
      ]
    },
    {
      "title": "Moving a renamed channel names it by your name for it",
      "version": "2026.9.1",
      "date": "2026-10-01",
      "area": "UI",
      "type": "Bug fix",
      "userFacing": true,
      "details": "After you renamed a channel, the status bar still used its original name when you moved it into a group (Moved \"Audio 2\" into ... for a channel you had called Kick). Status messages about a channel (moving it, placing a clip on it, and the no-instrument hint) now use the name you gave it, and the channel's own name only when it has none.",
      "tickets": [
        638
      ]
    },
    {
      "title": "Measure loudness says Close once the report is ready",
      "version": "2026.9.1",
      "date": "2026-10-01",
      "area": "UI",
      "type": "Bug fix",
      "userFacing": true,
      "details": "File > Measure loudness kept its Cancel button after the measurement had finished and the report was showing. The button now reads Cancel while the song is being rendered and Close once the result (or an error) is up. Escape presses it either way.",
      "tickets": [
        646
      ]
    },
    {
      "title": "The wave picker shows a short sample's real length, in milliseconds",
      "version": "2026.9.1",
      "date": "2026-10-01",
      "area": "UI",
      "type": "Bug fix",
      "userFacing": true,
      "details": "The wave picker printed 0.0s for every sample under a second, and because the length was worked out with whole-number division every other length was cut down to whole seconds too (a 1.5 second sample read 1.0s). Lengths under a second now read in milliseconds (250 ms) and longer ones in seconds to one decimal (1.5 s), the same way in the wave picker, the Wave bank and Samples tiles and the message after adding a sample.",
      "tickets": [
        645
      ]
    },
    {
      "title": "Capacitor's Choose sample... button is readable",
      "version": "2026.9.1",
      "date": "2026-10-01",
      "area": "UI",
      "type": "Bug fix",
      "userFacing": true,
      "details": "The caption was dark grey on Capacitor's dark button. The panel uses the vintage theme, whose dark text is meant for a cream background, but the button has its own dark fill. A sample or pack picker's caption now keeps the theme's text colour only when it can be read on the button's own fill, and otherwise switches to black or white, whichever reads.",
      "tickets": [
        644
      ]
    },
    {
      "title": "Help's Quick Tips match the app again, and View > Show wave bank says what it does",
      "version": "2026.9.1",
      "date": "2026-10-01",
      "area": "UI",
      "type": "Bug fix",
      "userFacing": true,
      "details": "Three Quick Tips in F1 Help were out of date. Clips said M switches a clip between repeat and stretch: M now adds a marker, every clip repeats, and dragging an edge by its bottom half changes its speed. Saving and exporting pointed at File > Publish, which no longer exists: audio is File > Share or File > Export, and File > Export also writes MIDI. Blue and red lines described filters nothing in the app shows any more, so that tip is gone. View > Toggle wave bank only ever showed the wave bank, so it is now called Show wave bank.",
      "tickets": [
        632
      ]
    },
    {
      "title": "Shortcuts show ] [ and 0 instead of OemCloseBrackets, OemOpenBrackets and D0",
      "version": "2026.9.1",
      "date": "2026-10-01",
      "area": "UI",
      "type": "Bug fix",
      "userFacing": true,
      "details": "The Markers menu listed its next/previous marker keys as OemCloseBrackets and OemOpenBrackets, and F1 Help showed the module editor's zoom reset as Ctrl+D0. Menus printed the key's internal enum name and the help screen only knew a handful of keys. Both now use one key-name table in GustUI (KeyNames.Display), so every menu row and every help listing prints the key's own legend: ], [, 0, Esc, Page Down and so on.",
      "tickets": [
        637,
        641
      ]
    },
    {
      "title": "The channel cost test waits for the cost window instead of guessing its timing",
      "version": "2026.8.376",
      "date": "2026-10-01",
      "area": "Build",
      "type": "Internal",
      "userFacing": false,
      "details": "ChannelCost_DoesNotThrow_WhenAChannelIsAddedMidWindow rendered for a fixed 1.1 s and expected the 1 s cost window to have rolled; with the release dry run sharing the CI box that 100 ms margin failed a beta build. It now renders until the reading is back, up to 10 s."
    },
    {
      "title": "The release workflow builds the macOS head too",
      "version": "2026.8.376",
      "date": "2026-09-30",
      "area": "Build",
      "type": "Internal",
      "userFacing": false,
      "details": "promote.yml's head matrix never gained osx-arm64-gl when build.yml did, so a release dry run failed in finalize on the missing .dmg. It now builds all five heads, and PromoteWorkflowTests fails if the two lists drift apart again."
    },
    {
      "title": "If a song fails to open, you are told why instead of being left with an empty window",
      "version": "2026.8.375",
      "date": "2026-09-30",
      "area": "UI",
      "type": "Bug fix",
      "userFacing": true,
      "details": "When something went wrong while the sequencer was being built for a song you opened (a demo, a file, a recent song), the welcome screen closed, no sequencer appeared, and nothing was said or logged: the only way out was to restart. The failure is now shown as \"Could not open <song>: <reason>\" on the status line and in a message that stays until you close it, the welcome screen comes back so you can try again, and the app log records what failed and where. Other song opens and imports that ran in the background now report their failures the same way.",
      "tickets": [
        625
      ]
    },
    {
      "title": "The Samples tab previews like the other tabs, has a tile view and a right-click menu, and every tab can search",
      "version": "2026.8.374",
      "date": "2026-09-30",
      "area": "UI",
      "type": "New feature",
      "userFacing": true,
      "details": "The Pattern explorer's three tabs now share one preview player. Whatever you are previewing - a pattern, a wave bank sample, a sample in the Samples tab, the wave picker or Add a sample - shows a playhead moving across its waveform with the part still to come dimmed, its play button turns into a stop, a playing tile shows a small stop button, and the status bar says what is playing. Clicking the item that is playing stops it. The Samples tab can now show its samples as tiles like the other two tabs as well as a list (the two buttons at the right of its path line; your choice is remembered), and both stay fast with thousands of files. Samples from your own folders draw their waveforms as they scroll into view, the detail line says how many bars a sample is, and the waveform no longer stretches across the whole width of a wide window. Right-click a sample to preview it, add it to the wave bank without placing it, place it on the selected channel, or show it in its folder or in the store. The Patterns and Wave bank tabs have a search box that filters as you type.",
      "tickets": [
        624
      ]
    },
    {
      "title": "GustUI's windowed list can lay its items out as a grid",
      "version": "2026.8.374",
      "date": "2026-09-30",
      "area": "UI",
      "type": "Internal",
      "userFacing": false,
      "details": "VirtualListElement gained a multi-column mode (SetLayout: line pitch, items per line, column pitch). Items fill a line left to right, the window is whole lines, and ScrollToIndex brings an item's line into view, so a tile grid of thousands builds only the tiles on screen, as the list does. The arithmetic (Lines, ColumnsFor, GridRange, CellOrigin) is pure and unit-tested. Previews are now tracked by their preview key as well as a Guid (LoopPreview.PlayingKey), which is what lets the shared preview player (PreviewFeedback) follow items that have no Guid, and Add a sample and the Samples tab's Add to wave bank share one add path (WaveBankAdd)."
    },
    {
      "title": "Stretched loops in older songs no longer change key",
      "version": "2026.8.373",
      "date": "2026-09-30",
      "area": "Sound engine",
      "type": "Bug fix",
      "userFacing": true,
      "details": "Songs using the Phase vocoder stretch (older and imported songs, and anything that names RubberBand) fitted pack loops to the song's tempo with Preserve pitch on, but the stretch corrected the pitch the wrong way: instead of keeping the loop's key it moved it further, so a loop made at 100 BPM played in a 124 BPM song came out about seven semitones sharp. It now keeps the key, like every other algorithm, and a test checks every algorithm at four stretch lengths so this cannot come back unnoticed. Cached renders made with the old stretch are thrown away and rebuilt. The shipped demos sound exactly as before: none of them stretches a loop this way.",
      "tickets": [
        623
      ]
    },
    {
      "title": "Play in time previews start straight away in step with the song, and keep their key",
      "version": "2026.8.372",
      "date": "2026-09-30",
      "area": "UI",
      "type": "Improvement",
      "userFacing": true,
      "details": "With Play in time on, a preview no longer waits for the next bar. It starts as soon as it is ready, part-way into its loop, exactly where it would be had it started on the bar the song is in, so it is in step from the first beat. Previews also always keep their key now, at 0.5x, 1x and 2x: they used to follow the song's own stretch, which changes the pitch when Preserve pitch is off and on songs using the Phase vocoder algorithm (every demo and imported song), so a sped-up loop could sound higher.",
      "tickets": [
        621
      ]
    },
    {
      "title": "Play in time: explorer previews can start on the next bar, fitted to the song's tempo, and loop",
      "version": "2026.8.371",
      "date": "2026-09-30",
      "area": "UI",
      "type": "New feature",
      "userFacing": true,
      "details": "The Pattern explorer (patterns, wave bank and samples) and the sample browser have a Play in time switch, with a length choice of 0.5x, 1x or 2x beside it. With it on, a preview whose length is known in bars (pack loops and patterns, or a sample whose source, file name or WAV tags give its tempo) starts on the song's next bar while the song plays, or at once when it is stopped; is stretched to the song's tempo with the song's own loop stretch, keeping its key when Preserve pitch is on (0.5x plays it at double the tempo, 2x at half); and loops until you click it again, preview something else, or switch Play in time off. Anything with no known tempo, such as a one-shot, still plays once as it is. The switch and length are remembered and shared by every explorer.",
      "tickets": [
        621
      ]
    },
    {
      "title": "Clicking a tile in the Pattern explorer previews it while the song is playing",
      "version": "2026.8.371",
      "date": "2026-09-30",
      "area": "UI",
      "type": "Bug fix",
      "userFacing": true,
      "details": "A click on a pattern or wave bank tile did nothing at all while the song played: no sound, no Previewing line, and a pack pattern that had never been heard was not even fetched, so its tile stayed blank. The explorer skipped every preview during playback. It now previews whether the song is playing or not, through the preview's own sound, so the song carries on undisturbed and you can audition patterns against it.",
      "tickets": [
        620
      ]
    },
    {
      "title": "Add channel lists From Pack before Pack group",
      "version": "2026.8.370",
      "date": "2026-09-30",
      "area": "UI",
      "type": "Improvement",
      "userFacing": true,
      "details": "In the + Add channel menu, From Pack (start a channel from one of your packs) now comes before Pack group (add a pack's standard folders to the song, a tool for pack authors), so the everyday choice is the nearer one."
    },
    {
      "title": "The sample editor can paste over a selection without changing its length, repeat a copy to fill it, and clear it",
      "version": "2026.8.369",
      "date": "2026-09-30",
      "area": "UI",
      "type": "New feature",
      "userFacing": true,
      "details": "Three new edits for the selected area in the Clip editor's sample view, on the right-click menu and the Edit menu. Paste over writes the copied audio into the selection from its start, replacing samples in place: a shorter copy stops when it runs out and leaves the rest of the selection alone, a longer one stops at the selection end, and the sample never changes length. Paste over repeating does the same but repeats a shorter copy back to back until the selection is full, cutting the last repeat at the end. Clear area (the item that was called Silence) sets the selection to silence. They are greyed out with no selection, and the pastes with nothing copied. Each is one undo step. Menu only: Ctrl+Shift+V already means the opposite in the sequencer.",
      "tickets": [
        619
      ]
    },
    {
      "title": "A pack pattern dragged onto the timeline without previewing it first now plays and shows its waveform",
      "version": "2026.8.368",
      "date": "2026-09-30",
      "area": "Sequencer",
      "type": "Bug fix",
      "userFacing": true,
      "details": "A pack loop's place in the wave bank holds no audio of its own; it names the loop, and the loop's audio is fetched separately. Clicking a pattern in the pattern explorer fetched it, but dropping one on the timeline never did, so a pack pattern dragged straight from a channel added from a pack (or a sample-pack loop dropped before its row had loaded) landed as a blank clip that played silence. The drop still places the clip at once, and now fetches the loop's audio behind it; the clip fills in with its waveform and plays as soon as it arrives.",
      "tickets": [
        618
      ]
    },
    {
      "title": "The line between channels runs the full width of a widened channel header",
      "version": "2026.8.367",
      "date": "2026-09-30",
      "area": "Sequencer",
      "type": "Bug fix",
      "userFacing": true,
      "details": "The resize bar under each channel header, which is also the line that splits one channel from the next, was sized once when the row was built. Widening the header column moved it but never widened it, so the split line stopped at the old width. It now follows the column, and so does the one under each automation lane band.",
      "tickets": [
        617
      ]
    },
    {
      "title": "The desktop app opens in seconds again, instead of sitting for minutes with no window",
      "version": "2026.8.366",
      "date": "2026-09-30",
      "area": "Desktop",
      "type": "Performance",
      "userFacing": true,
      "details": "Startup seeds every factory preset and module on every launch, and each seed rewrote the whole library to disk: hundreds of preset seeds times about 900 preset files, and about 50 module seeds times about 70 definitions. As the library grew with the effects suite this reached minutes with no window. An unchanged preset is now not written at all, a changed one writes only itself and the index, and the module library writes only the entries that changed. Measured on the DX build: from several minutes, then 35 s, to about 6.6 s to the window.",
      "tickets": [
        616
      ]
    },
    {
      "title": "Web: the pattern that crashed store modules (#581) is removed from 13 more places and guarded by a test",
      "version": "2026.8.365",
      "date": "2026-09-30",
      "area": "Web",
      "type": "Internal",
      "userFacing": false,
      "details": "#581 was a conditional expression (a ? b : c) inside a try whose catch has a 'when' filter: under .NET WASM AOT the reference it produced could be lost to the garbage collector, and the web runtime died. Measured in Edge on Bank Manager's real data, that exact method stored a dangling list in about a third of versions and killed the runtime every run, while the same code with a plain catch, with no conditional, or moved into a method the try only calls was clean in 600 versions each; building objects inside the filtered try was not the problem on its own. Every filtered catch in the projects the web build compiles was checked (50 sites). The 13 that still had a conditional, ?? or ?. inside the filtered try now call a separate method instead: loading a song's store modules (TryMaterialize, which did not reproduce but was split anyway), module upgrades, listen grants, the module About panel, embedding a store module, two publish-wizard checks, the package-key and licence-claim calls, review writes, and Serum import. WebAotExceptionFilterTests fails on any new one. docs/web-aot-exception-filters.md has the rule, the measurements and the full site list.",
      "tickets": [
        614
      ]
    },
    {
      "title": "Automation segments can curve, hold or S-bend",
      "version": "2026.8.364",
      "date": "2026-09-30",
      "area": "Sequencer",
      "type": "New feature",
      "userFacing": true,
      "details": "Every segment of an automation lane now has a small square at its middle. Drag it up or down to bend the segment (or Alt+drag anywhere along it), double-click it to straighten it again, or right-click it for Straight, Exponential, Logarithmic, S-curve and Hold (step). With points selected, the lane menu's Shape the selected segments sets many at once. Playback, the lane drawing, the row overlay, copy, paste, undo and saving all follow the shape, and moving a point keeps its segment's shape. Straight segments play exactly as before. REAPER import now brings envelope point shapes across: square, slow start/end, fast start and fast end land as the matching shapes, and a bezier as a curve of the same tension. Songs saved with this version need this version or later to open (song format 40).",
      "tickets": [
        592
      ]
    },
    {
      "title": "Adding a store module no longer crashes the web version of the studio",
      "version": "2026.8.363",
      "date": "2026-09-30",
      "area": "Web",
      "type": "Bug fix",
      "userFacing": true,
      "details": "In the browser, swapping a channel's instrument for a store module (Bank Manager, Rat 030 and other large ones) could freeze the studio for good, with the console full of \".NET runtime already exited\" errors. It happened whether or not the song was playing. The first time a session opened one of those packages, reading its versions went wrong: the code that reads a version sat inside an error handler of a kind that the browser build's compiled code does not handle safely, and a memory clean-up in the middle of it threw away a module's definition while the version still pointed at it. That memory was then reused, and the next write through it damaged the app's memory until the runtime stopped. The reading now happens outside that handler, so every version keeps its own definitions. Tested by opening every store module and swapping instruments during playback 70 times in Edge, where the unfixed build crashed within the first few swaps in 7 of 8 runs. The desktop apps were never affected.",
      "tickets": [
        581
      ]
    },
    {
      "title": "Notes you play answer much faster on the DirectX build: about 25 ms instead of about 125 ms",
      "version": "2026.8.362",
      "date": "2026-09-30",
      "area": "Sound engine",
      "type": "Improvement",
      "userFacing": true,
      "details": "Playing a MIDI keyboard, typing notes on the QWERTY layout, or hitting notes in the piano roll now sounds about 25 ms after the key, down from about 125 ms (plus, in both cases, your audio device's own delay). That is fast enough to play in time. The live audition now renders 10 ms of audio at a time and keeps about 30 ms queued instead of 150 ms. While notes are being played it asks Windows for a 1 ms timer, so it is never late refilling that queue. If an instrument is ever too heavy to keep up, it queues a little more rather than clicking. Measured with the synth demo playing and half the CPU busy: no dropouts, in the audition or the song. This applies to the Windows DirectX build. The OpenGL builds keep the old queue for now, because their audio backend only counts what it has played once a frame. The browser still plays pre-rendered notes, and its lack of bend and sustain is unchanged and not part of this.",
      "tickets": [
        610
      ]
    },
    {
      "title": "MIDI keyboards and controllers work on Linux, through the ALSA sequencer",
      "version": "2026.8.362",
      "date": "2026-09-30",
      "area": "Desktop",
      "type": "New feature",
      "userFacing": true,
      "details": "The Linux build now hears MIDI input as the Windows build and the browser do: notes with velocity, the sustain pedal, pitch bend and MIDI learn, from every MIDI source the ALSA sequencer knows about (USB keyboards, virtual ports, other programs). It connects to them by itself, shows them in Preferences > MIDI with the same on/off switches, and follows devices being plugged in and out. On a machine without ALSA's sequencer (no sound driver, or libasound missing) MIDI is simply absent, and Preferences says why. This was built and tested without a real MIDI device, so the first reports from Linux users are welcome.",
      "tickets": [
        608
      ]
    },
    {
      "title": "MIDI keyboards on macOS (built for the Mac, not yet tried on one)",
      "version": "2026.8.362",
      "date": "2026-09-30",
      "area": "Desktop",
      "type": "New feature",
      "userFacing": true,
      "details": "The macOS app now has a MIDI input backend, CoreMIDI, so MIDI keyboards and controllers can play instruments and be MIDI-learned there as they already can on Windows and in the browser: every connected input is listed in Preferences > MIDI by name, and plugging one in or out is picked up by itself. Please note it was written and built without a Mac to run it on, so it has never been tried on real hardware. It is built to fail safely: if CoreMIDI cannot be started, Preferences > MIDI says why and lists no devices, and nothing else is affected. Reports from Mac users are very welcome.",
      "tickets": [
        609
      ]
    },
    {
      "title": "Record onto the timeline: arm channels and record audio from your microphone or notes from a MIDI keyboard, in time with the song",
      "version": "2026.8.361",
      "date": "2026-09-30",
      "area": "Sound engine",
      "type": "New feature",
      "userFacing": true,
      "details": "Arm a channel with the new red dot beside its Mute button, then press Record (the red dot after the metronome in the toolbar, or R). From a stop it counts in if a count-in is set, then records; while playing it punches in, and pressing it again punches out. With Loop selection on, only the selection is recorded and each pass becomes a take: the last one plays, and Ctrl+Z steps back through the earlier ones, each take being one undo step. Audio takes are placed as sample clips, embedded in the song. Notes played on a MIDI keyboard during a recording become a pattern on each armed channel that has an instrument. Takes land in time: the app works out when each sample was heard from the audio devices' own timing, and Preferences > Audio has a Recording latency setting with a Measure button that plays the metronome out of your speakers and records it back to find the rest. Also in Preferences > Audio: 'Metronome only while recording'. Not yet: comping between takes, choosing the input device, stereo input, and quantize on record.",
      "tickets": [
        594
      ]
    },
    {
      "title": "Loop selection works again once a song has been fully cached for playback",
      "version": "2026.8.361",
      "date": "2026-09-30",
      "area": "Sound engine",
      "type": "Bug fix",
      "userFacing": true,
      "details": "On desktop, once the whole song had been rendered to the background cache, playback came off disk and ignored the loop mode: Loop selection played straight through the selected bars, and No loop wrapped to the top instead of stopping. A session now plays from the cache only while the whole song is looping, and live in the other two modes, where the loop rules are followed.",
      "tickets": [
        612
      ]
    },
    {
      "title": "Record automation live: move a control while the song plays and it is written into its lane",
      "version": "2026.8.361",
      "date": "2026-09-30",
      "area": "Sequencer",
      "type": "New feature",
      "userFacing": true,
      "details": "Each channel has an automation mode, set from its right-click menu (Automation): Read plays the lanes and records nothing, as before; Touch records a control while you hold it and hands back to the lane when you let go; Latch keeps recording its last value after you let go, until playback stops; Write records every lane on the channel from play to stop. Armed channels show T, L or W in their row. Moving a knob on a device panel or Stack card while playing then writes points where you heard the move, even though the audio is rendered ahead, creating the lane if it has none. The points are thinned so a sweep is a handful of points and a held value is two, and each recording pass is one undo step. Re-recording a lane plays your new value as you move it. Send levels and MIDI-learned controllers record too. The mixer's own volume and pan cannot be automated yet.",
      "tickets": [
        596
      ]
    },
    {
      "title": "Menu shortcuts spelled as a key name, like Delete and Escape, no longer wrap onto two lines",
      "version": "2026.8.361",
      "date": "2026-09-30",
      "area": "UI",
      "type": "Bug fix",
      "userFacing": true,
      "details": "A menu row's shortcut had a fixed 22-pixel box for its key, which fits a letter but not a name: the piano roll's and drum lanes' Pattern menus showed \"Del\" / \"ete\" and \"Esc\" / \"ape\" spilling onto the next row. The key's width is now measured (in GustUI's menu row), the menu grows to fit it, and a long label gives way to the shortcut rather than running under it.",
      "tickets": [
        605
      ]
    },
    {
      "title": "On the OpenGL builds, held notes now sustain and chords sound when you play the keyboard, a MIDI keyboard or the piano roll",
      "version": "2026.8.360",
      "date": "2026-09-30",
      "area": "Desktop",
      "type": "Bug fix",
      "userFacing": true,
      "details": "The live audition stream (what makes a held note keep sounding until you let go, lets several notes sound together and follows pitch bend) was only built into the DirectX build. The OpenGL builds for Windows, Linux and macOS fell back to a short pre-rendered one-shot of about 400 ms for every note from the computer keyboard, the piano roll or a MIDI keyboard. They now use the same streaming audition as DirectX, through KNI's DynamicSoundEffectInstance on OpenAL.",
      "tickets": [
        611
      ]
    },
    {
      "title": "Control API: select_lane_points without a range only reads the lane",
      "version": "2026.8.360",
      "date": "2026-09-30",
      "area": "Services",
      "type": "Bug fix",
      "userFacing": false,
      "details": "POST /action select_lane_points with no startBeats/endBeats defaulted to 0..end and selected every point of the lane, although docs/remote-control-api.md says omitting the range selects nothing and just reads the points. It now reads only: the lane's selection is left as it was and the lane need not be expanded. Either end alone runs from the lane's start or to its end. The range is read by ControlArgs.LanePointRange, unit-tested.",
      "tickets": [
        607
      ]
    },
    {
      "title": "Sends and return channels: share one reverb or delay between channels",
      "version": "2026.8.360",
      "date": "2026-09-30",
      "area": "Sound engine",
      "type": "New feature",
      "userFacing": true,
      "details": "Add channel > Return Channel makes a return: a channel with no clips holding a reverb at 100% wet. Any channel can then send to it from the new Sends card at the end of its Stack, with a level knob per send (right-click it for pre- or post-fader, its automation lane, or to remove it). A post-fader send follows the channel's fader and pan; a pre-fader one does not. Send levels can be automated like any knob. Returns sum into the master (or their group) like any channel, can send on to another return, and say which channels feed them. Soloing a channel keeps its returns audible, and soloing a return plays everything sent to it. REAPER projects now bring their sends across as sends to return channels, with send volume envelopes as lanes, where they used to be dropped. A lookahead device on a return runs uncompensated for now, and its card says so. Songs with sends need this version (song format 39); songs without them sound exactly as before.",
      "tickets": [
        595
      ]
    },
    {
      "title": "Play instruments from a MIDI keyboard, and MIDI-learn any knob to a controller",
      "version": "2026.8.360",
      "date": "2026-09-30",
      "area": "UI",
      "type": "New feature",
      "userFacing": true,
      "details": "MIDI keyboards and controllers now work on Windows (both builds) and in Chrome, Edge and Firefox. Notes play the selected channel's instrument live, through the same sound QWERTY musical typing uses, with velocity, the sustain pedal and the pitch-bend wheel. Right-click any knob in the Stack and choose MIDI learn, then move a knob or slider on your controller: it follows from then on, as an undoable edit like a drag (the mod wheel is learnable the same way). Preferences has a new MIDI page listing your devices, each with an on/off switch and a light that blinks when it sends anything, plus the bend range and your learned controllers with a Forget button. Devices plugged in later appear by themselves. In the browser MIDI is off until you switch it on, because the browser asks permission. Not yet on Linux or macOS.",
      "tickets": [
        593
      ]
    },
    {
      "title": "Markers and named sections on the timeline: jump to them, loop a section in one step",
      "version": "2026.8.359",
      "date": "2026-09-30",
      "area": "Sequencer",
      "type": "New feature",
      "userFacing": true,
      "details": "The sequencer's ruler has a marker lane under the bar numbers. Press M to drop a marker at the playhead (on the beat while playing), double-click the lane to add one there, or select some bars and press Shift+M to make them a named section such as Intro, Verse or Drop. Click a marker to put the playhead on it; drag it to move it (bar lines, Shift for free); drag a section's end to resize it; double-click to rename; right-click for Loop this section, Select this section, colour and delete. ] and [ jump to the next and previous marker, L loops the section the playhead is in, and the new Markers menu lists them all to jump to. Selecting a section makes it the area Copy, Cut and Paste act on, and a cut or paste across every channel carries the markers after it along. Markers are undoable, saved in the song (an older build opens such a song and simply leaves them off), and REAPER imports bring the project's markers and regions in as markers and sections.",
      "tickets": [
        590
      ]
    },
    {
      "title": "A Loudness meter (LUFS and true peak), and File > Measure loudness for the whole song",
      "version": "2026.8.359",
      "date": "2026-09-30",
      "area": "Sound engine",
      "type": "New feature",
      "userFacing": true,
      "details": "A new Loudness meter effect (Utility) measures whatever passes through it, which it leaves untouched, to ITU-R BS.1770-4 and EBU R 128: momentary and short-term loudness as bars, integrated loudness since Play written large against a target you pick (-14 LUFS streaming, -16 podcast, -23 broadcast or -9 club), the loudness range, the highest true peak (warned above -1 dBTP) and the loudest readings. The readings stay on screen when the song stops, so playing the song through gives its number. Put it on the master's Stack to meter the mix, or on any channel. File > Measure loudness... renders the whole song as Share does and reports the same readings without playing it, with what a streaming service's normalisation will do to it. The meter is checked against the EBU Tech 3341 and 3342 reference cases. It sits beside the existing peak meters rather than replacing them: they answer a different question (is anything about to clip, right now).",
      "tickets": [
        591
      ]
    },
    {
      "title": "Keyboard shortcuts work again straight after renaming something",
      "version": "2026.8.359",
      "date": "2026-09-30",
      "area": "UI",
      "type": "Bug fix",
      "userFacing": true,
      "details": "After renaming a channel (or anything else through the small Rename dialog), every keyboard shortcut stopped working until you clicked into another text box: the closed dialog's text field kept the keyboard. A text field that is no longer on screen now lets go of it.",
      "tickets": [
        606
      ]
    },
    {
      "title": "Export a song or a pattern as a MIDI file, from File > Export or a pattern's right-click menu",
      "version": "2026.8.359",
      "date": "2026-09-30",
      "area": "Sequencer",
      "type": "New feature",
      "userFacing": true,
      "details": "File > Export > Song as MIDI file writes the whole song as a Standard MIDI File (format 1) that any DAW or hardware sequencer opens: the tempo, the time signature, and one track per channel that plays notes, named after the channel, with every note where it actually plays (clip trims, repeats and stretches included) and its velocity. Right-clicking a pattern in the pattern explorer offers Export as MIDI file for that pattern alone. Notes between keys or with a drawn bend are written as MPE on that track only (each note on its own channel, bends at a 48-semitone range); tracks without them stay plain MIDI on channel 1. Loops and samples are audio and are not included, nor are automation and instruments. A file exported this way reads back through the app's own .mid drop with its notes unchanged.",
      "tickets": [
        588
      ]
    },
    {
      "title": "Save a song as MP3 (192, 256 or 320 kbps) or FLAC, on the desktop and on the web",
      "version": "2026.8.359",
      "date": "2026-09-30",
      "area": "Sound engine",
      "type": "New feature",
      "userFacing": true,
      "details": "Save as audio (File > Share, and now also File > Export > Song as audio) offers MP3 and FLAC beside Ogg Vorbis and WAV. MP3 has a bitrate row of its own: 192, 256 (the default) or 320 kbps; FLAC is lossless, about half the size of WAV, and carries the song's title and author. Both encoders are written in ezmuze itself, so they work the same in the browser as on the desktop and add no licence to the app. The dialog now shows how far the encoding has got. The MP3 encoder has no psychoacoustic model yet, which is why it does not offer bitrates below 192, and like any MP3 without a gapless header it starts with 24 ms of silence.",
      "tickets": [
        589
      ]
    },
    {
      "title": "Control API: save_to answers the next save dialog, so exports can be scripted",
      "version": "2026.8.359",
      "date": "2026-09-30",
      "area": "Desktop",
      "type": "Internal",
      "userFacing": false,
      "details": "POST /action {\"name\":\"save_to\",\"path\":...} queues a path for the next native save dialog, which then writes there without showing, the save-side twin of pick_file. Added to drive the MP3, FLAC and MIDI exports end to end in a running app (#588, #589)."
    },
    {
      "title": "A metronome: a click on every beat, from a new button beside Loop or the K key, with an optional count-in",
      "version": "2026.8.358",
      "date": "2026-09-30",
      "area": "Sound engine",
      "type": "New feature",
      "userFacing": true,
      "details": "The transport has a metronome. Turn it on with the new button beside Loop in the sequencer's toolbar, or K; it is remembered between sessions. It clicks on every beat of the song's time signature, with the first beat of each bar higher and louder, and it follows loops and jumps exactly, because each click is placed against the audio actually being heard. Preferences > Audio sets its volume, its sound (Click, Beep or Woodblock) and a count-in of one or two bars before playback starts from a stop, off by default; the count-in plays whether the metronome is on or not, and moving the playhead while playing never counts in again. The click is only ever heard: it is never in a bounce, an export or the bounce cache. There is no 'only while recording' option yet, because the song does not record yet.",
      "tickets": [
        585
      ]
    },
    {
      "title": "Quantize, swing and humanize the notes of a pattern, in the piano roll and the drum lanes",
      "version": "2026.8.358",
      "date": "2026-09-30",
      "area": "Sequencer",
      "type": "New feature",
      "userFacing": true,
      "details": "The Pattern menu of the piano roll and of the drum lanes has Quantize (also Ctrl+Q), Swing and Humanize. Each opens a small dialog and acts on the selected notes, or on every note when nothing is selected, as one undo step. Quantize pulls note starts to a grid from 1/4 to 1/32, triplets included, with a strength so it can tighten without flattening, and can pull note ends too. Swing delays the off-beat eighths or sixteenths, from straight to a full triplet shuffle; applying it again replaces the swing rather than adding to it. Humanize moves note starts by up to 40 ms and velocities by up to half either way, at random but repeatably, and undo puts back exactly what was there.",
      "tickets": [
        587
      ]
    },
    {
      "title": "Fixed memory use growing with every song opened, which took the app past 6 GB in an hour",
      "version": "2026.8.358",
      "date": "2026-09-30",
      "area": "UI",
      "type": "Bug fix",
      "userFacing": true,
      "details": "Every song you opened stayed in memory after you moved on to the next one. GustUI keeps a list of every window so that Escape and menu shortcuts reach the right one; a window joined it when it was made and left only when it was closed. The pattern explorer makes its window whenever a song opens but shows it only when asked, so a window that was never shown was never closed and never left the list, and through it the whole previous song, its sequencer and its patterns stayed reachable. In a scripted soak on a Debug build, each round of four demo opens added about 37 MB of managed heap that no garbage collection could free: 205 MB to 660 MB over 13 rounds, with the working set going from 450 MB to 1.75 GB. The list now holds its windows weakly, so it no longer keeps a window alive by itself. Over 17 rounds the heap now stays at 172 to 182 MB and the working set levels off at 800 to 950 MB. Playing, the piano roll, module panels, the Clip editor, Drum chop previews, theme switches and scrolling were soaked the same way and did not grow.",
      "tickets": [
        604
      ]
    },
    {
      "title": "Recording works on the OpenGL builds with microphones they could not open before, such as a LifeCam webcam",
      "version": "2026.8.358",
      "date": "2026-09-30",
      "area": "Desktop",
      "type": "Bug fix",
      "userFacing": true,
      "details": "On the OpenGL builds (Windows GL, Linux, macOS), Record a sample failed with 'Failed to open capture device. - OpenAL Error: 40964' on some microphones, a Microsoft LifeCam VX-2000 among them. The audio library those builds ship could only open a device in the exact format it mixes at (48 kHz stereo for the LifeCam), and the app always asked for 44.1 kHz mono. When that fails the app now opens the device itself, trying mono then stereo at 44.1, 48, 96, 32 and 16 kHz, records at the rate the device accepts and folds stereo down to mono. Microphones that already worked open exactly as before. The DirectX build was not affected.",
      "tickets": [
        582
      ]
    },
    {
      "title": "The compiled GL visualiser shaders no longer depend on the line endings of the checkout that built them",
      "version": "2026.8.358",
      "date": "2026-09-30",
      "area": "Build",
      "type": "Internal",
      "userFacing": false,
      "details": "VisEffectsGL.xnb embeds its hand-written GLSL as text, line endings included. The committed DesktopGL and web copies had been built from a tree with one CRLF source among three LF ones, which no checkout reproduces, so the file showed as modified after every local content build. The .frag, .vert, .glsl and .glslfx sources are now eol=lf in .gitattributes, the KNI RawGlslEffectImporter normalises them to LF when it reads them (so a CRLF copy compiles to the same bytes), and both .xnb were rebuilt once from LF sources. The shaders themselves are unchanged. ContentFreshnessTests now checks that the committed DesktopGL .xnb embeds the LF form of every source.",
      "tickets": [
        580
      ]
    },
    {
      "title": "Opening the piano roll no longer closes an open module panel",
      "version": "2026.8.358",
      "date": "2026-09-30",
      "area": "UI",
      "type": "Bug fix",
      "userFacing": true,
      "details": "Opening the piano roll, the drum lanes or the wave picker from a clip closed any device panel you had open, and its UI editor with it; opening a panel likewise closed the piano roll. That rule dated from when both were full-screen sheets and only one could be on screen. They are ordinary windows now, so they stay open together: the pattern editor comes to the front, and the panel is one click away behind it, or in the View menu's window list. A piano roll opened with a panel up also used to be left unlaid-out and frozen, because only one of the two got its per-frame update; both run now. The module builder still replaces a panel or a piano roll, and only one pattern editor is open at a time.",
      "tickets": [
        603
      ]
    },
    {
      "title": "Piano roll note waveforms now show release tails past the end of a note",
      "version": "2026.8.357",
      "date": "2026-09-30",
      "area": "Sequencer",
      "type": "Improvement",
      "userFacing": true,
      "details": "A note's waveform no longer stops at the end of the note: when its sound keeps ringing (a release, a long decay), the rest is drawn past the note in a dimmer version of the note colour, so you can see how far each note really carries. It keeps the note's height, and anywhere silent is still left empty. A note that starts while another is ringing sits on top of that tail.",
      "tickets": [
        602
      ]
    },
    {
      "title": "The piano roll shows each note's own waveform inside it",
      "version": "2026.8.356",
      "date": "2026-09-30",
      "area": "Sequencer",
      "type": "New feature",
      "userFacing": true,
      "details": "Each note in the piano roll now draws the waveform it actually produced: its attack, how it decays and how loud it came out. The pattern is rendered in the background through the channel's instrument (without effects), and every voice is heard separately before they are mixed, so each note of a chord shows its own sound. All notes share one scale, so a quiet note looks quiet; the waveform is not shrunk again by the note's velocity. It is drawn only inside the note. After an edit the waveforms update a moment later. Notes on a hosted plugin instrument, and bent notes, show no waveform yet.",
      "tickets": [
        602
      ]
    },
    {
      "title": "The wave picker's Convert to drum kit now opens Drum chop, which fixes a kit landing at bar 1 on top of the loop",
      "version": "2026.8.356",
      "date": "2026-09-30",
      "area": "UI",
      "type": "Bug fix",
      "userFacing": true,
      "details": "Convert to drum kit… on a wave picker row is now a shortcut to the Clip editor's Drum chop for that wave, so you see and adjust the cuts before anything is made. It opens on the first clip in the song that plays the wave; when nothing plays it yet, the wave is placed first, on a new channel at bar 1 as a drop would place it (one undo step), and chopped there. The old one-step conversion is gone, and with it three problems: the kit was always placed once at bar 1, on top of a loop that kept playing, because it only looked for the loop in pattern clips (#599); its hits were timed at the song's tempo rather than the loop's, so a 125 BPM loop in a 122 BPM song drifted against the arrangement (#599); it took about fifteen presses of Ctrl+Z to undo (#600); and it analysed the loop on the game thread, freezing the app for over half a second (#601). Making a kit onto an existing Capacitor channel is not offered any more.",
      "tickets": [
        599,
        600,
        601
      ]
    },
    {
      "title": "Drum chop in the Clip editor: see where a loop will be cut into drums, adjust it with sliders, hear it and make the kit",
      "version": "2026.8.356",
      "date": "2026-09-30",
      "area": "UI",
      "type": "New feature",
      "userFacing": true,
      "details": "Open a drum loop in the Clip editor and press the Drum chop button (or Drum chop > Chop into a drum kit…, or right-click the clip on the timeline > Chop into a drum kit…). Markers appear where the loop would be cut into hits, and a coloured band under the waveform shows which drum pad each slice will play on: slices in one colour are the same drum. Click the marker lane to add a marker, drag one to move it, drag it off the lane to remove it; markers you place or move are pinned (solid flags) and the sliders leave them alone, while detected ones (hollow ticks) follow the sliders. Sensitivity and Min gap decide where the hits are; Match and Layers decide which hits share a pad; Keep one-offs gives a sound heard only once a pad of its own. Click a slice to hear it, Play kit to hear the loop rebuilt from the pads, Play loop to compare. Make kit puts a Capacitor kit on a new channel under the clip with a pattern that plays it where the clip did, in one undo step; Save as cues keeps the markers on the clip. The loop is analysed at its own tempo and nothing heavy runs on the game thread, so the sliders never freeze the app. Ctrl+Z inside Drum chop undoes marker changes; Escape leaves it.",
      "tickets": [
        598
      ]
    },
    {
      "title": "The Clip editor's sample view can draw and edit drum chop markers",
      "version": "2026.8.356",
      "date": "2026-09-30",
      "area": "UI",
      "type": "Internal",
      "userFacing": false,
      "details": "The sample view half of the Drum chop mode (#598), not yet reachable. In chop mode the marker lane holds the chop's slice markers instead of the loop and cues: pinned markers are the solid amber cue flag, detected ones a hollow amber tick with a fainter line, and a 3 px band along the foot of the wave shows which pad each slice plays on, in one of sixteen pad colours, with the pad number beside its marker. A click in the lane adds a marker, a drag moves one, dragging one well above or below the lane removes it, a double-click hands a pinned one back to detection, and a click on the wave reports the slice to audition."
    },
    {
      "title": "A drum kit can be made from chop markers, with pads shared by sound and hits at their own level",
      "version": "2026.8.356",
      "date": "2026-09-30",
      "area": "Sound engine",
      "type": "Internal",
      "userFacing": false,
      "details": "The kit-making half of the Clip editor's Drum chop mode (#598). Send cues to drum pads' machinery (a render of the clip as it plays, pads cut from it, the kit channel below the clip with its effects and mixer, one undo step) now also takes a list of markers to cut at and a pad map: pieces the chop-and-cancel grouping says are the same drum share one pad even though each slice's audio differs, and each hit keeps its level relative to the pad's first as its velocity. The clip's own cues are left alone. Send cues to drum pads itself is unchanged."
    },
    {
      "title": "Drum chop markers: detected, pinned and vetoed, with their own undo",
      "version": "2026.8.356",
      "date": "2026-09-30",
      "area": "Sound engine",
      "type": "Internal",
      "userFacing": false,
      "details": "The model behind the Clip editor's Drum chop mode (#598), with no UI yet. DrumChopSession holds a loop's slice markers: detected ones follow the settings, markers the user adds or drags are pinned and never moved by them, and a detected marker the user removes leaves a veto so no setting brings it back. It works out the loop's own tempo from the clip (its beats over its seconds), runs the grouping on a captured input so it can go off the game thread and be cancelled, decides which sounds get pads (every sound by default, most used first past sixteen, numbered by first heard; one-off sounds left out when Keep one-off sounds is off), renders what the kit will sound like, and keeps an undo history of its own that leaves the song alone."
    },
    {
      "title": "The drum loop analysis is split into stages and its four swept constants become settings",
      "version": "2026.8.356",
      "date": "2026-09-30",
      "area": "Sound engine",
      "type": "Internal",
      "userFacing": false,
      "details": "For the Clip editor's Drum chop mode (#598). DrumLoopDecomposer is now Analyse (the mono mix and spectral flux, computed once), PickOnsets (cheap: re-run on every slider move), SnapToAttack (for a marker placed by hand) and Group (the chop-and-cancel grouping over ANY list of onsets, cancellable, and saying which chop each hit came from). The onset threshold, minimum gap, match tolerance and layer ceiling are a Settings record whose defaults are the swept values; the attack window and the 2 ms cancellation slide stay private on purpose. With the defaults the output is bit-identical to before (the golden test)."
    },
    {
      "title": "Drum loop analysis is pinned by a golden test before it is opened up for the Clip editor",
      "version": "2026.8.356",
      "date": "2026-09-30",
      "area": "Sound engine",
      "type": "Internal",
      "userFacing": false,
      "details": "Groundwork for the Clip editor's Drum chop mode (#598). DrumLoopDecomposerGoldenTests fingerprints today's chop-and-cancel decomposition bit for bit (every sound's samples, every hit's position and gain, the residual) on synthetic breaks at several tempos, captured before the method is split into stages and its swept constants become settings. The defaults must keep reproducing these fingerprints exactly."
    },
    {
      "title": "Tabbed windows: a calmer title bar with a grip to drag by, and inactive tabs that match the active one",
      "version": "2026.8.355",
      "date": "2026-09-30",
      "area": "UI",
      "type": "Improvement",
      "userFacing": true,
      "details": "Follow-up to the tabbed-window rework. The empty part of a tabbed window's title bar was either a black recess or the title bar's own accent colouring, and with the accent line running under the window buttons they looked like another tab. It is now one plain surface in the theme's colours behind both the empty space and the buttons, with a small dot grip beside the buttons and the move cursor to show where to drag the window. The active tab is the only accented one. Inactive tabs now use the same gradient as the active tab, dimmed, and no longer draw a bottom edge.",
      "tickets": [
        597
      ]
    },
    {
      "title": "Tabbed windows get their close and maximise buttons back, and tabs drag to reorder or tear off",
      "version": "2026.8.354",
      "date": "2026-09-30",
      "area": "UI",
      "type": "Improvement",
      "userFacing": true,
      "details": "When windows were tabbed together they lost their window buttons: every tab carried its own pin, pop-out and maximise, nothing sat at the far right, and dragging a tab only moved the whole window. Now a tabbed window keeps pin, maximise/restore and close at the far right like any window, and that close closes every tab (anything that asks before closing still asks). Each tab carries only its own close. Drag a tab sideways to reorder the tabs; drag it up or down about twice the tab's height to tear it off into its own window, which keeps following the pointer so you can drop it, dock it or merge it straight into another window. That replaces the pop-out button. Drag the empty strip beside the window buttons to move, dock or merge the whole tabbed window.",
      "tickets": [
        597
      ]
    },
    {
      "title": "Betas build faster: the heads build alongside the tests, and the preset checks sample",
      "version": "2026.8.353",
      "date": "2026-09-30",
      "area": "Build",
      "type": "Internal",
      "userFacing": false,
      "details": "The beta pipeline went from about 25 minutes to 32-47. Three changes. (1) build.yml: the five heads no longer wait for the test job; they start with it, and deploy-beta waits for both, taking 9-12 minutes of tests off the critical path in front of the ~21-minute web AOT. (2) test.sh runs the suite with --filter Category!=Probe: the per-synth level probes, the two ReportEveryPresetsHeadroom reports and BarAheadBisectTests (a per-channel bisect of the ShippedDemoBarAheadParityTests gate) print tables for a person writing a bank or chasing a parity failure and cost about 4 CPU-minutes a build; run them with --filter Category=Probe. (3) The render-heavy factory-bank checks (audible and no clipping, chord survival, no two presets alike) and HALL 9000's two render loops hear 5 presets drawn at random each run (PresetSample) instead of every preset of every bank; EZMUZE_ALL_PRESETS=1 sweeps every preset, and the seed is printed and replayable with EZMUZE_PRESET_SEED."
    },
    {
      "title": "Set note velocity by dragging a note's top or bottom edge, in the piano roll and the drum lanes",
      "version": "2026.8.352",
      "date": "2026-09-29",
      "area": "Sequencer",
      "type": "New feature",
      "userFacing": true,
      "details": "Notes always had a velocity (MIDI and REAPER imports carry it, and instruments play louder, brighter or with accents because of it) but there was no way to see it properly or change it: quieter notes were only drawn a little darker, and every new note was full velocity. Now a note's height is its velocity, in both the piano roll and the drum lanes: full height at 100%, a thin line at 0%, centred on its row. Drag a note's top or bottom edge (or just outside it, so even a very quiet note can be grabbed) to set it; a readout shows the value. With several notes selected, dragging one moves them all by the same amount. New notes start at 80% and then use the last velocity you set. One undo step per drag. In the piano roll, zoom in if the rows are too short to grab an edge.",
      "tickets": [
        586
      ]
    },
    {
      "title": "The sequencer's vertical scrollbar no longer ends up off the right edge after zooming rows or resizing",
      "version": "2026.8.351",
      "date": "2026-09-29",
      "area": "Sequencer",
      "type": "Bug fix",
      "userFacing": true,
      "details": "The sequencer hands the vertical scrollbar's strip back to the timeline when every channel fits, and takes it again when they overflow. It decided that from the sizes of the previous redraw and never checked again, so zooming the rows in (Ctrl+wheel) until they overflowed, or going straight from a maximised window to a smaller one, put the scrollbar just past the right edge, where it could not be seen or grabbed until the window was resized again. Going back left an empty strip where it had been. The sequencer now lays itself out again as soon as the scrollbar starts or stops being needed. tools/lua/sequencer-scrollbar-edge.lua checks all four cases in a running app.",
      "tickets": [
        583
      ]
    },
    {
      "title": "Dragging into the space under the last channel shows that it will make a new channel",
      "version": "2026.8.350",
      "date": "2026-09-29",
      "area": "Sequencer",
      "type": "Bug fix",
      "userFacing": true,
      "details": "Dragging a wave, sample, pattern or loop from the explorer into the empty strip under the last channel showed the drop landing on that last channel, with its plain name badge, but letting go made a new channel instead. The preview now tells the truth: while you hover in that strip it shows the new-channel ghost at bar 0, where the drop puts it, with the \"— new channel\" badge, and the existing rows dim. What the drop does is unchanged.",
      "tickets": [
        584
      ]
    },
    {
      "title": "On a song with many channels, the add-channel button can be scrolled into view again",
      "version": "2026.8.349",
      "date": "2026-09-29",
      "area": "Sequencer",
      "type": "Bug fix",
      "userFacing": true,
      "details": "The + button under the last channel was left out of the sequencer's scroll range. Scrolled all the way down, by wheel or scrollbar, it sat just below the edge of the list, and when the channels fitted the window it was cut in half. A song with more channels than the window could show therefore could not gain a channel. The scroll range and the scrollbar now run to the bottom of the + row, and channels that fit the window leave room for it, so it is always reachable. The same band is where you drop a sample or pattern to make a new channel, so that works on long songs too.",
      "tickets": [
        579
      ]
    },
    {
      "title": "The release pipeline keeps the last 30 betas on the CDN and refuses to run on a nearly full disk",
      "version": "2026.8.348",
      "date": "2026-09-29",
      "area": "Build",
      "type": "Internal",
      "userFacing": false,
      "details": "Every beta was kept on the CDN for ever; 205 of them at ~400-500 MB each filled the VPS disk to 100% on 2026-09-29, breaking bug-board uploads. deploy-beta.sh now keeps the newest EZMUZE_BETA_KEEP (default 30) version directories under beta/ after each successful deploy: version-sorted, only three-integer directory names (never feed/ or changelog.json), the just-published version protected, each removal logged, never fatal. Promotion is unaffected because promote.yml rebuilds from source and publish.sh copies its own stage. prepare.sh, deploy-beta.sh and publish.sh now fail early with a clear message when a volume they write to has under EZMUZE_MIN_FREE_GB (default 10) free, and prepare.sh prunes unused Docker build cache older than 72h box-wide (it had reached 36 GB, mostly from ezmuze-central's per-push image builds). tools/release/prune-betas.sh previews or runs retention by hand; test_prune_betas.sh tests it and runs in test.sh."
    },
    {
      "title": "The macOS build leaves out the Windows and Linux natives the full KNI dependencies bring",
      "version": "2026.8.347",
      "date": "2026-09-29",
      "area": "Build",
      "type": "Internal",
      "userFacing": false,
      "details": "After #578 made KNI's ThirdParty/Dependencies the full MonoGame.Dependencies submodule, the platform project's content also carried x86/ and x64/ natives (SDL2.dll, soft_oal.dll, libopenal.so) and MonoGame.Framework.dll.config into the macOS publish, and the Contents/MacOS Mach-O-only check failed the build (run 36569856069). build-head.sh now drops them after asserting the two dylibs are there; none is ever loaded on macOS."
    },
    {
      "title": "One KNI for local builds and CI: ezmuze/kni main",
      "version": "2026.8.347",
      "date": "2026-09-29",
      "area": "Build",
      "type": "Internal",
      "userFacing": false,
      "details": "Local builds compiled KNI from a fork branch (madninjaskillz/kni feature/raw-glsl-vis) while CI compiled a snapshot in ezmuze/kni, with no shared history, so the two could drift (#578). The fork's later work was replayed onto ezmuze/kni main: the Slang shader processor and its fixes, DX recording through Media Foundation, and the XAudio2 quit fix. ezmuze/kni now carries KNI's third-party code as submodules, as the fork did, so the content builder can be built from it. The local ../kni checkout tracks ezmuze/kni main, and the fork branch is an archive. The release build fetches those submodules itself (kni_submodules in tools/release/lib.sh, called from build-head.sh): the DX, macOS and web heads compile NVorbis and the Stb image code from them, and actions/checkout's deploy key cannot fetch other repos. Verified: all five heads build in Release from a fresh clone once the submodules are fetched; the merged content builder reproduces all 23 committed .xnb files byte for byte; the DX and DesktopGL apps play, show the welcome video and quit cleanly.",
      "tickets": [
        578
      ]
    },
    {
      "title": "Quitting the Windows app while audio plays no longer occasionally hangs",
      "version": "2026.8.347",
      "date": "2026-09-29",
      "area": "Desktop",
      "type": "Bug fix",
      "userFacing": true,
      "details": "About one quit in eight with sound playing could leave the DirectX build stuck in the background instead of closing. The cause was in the audio library underneath the app (KNI's XAudio2 back end). The callback XAudio2 makes when a buffer finishes took a lock that the code tearing a voice down already held, while that teardown waited for the callback's thread to go idle, so the two waited on each other forever. The callback now waits at most 2 ms for the lock and otherwise hands the buffer end to the next frame's update, so no audio is lost and nothing can deadlock. The same deadlock could also freeze the app for good mid-session, when one sound was replaced while another played. The app side of orderly quitting shipped earlier (#549); this is the library half, now in the KNI build CI uses.",
      "tickets": [
        555
      ]
    },
    {
      "title": "Removing a module closes its panel and UI editor",
      "version": "2026.8.346",
      "date": "2026-09-29",
      "area": "UI",
      "type": "Bug fix",
      "userFacing": true,
      "details": "Deleting a device from the Stack (or undoing the step that added it, or deleting its whole channel) left its custom UI panel and its UI editor open on a device that no longer existed, along with any sample or content picker opened from that panel. They now close as the device goes. The module builder already did this.",
      "tickets": [
        577
      ]
    },
    {
      "title": "The mouse wheel over the piano roll's keyboard scrolls up and down",
      "version": "2026.8.346",
      "date": "2026-09-29",
      "area": "UI",
      "type": "Bug fix",
      "userFacing": true,
      "details": "Over the piano keys at the left of the piano roll, the mouse wheel did the grid's gesture, zooming time, which does nothing useful there and nothing at all once the view is zoomed right out, so the scrollbar was the only way up and down. It now works like the sequencer's track headers: the wheel over the keys scrolls up and down, and Ctrl+wheel there makes the rows taller or shorter. Over the grid nothing changed: the wheel zooms time, Shift+wheel scrolls and Ctrl+wheel zooms the rows.",
      "tickets": [
        576
      ]
    },
    {
      "title": "The drum lanes select, move and resize like the piano roll",
      "version": "2026.8.345",
      "date": "2026-09-29",
      "area": "UI",
      "type": "Improvement",
      "userFacing": true,
      "details": "The drum lane editor lagged behind the piano roll. Right-drag now draws a box that selects every hit it touches (Shift adds to the selection), and dragging a selected hit moves the whole selection together, keeping its shape at the edges. A hit's end now shows a resize pointer and drags to lengthen or shorten it, and dragging the end of a selected hit resizes them all by the same amount. That matters because Capacitor stops a sample when its hit ends, so a crash or a long 808 needs a long hit to ring. A clicked hit is now a 16th instead of a whole bar. Dragging no longer rebuilds the whole grid on every step, and the pattern's waveform re-renders once when you let go rather than on every step (see the entry on pattern editors).",
      "tickets": [
        574
      ]
    },
    {
      "title": "Dragging notes no longer re-renders the pattern on every step, in any pattern editor",
      "version": "2026.8.345",
      "date": "2026-09-29",
      "area": "Sequencer",
      "type": "Performance",
      "userFacing": true,
      "details": "Moving a note re-rendered the pattern's waveform and ring-out on every step of the drag: 52 renders for an eight-second drag in the drum lanes, and 83 for the same drag in the piano roll while the song played (the piano roll waited only while stopped; playback bypassed it, #575). Edits now park the render until the drag ends, or until 400 ms after the last edit for key nudges and undo, and queue it once; play, save and closing the editor queue it at once. The same drags now queue 2 to 4 renders. What you hear still follows the drag: an edited bar plays its instrument live until the new render lands. The pattern's old ring-out is dropped the moment it is edited, so it can no longer play the notes you just moved. The wave picker uses the same rule. /stats patternRenders.deferred shows what is parked.",
      "tickets": [
        575
      ]
    },
    {
      "title": "When the audio device runs dry, the log says whether a garbage collection was the cause",
      "version": "2026.8.344",
      "date": "2026-09-29",
      "area": "Desktop",
      "type": "Internal",
      "userFacing": false,
      "details": "A single 5 ms output-starved event on Elevation (#573) could not be reproduced in 96 further plays of the four demos straight after loading them, a third of them with the full test suite running as CPU load. Each output-starved event now logs one line: how long the device ran dry, the time since the last buffer against the audio the device held, and the garbage collections (by generation) and GC pause time in that gap. The counters are read at every submit, which costs nothing; only the line itself allocates, and only when the device ran dry (the first 20, then every 100th)."
    },
    {
      "title": "No more dropout in the first moment of playback straight after opening a song",
      "version": "2026.8.344",
      "date": "2026-09-29",
      "area": "Desktop",
      "type": "Bug fix",
      "userFacing": true,
      "details": "Pressing play straight after opening a heavier song could drop out for about 20 ms right at the start, and /stats counted one audio underrun each time (three of the four demos on the release smoke test). The output device asks for its first three buffers the moment it starts, and the render-ahead thread had not rendered them yet; the fallback that renders on the spot could not get at the renderer while that thread was busy with the song's first, slowest render, and played silence instead. Play now waits until those three buffers are ready before starting the device: 1 to 28 ms on the demos straight after loading, capped at 250 ms, after which it starts anyway as before. After the fix, 24 plays of the four demos straight after loading counted no underruns. A repeatable check is in tools/lua/start-of-play-underruns.lua.",
      "tickets": [
        572
      ]
    },
    {
      "title": "Duplicating or inserting bars inside a long clip splits it instead of stacking the copy on top",
      "version": "2026.8.343",
      "date": "2026-09-29",
      "area": "Sequencer",
      "type": "Bug fix",
      "userFacing": true,
      "details": "Duplicate, Insert empty bars and Paste insert open a gap and push the rest of the selected channels right, but a clip crossing the insert point was left whole, so the inserted material landed on top of its second half: duplicating bars 4-8 of the Synth demo's Bass and Arp left their 4-14 clips in place with the copy at 8-12 on top (#569). A clip crossing the point is now split there, the way Split at playhead splits: the head stays, the tail (playing on from where the head stopped, a sample clip's fades on the outer ends) moves with everything else. Clips that end or start at the point are not split, and Cut-collapse never splits. The ripple edits moved into Ezmuze.Core (RippleEdits, with RippleMath.Straddlers) so the whole edit is tested; Split at playhead uses the same split. The decision is in design-guide.md.",
      "tickets": [
        569
      ]
    },
    {
      "title": "Build hygiene finished: nullable and the correctness analyzers are enforced everywhere",
      "version": "2026.8.343",
      "date": "2026-09-29",
      "area": "Build",
      "type": "Internal",
      "userFacing": false,
      "details": "The last step of #451: analyzer rule CA2211 (no public mutable static fields) is switched on and is a build error. Three injection seams became properties: LocalFolderService.OsSearch, DroppedFileWaveSource.AssetResolver and GameThreadSyncContext.ContinuationFailed. The audio engine's 24 diagnostic switches and counters stay fields, each suppressed with its reason: 10 are [ThreadStatic], two are updated through Interlocked, and all are read on the render path. With this, everything the ticket asked for is in place. Nullable warnings are errors in every project (the app heads since the rollout, and now the audio engine, the Splice plugin, the tests and every tool). The four correctness analyzers (CA1305, CA1310, CA1001, CA2211) are errors. The broad Recommended analyzer set stays off by decision (docs/build-hygiene.md section 3). .editorconfig and central package management were already in place. docs/build-hygiene.md records the counts and the method.",
      "tickets": [
        451
      ]
    },
    {
      "title": "Types holding disposable fields are checked at build time",
      "version": "2026.8.343",
      "date": "2026-09-29",
      "area": "Build",
      "type": "Internal",
      "userFacing": false,
      "details": "Part of #451: analyzer rule CA1001 (a type holding a disposable field should itself be disposable) is switched on and is a build error. None of its 23 findings was a leak. 19 are views holding GustUI elements, whose Dispose is empty because the element tree owns them. ExternalCallThread's worker keeps its wake event for the life of a thread that may be abandoned mid-call. ListingGate and the wave browser leave superseded CancellationTokenSources to the GC, because in-flight work still holds their tokens. The web page owns the game for the life of the tab. Each is suppressed on its type with that reason, so a new disposable field on any other type fails the build."
    },
    {
      "title": "String prefix and suffix checks say how they compare",
      "version": "2026.8.343",
      "date": "2026-09-29",
      "area": "Build",
      "type": "Internal",
      "userFacing": false,
      "details": "Part of #451: analyzer rule CA1310 is switched on and is a build error. The five app checks it found were machine text compared culture-sensitively and were fixed under #568. The remaining 50 (39 in the tests, 11 in tools) compare ASCII literals against machine or test strings and now say StringComparison.Ordinal. A new StartsWith, EndsWith or IndexOf(string) without a comparison now fails the build."
    },
    {
      "title": "Every number and date the code formats now says which culture it uses",
      "version": "2026.8.343",
      "date": "2026-09-29",
      "area": "Build",
      "type": "Internal",
      "userFacing": false,
      "details": "Part of #451: analyzer rule CA1305 is switched on and is a build error. Its 239 findings (134 in the app and libraries, 36 in the tests, 69 in tools) now pass a culture explicitly. Text a person reads keeps the user's culture, now written as CultureInfo.CurrentCulture, so nothing on screen changes. Machine text (hex, ids, cache keys, stored integers) says CultureInfo.InvariantCulture, chosen only where the output is identical in every culture. The six sites where the culture really did change a machine format (the crash reporters' time stamps) were fixed separately under #568. A new ToString, Parse or string.Format without a culture now fails the build."
    },
    {
      "title": "Crash reports and a few path and command-line checks no longer depend on your language settings",
      "version": "2026.8.343",
      "date": "2026-09-29",
      "area": "Core",
      "type": "Bug fix",
      "userFacing": false,
      "details": "Found while switching on the culture analyzers (#451). The desktop, Linux and Mac crash reporters stamped the report and its crash-<time>.txt file name using the user's culture, so a locale with a different calendar or time separator (Thai gives a Buddhist-era year) produced names and times in that form; they now use the invariant culture. Five prefix checks on machine text (a legacy pack's loop file names, a local-folder key's quality suffix, a typed '~/' path, and the desktop command line's leading '--') used culture-sensitive StartsWith, which ignores zero-width and soft-hyphen characters; they are now ordinal. English locales behave exactly as before.",
      "tickets": [
        568
      ]
    },
    {
      "title": "Nullable warnings are errors in the audio engine, the Splice plugin and the tests too",
      "version": "2026.8.343",
      "date": "2026-09-29",
      "area": "Build",
      "type": "Internal",
      "userFacing": false,
      "details": "Part of #451: the last projects that carried nullable warnings as warnings, Ezmuze.Audio (21), Ezmuze.Services.Splice (86), Ezmuze.Tests (221) and tools/GpuDspSpike (5), are cleared and no longer opt out, so a nullable warning is now an error in every project on nullable enable. Annotations only, as in the app rollout: a '?' where a value can be null (the render path's optional RenderSpans, a Splice lookup that misses, an 'out string failure' that is null on success), [NotNullWhen(true)] on the Try methods, '!' where the code already assumed a value, and in the tests '!' on the nulls passed on purpose to check a method copes. Ezmuze.Audio.Vst stays on nullable disable; its files now say so with #nullable disable, because Ezmuze.Tests compiles them directly. Every head and tool builds with no new warnings and the full suite passes."
    },
    {
      "title": "Light theme: automation lanes sit on the light timeline instead of a black bar",
      "version": "2026.8.343",
      "date": "2026-09-29",
      "area": "UI",
      "type": "Bug fix",
      "userFacing": true,
      "details": "In the light theme an expanded channel's automation lane strips were near-black bars across the white timeline. They now take the light theme's panel colour with the lane's tint, and follow a live theme switch. The dark theme looks exactly as it did.",
      "tickets": [
        571
      ]
    },
    {
      "title": "File > Store... brings the store forward when it is already open",
      "version": "2026.8.343",
      "date": "2026-09-29",
      "area": "UI",
      "type": "Bug fix",
      "userFacing": true,
      "details": "Clicking the sequencer while the store was open put the store behind it, and choosing File > Store... again then did nothing you could see. It now brings the open store back to the front, as Preferences does.",
      "tickets": [
        570
      ]
    },
    {
      "title": "A pattern on two channels rings out with each channel's own instrument",
      "version": "2026.8.342",
      "date": "2026-09-29",
      "area": "Sound engine",
      "type": "Bug fix",
      "userFacing": true,
      "details": "A pattern's release tail (the generator's ring-out after the pattern's last bar, cached so it can be played without running the synth) was stored under the pattern alone, so a pattern placed on two channels with different instruments had ONE tail, rendered through one channel's instrument and played on both (#566): The Dark's Bass Line and Bass Hold C sit on Bass Deep and Bass Warm. Tails are now keyed per channel the way #563 keyed the waveforms: the pattern's home channel (the first that places it) keeps the pattern's id, so saved render caches and every song without a shared pattern are unchanged, and any other channel renders and reads a tail of its own (PatternFaces.TailKey; NaiveSongRenderer.PatternTailKey reads it by the same key). Measured on The Dark: its playback and export are bit-identical either way, because a channel that is not the pattern's home plays its synth live and rings out on its own; the cached tail is what a non-real-time trigger-FX region reading past the clip (the channel PCM window) consumes, and that now hears the right instrument. Covered by SharedPatternTailTests (two channels, two instruments, each reads its own ring-out; a second channel no longer reads the home's). RenderDemoSong gained --app-caches per-channel|shared to publish bodies and tails the way the app does, for measuring it.",
      "tickets": [
        566
      ]
    },
    {
      "title": "REAPER import brings a Kilohearts Shaper across with its drawn curve",
      "version": "2026.8.342",
      "date": "2026-09-29",
      "area": "Modules",
      "type": "New feature",
      "userFacing": true,
      "details": "A kHs Shaper (on its own, in Multipass or Snap Heap, or in a Phase Plant lane) now imports onto the built-in Shaper instead of a best guess. Its drawn curve is decoded from the plugin's saved state and redrawn on the Shaper's points, matching the plugin's to within what an ear could tell (the report says so if it cannot); drive, the loop mode (as Overflow: hold, mirror, repeat), the DC filter and the mix carry exactly, and envelopes on Drive, Loop mode, DC Filter and Mix become lanes. How the plugin's curve behaves was measured by rendering through it: each segment bends by the two slopes saved with the point it leaves. With this, every Kilohearts snapin that has a counterpart in the built-in effects lands on it; only the Vocoder, which has none, is still left out.",
      "tickets": [
        504
      ]
    },
    {
      "title": "The Shaper's Mix blends against a dry signal delayed to match, so part-wet does not comb",
      "version": "2026.8.342",
      "date": "2026-09-29",
      "area": "Sound engine",
      "type": "Improvement",
      "userFacing": false,
      "details": "The Shaper runs 4x oversampled and holds one more sample so Repeat's wraps can be smoothed on both sides, which makes its output 42 samples late. It now declares that latency through the lookahead contract (#500), as the Distortion does since #554: the device's Mix delays its dry path by the same 42 samples, and in the song the chain in front of the Shaper is rendered 42 samples early so it lands on time. It had not shipped yet, so no user ever heard the comb; nothing shipped uses fx.shaper, so every preset and demo is unchanged."
    },
    {
      "title": "A Shaper: draw the curve your sound is bent through",
      "version": "2026.8.342",
      "date": "2026-09-29",
      "area": "Modules",
      "type": "New feature",
      "userFacing": true,
      "details": "The built-in effects suite gets its Shaper (in the Distortion folder): a waveshaper whose curve you draw. The display is the curve and the editor at once - drag a point to bend it, click empty space to add one, drag a point out of the box to take it away, the same gestures as an LFO shape, up to 16 points with the ends held at the edges. Drive (-6 to +20 dB) pushes the sound along the curve, and Overflow decides what happens past its ends: Hold stays at the end, Mirror folds back along the curve, Repeat wraps round to the other end. A DC filter, on by default, takes out the offset a lopsided curve leaves, and silence stays silence whatever you draw. It runs 4x oversampled, and Repeat's wraps are band-limited, so bright material stays clean. While the song plays a dot shows where the sound is reaching on the curve. The Stack card shows Drive. Ranges and modes follow Kilohearts Shaper. Behind it: a new fx.shaper primitive; the curve is stored on the device like an LFO's drawn shape, and it is heard from the next bar after an edit.",
      "tickets": [
        529
      ]
    },
    {
      "title": "REAPER import: a Kilohearts Dual Delay keeps both of its delay lines",
      "version": "2026.8.341",
      "date": "2026-09-29",
      "area": "Import",
      "type": "Improvement",
      "userFacing": true,
      "details": "A Kilohearts Dual Delay in an imported REAPER project now lands on the new Dual delay with both lines: time (or its synced note value), ratio, feedback, crosstalk, spread, tone, duck and mix, and its automation of each. Before, it became the single-line Delay and the second line, its crosstalk and its spread were reported as left out. The plugin's saved settings and automation laws were measured on the installed plugin; its ratio (0.2 to 2) fits inside the Dual delay's, whose range became 0.2 to 5 to take it."
    },
    {
      "title": "A new Dual delay: two delay lines at different times that can feed each other",
      "version": "2026.8.341",
      "date": "2026-09-29",
      "area": "Sound engine",
      "type": "New feature",
      "userFacing": true,
      "details": "Dual delay (in the Delay folder) runs two delay lines: the left at Time and the right at Time x Ratio (a fifth to five times, starting at 1.618). Crosstalk feeds each line's repeats into the other, so the echoes weave between the sides; Spread sets how far apart the two lines sit, from both in the middle to one on each side. Feedback, Tone and Duck work as on the Delay, Sync swaps Time for a note value at the song's tempo, and moving Time or Ratio while it plays glides rather than clicks. The display draws both lines' echoes, the left line above and the right below, and the readout shows both times. The Stack card has Time (or Note), Feedback and Crosstalk after the Mix, which starts at 35%. Ranges match Kilohearts Dual Delay so REAPER imports can map onto it. It runs on the same engine primitive as the Delay, which gained Ratio and Crosstalk inputs that leave the Delay unchanged at their defaults.",
      "tickets": [
        533
      ]
    },
    {
      "title": "Nullable checking is on for the whole app, and a new warning fails the build",
      "version": "2026.8.341",
      "date": "2026-09-29",
      "area": "Build",
      "type": "Internal",
      "userFacing": false,
      "details": "The last step of the nullable rollout (#451, docs/build-hygiene.md section 5): the five app heads (DX, DesktopGL, Linux, Mac, web) switch from <Nullable>annotations</Nullable> to <Nullable>enable</Nullable>, so Directory.Build.targets now treats a nullable warning in app code as an error in every head. The 270 per-file #nullable enable directives are removed in the same change; the one left is ClipGlueService.cs, which tools/Vst3Probe also compiles with nullable disabled. Every head builds with 0 errors and no new warnings, and the full suite passes. What #451 still lists (Ezmuze.Tests, Ezmuze.Audio and Splice clearing their own nullable warnings, and the stricter analyzer rules) is not part of this change."
    },
    {
      "title": "Nullable annotations for the sequencer's new row and block classes, and the buddy list",
      "version": "2026.8.341",
      "date": "2026-09-29",
      "area": "Build",
      "type": "Internal",
      "userFacing": false,
      "details": "Seventh batch of the nullable rollout (#451): code that landed on main while the rollout was under way. SequencerGutterRows and SequencerBlockBinder (split out of SequencerView), BuddyHost, BuddiesModal and ControlCommandExecutor.Buddies now open with #nullable enable, and the already-enabled files main changed build clean again. Annotations only: the host interfaces say ChannelAt and the gutter's edit history can be null, the explicit implementations of the fonts and colour tables say what the code already assumed with '!', the buddy source returns a nullable pair when there is no account, and the demo transport's SendAsync matches the interface's Task<BuddyMessage?>. 107 warnings to 0 with nullable on in every head; the heads' own counts unchanged; Ezmuze.Tests stays at 221."
    },
    {
      "title": "Nullable annotations for the desktop and web heads' own code",
      "version": "2026.8.341",
      "date": "2026-09-29",
      "area": "Build",
      "type": "Internal",
      "userFacing": false,
      "details": "Sixth batch of the nullable rollout (#451): the files each head compiles on its own now build with no nullable warnings when nullable is switched on for that head: Ezmuze.Studio.Desktop (control server, file drag, audio capture, the audio bridge, the crash reporter, the quit guard and the rest, shared by DX, DesktopGL, Linux and Mac), the Linux and Mac crash reporters and file-drag stubs, and the web head's worker bridges, Program and Index page. Annotations only: event handlers take object? sender as the events declare, TextWriter overrides take string?, the drag callbacks are nullable delegates on every platform (they were always invoked with ?.), fields the web worker clears to null are marked and a '!' records each use that already assumed them set. With nullable on, the app's own warnings are now 0 in all five heads (DX 72, Linux 46, Mac 46, web 18 before); Ezmuze.Tests 241 to 221."
    },
    {
      "title": "Nullable annotations for the game class, and all of the shared app code",
      "version": "2026.8.341",
      "date": "2026-09-29",
      "area": "Build",
      "type": "Internal",
      "userFacing": false,
      "details": "Fifth batch of the nullable rollout (#451): EzmuzeStudioGame.cs, and the 29 files in Ezmuze.Studio.Shared that never had a warning, now open with #nullable enable, so every file in Ezmuze.Studio.Shared does, and all of them build clean in every head. Annotations only: the song-open path says which of its local path, service, service path and callback can be null, importers are Func<..., Task<Song?>> because an importer can come back empty (RunImportAsync already checked), the control-API pass-throughs return string? and nullable tuples, and a '!' records each place the code already assumed the window, the song or the legacy data. SongLoadCard.Show's onCancel and RecentProjects.Remember's serviceName, which already handled null, say so. Batch 186 to 0; the app's own warnings with nullable on for the whole DX head 258 to 72, all of them in the desktop head's own files; the heads' own counts are unchanged and Ezmuze.Tests stays at 241."
    },
    {
      "title": "Nullable annotations for the biggest screens",
      "version": "2026.8.341",
      "date": "2026-09-29",
      "area": "Build",
      "type": "Internal",
      "userFacing": false,
      "details": "Fourth batch of the nullable rollout (#451): the last 13 files in Ezmuze.Studio.Shared/Screens (SequencerView and its Lanes and Rows partials, ModuleEditorView, StackPanelView, SampleClipEditorPanel, the wave browser and picker, the module about, publish and file-browser modals, InstalledTab and ModulePrepareProgress) now open with #nullable enable, so every Screens file does, and all of them build clean in every head. Annotations only. Control-API helpers that return null for success say string?, lookups that can miss return a nullable type, the wave browser's cache lookup carries [NotNullWhen(true)], the two TruncateToWidth helpers carry [NotNullIfNotNull], fields the sequencer clears are marked '?', control delegates that are always set say '= null!', and a '!' records each place the code already assumed a value. Batch 804 to 0; the app's own warnings with nullable on for the whole DX head 1,048 to 258 (what is left is EzmuzeStudioGame.cs and the desktop head's own files); the heads' own counts are unchanged (DX 105, DesktopGL 107, Linux 21, Mac 22, web 22, plus GustUI's three) and Ezmuze.Tests stays at 241."
    },
    {
      "title": "Nullable annotations for the middle-sized screens",
      "version": "2026.8.341",
      "date": "2026-09-29",
      "area": "Build",
      "type": "Internal",
      "userFacing": false,
      "details": "Third batch of the nullable rollout (#451): 29 more files in Ezmuze.Studio.Shared/Screens (the SequencerView partials up to about 40 warnings each, Toasts, the pack store views, the preset browser, the piano roll, the pattern explorer and others) now open with #nullable enable and build clean in every head. Annotations only. Return types that do return null say so (Toasts.Show and Keep return Toast?, several SequencerView helpers return Channel?), tuple results carry their nullable parts, fields the sequencer sets back to null are marked, and a '!' records each place the code already assumed a value. Batch 542 to 0; the app's own warnings with nullable on for the whole DX head 1,570 to 1,048; the heads' own counts DX 109 to 105, DesktopGL 108 to 107, Linux 22 to 21, Mac 23 to 22, web 23 to 22; Ezmuze.Tests 242 to 241."
    },
    {
      "title": "Nullable annotations for the smaller screens",
      "version": "2026.8.341",
      "date": "2026-09-29",
      "area": "Build",
      "type": "Internal",
      "userFacing": false,
      "details": "Second batch of the nullable rollout (#451): the 62 files in Ezmuze.Studio.Shared/Screens with ten warnings or fewer each now open with #nullable enable and build clean in every head. Annotations only, as in the first batch; UI fields that a builder the constructor calls always sets say so with '= null!', parameters whose method already copes with null say so with '?', and a few call sites that pass an honestly nullable song or history to a callee that always had to cope get '!'. Batch 253 to 0; the app's own warnings with nullable on for the whole DX head 1,843 to 1,570; the heads' own counts DX 132 to 109 (plus three from GustUI, which moved independently), DesktopGL 131 to 108, Linux 45 to 22, Mac 46 to 23, web 46 to 23; Ezmuze.Tests 257 to 242."
    },
    {
      "title": "Nullable annotations for the app's data layer",
      "version": "2026.8.341",
      "date": "2026-09-29",
      "area": "Build",
      "type": "Internal",
      "userFacing": false,
      "details": "First batch of the nullable rollout (#451, docs/build-hygiene.md section 5): every file in Ezmuze.Studio.Shared/Data now opens with #nullable enable and builds with no nullable warnings in any head (DX, DesktopGL, Linux, Mac, web). Annotations only: a '?' where a value really can be null (a store read, a picker that was cancelled, a lookup that missed), [NotNullWhen] on the Try methods, [NotNullIfNotNull] where null in means null out, a '!' where the code already guarantees a value the compiler cannot see (a guard earlier in the method, a setting always set by its initializer), and '= null!' on the few members that are always assigned before use (SongRepository.Song, the control API's command path and method, a setting descriptor's delegates). No null checks were added and no behaviour changed. A handful of callees that already handle null got '?' on the parameter, including two in Ezmuze.Legacy and one in Ezmuze.Audio. With nullable switched on for the whole DX head, the app's own warnings fell from 2,245 to 1,843 (Data 421 to 0; Screens rose from 1,570 to 1,595 as callers now see honest signatures); the heads' own warning counts fell (DX 138 to 132, DesktopGL 137 to 131, Linux 51 to 45, Mac 52 to 46, web 52 to 46), and Ezmuze.Tests from 297 to 257."
    },
    {
      "title": "Dialogs left open follow a switch between light and dark too",
      "version": "2026.8.341",
      "date": "2026-09-29",
      "area": "UI",
      "type": "Bug fix",
      "userFacing": true,
      "details": "After #565 the main screens followed a live theme switch, but a dialog that was already open kept its text, text fields and some button labels in the old theme until it was reopened: Preferences, New song, the song picker, the publish wizard, Licences, Share, and about fifty others. Their text helpers took a copy of the theme colour; they now read it as they draw, and text fields do the same. A colour that is not the theme's (an error status in red) is unchanged.",
      "tickets": [
        567
      ]
    },
    {
      "title": "Switching between light and dark now changes the whole screen straight away",
      "version": "2026.8.340",
      "date": "2026-09-29",
      "area": "UI",
      "type": "Bug fix",
      "userFacing": true,
      "details": "Switching theme while the app was open left parts of it in the old theme until they were reopened. The most visible was the sequencer's title strip, which stayed dark after a switch to light; others were window title bars and tab strips, dialog bodies and footers, the welcome screen's buttons and banners, the preset browser, the pattern explorer's cards, the Stack's cells and the mixer strip. About 330 places took a copy of a theme colour when they were built; they now read it as they draw. The theme's shared button styles are updated in place, so every existing button follows too. Separately, the \"No custom UI yet\" message on a device panel was near-black text on a near-black box in the light theme; the panel itself stays dark in both themes, so its text now does too.",
      "tickets": [
        565
      ]
    },
    {
      "title": "A pattern on two channels shows each channel's own waveform, and an unfocused window no longer lights the top-left corner",
      "version": "2026.8.339",
      "date": "2026-09-29",
      "area": "UI",
      "type": "Bug fix",
      "userFacing": true,
      "details": "Two things made the sequencer draw the same song differently from one run to the next (#563). One pattern placed on two channels (The Dark's Bass Line and Bass Hold C, on both Bass Deep and Bass Warm) had ONE waveform face keyed by the pattern, so the channels overwrote each other's render on every bind and both showed whichever instrument finished last. The first channel that places a pattern now keeps its id (so the saved render cache, the audio substrate and the tail are unchanged) and any other channel draws from a face of its own (PatternFaces, tested). Separately, in GustUI, while the window was not focused the frozen pointer position was divided by the display scale again on every frame, so at 150% it slid into the top-left corner within a second and lit the sequencer's corner resize handle; the frozen position is now kept as it was (InputManager.PointerForFrame, tested). The shared pattern's release TAIL is still one per pattern in the audio path; that is #566.",
      "tickets": [
        563
      ]
    },
    {
      "title": "Lookahead: the cases still left uncompensated are assessed and written down",
      "version": "2026.8.339",
      "date": "2026-09-29",
      "area": "Sound engine",
      "type": "Internal",
      "userFacing": false,
      "details": "#562, part 3 (docs/lookahead.md §8). The five cases a lookahead effect still runs late in, each with why it is not cheap to compensate exactly and what it would take: a group bus that plays its children's notes, a channel keyed by a source that depends on it (a cycle, latent by design), a device after the lookahead device on the master, an instrument channel with audio clips, and TFX lanes (the cheapest of the five). Documentation only; no code changed.",
      "tickets": [
        562
      ]
    },
    {
      "title": "Channels under a lookahead bus or feeding a lookahead sidechain are no longer rendered twice",
      "version": "2026.8.339",
      "date": "2026-09-29",
      "area": "Sound engine",
      "type": "Performance",
      "userFacing": false,
      "details": "#562, part 2 (docs/lookahead.md §7). A channel a lookahead lane renders ahead (a group bus's child, a sidechain source) used to be rendered a second time on time for its own output and meters, doubling its CPU. The lane now also keeps each such channel's finished output, and the song copies it back at the heard position instead of rendering it again: the mix, level meters, group sums, sidechain peaks and keyed channels read exactly what they would have. The channel's own meters and displays read the lane's graphs, a fraction of a millisecond early; its CPU badge reads 0% because the cost is spent in the lane. Null tests stay bit-exact, and the channel VUs match block by block."
    },
    {
      "title": "Channels rendered ahead for a lookahead bus or sidechain play from the same caches as the rest of the song",
      "version": "2026.8.339",
      "date": "2026-09-29",
      "area": "Sound engine",
      "type": "Improvement",
      "userFacing": false,
      "details": "#562, part 1 (docs/lookahead.md §7). A lookahead lane (the second renderer that runs a group bus's children, or a sidechain source, ahead of the song) now reads the same host-supplied audio its owner does, by reference: decoded loops, wave-bank decodes and fits, pattern bodies and tails, sample-clip laps, and the owner's cacheless and deferred-loading choices. A channel it renders ahead therefore plays from the cached body exactly when the song does, instead of re-rendering it live, and matches the song exactly in the app as it already did offline. Its own merged-bar cache stays its own, so it never changes what the song renders. Tests use bodies unlike the live render, so a lane that played live would be plainly wrong."
    },
    {
      "title": "Light theme: clip waveforms are visible again",
      "version": "2026.8.339",
      "date": "2026-09-29",
      "area": "UI",
      "type": "Bug fix",
      "userFacing": true,
      "details": "In the light theme, pattern clips in the sequencer showed as flat grey blocks. Their waveform was drawn in a lighter shade of the channel colour, which only contrasts with a face on a dark lane; on the light lane it landed on almost the same grey as the face. The waveform's shade is now chosen from how the face looks over the current theme's lane: the dark theme is unchanged, and the light theme draws the waveform in a darker shade of the channel colour. Switching theme now re-draws the clip faces straight away instead of at the next edit or scroll.",
      "tickets": [
        564
      ]
    },
    {
      "title": "A test song with every built-in effect, the one the web check plays",
      "version": "2026.8.339",
      "date": "2026-09-29",
      "area": "Build",
      "type": "Internal",
      "userFacing": false,
      "details": "EffectsSuiteSongTests builds the synth demo with all 28 built-in effects dealt round its channels (a few part-wet, and the Reverb's Mix on a lane), round-trips it through the serializer, and renders twenty seconds the way the web's audio worker does (44.1 kHz, taps fed, every channel live), requiring finite, non-silent output. With EZ_FXSONG_OUT set it also writes the song, a heavier variant with two more Parametric EQs, Reverbs, Distortions and Limiters per channel, and the peak of every 2048-frame block, which is the desktop half of the desktop-versus-web parity spot check. Web check of 2026-09-29 (AOT Release, headless Edge): the song loads and plays with no exceptions; the worker renders a 46 ms block in 6 ms on average (p99 11 ms) with no underruns; the heavy variant averages 23 ms (p95 45 ms) with no underruns; 430 of 431 block peaks match the desktop exactly and the other is one 16-bit step off; the Parametric EQ spectrum, the Gate and Compactor curves and meters, and the header meters all read live."
    },
    {
      "title": "The sequencer's gutter rows and block binder are their own classes now",
      "version": "2026.8.338",
      "date": "2026-09-29",
      "area": "UI",
      "type": "Internal",
      "userFacing": false,
      "details": "#452 step 2. Two pieces of SequencerView became real classes, moved rather than rewritten, each reaching the view only through a narrow interface: SequencerGutterRows (building and placing each channel header in the gutter, pulling its knobs and toggles back from the song, the per-frame meters, CPU and voice badges; ISequencerGutterHost) and SequencerBlockBinder (the pooled block views, BindBlock with its loop outline, mini-roll, edit mark, tail ghost and trigger-FX indicators, and the selection dimming; ISequencerBlockHost). Every moved line is the old one with the view's members reached through the interface, and SequencerView keeps forwarding members so its callers read as before. Nothing a user sees changes.",
      "tickets": [
        452
      ]
    },
    {
      "title": "Kilohearts Pitch Shifter, Resonator, Reverser and Tape Stop come in from REAPER on the new built-in effects",
      "version": "2026.8.338",
      "date": "2026-09-29",
      "area": "Import",
      "type": "New feature",
      "userFacing": true,
      "details": "A REAPER project using kHs Pitch Shifter, Resonator, Reverser or Tape Stop (on its own, or inside Multipass, Snap Heap or Phase Plant) used to lose them. Each now lands on the matching built-in effect with its settings: the shift, jitter, grain and mix; the ring's note, decay (as the ring time, measured on the plugin), intensity and mix; the reversed segment length or note value, crossfade and mix; the tape's Play switch, stop and start times and the same curve. Automation on their parameters becomes lanes, read on the plugins' own dial laws. What does not fit is listed in the import report: grains longer than 100 ms, a note outside C1-C7, the Resonator's square timbre, the Pitch Shifter's correlated mode, segments over 2 s, a stop shorter than 20 ms. The Shaper is still to come."
    },
    {
      "title": "The Tape stop's spin-up bends the way Kilohearts Tape Stop's does",
      "version": "2026.8.338",
      "date": "2026-09-29",
      "area": "Sound engine",
      "type": "Improvement",
      "userFacing": true,
      "details": "Measured on the plugin, a spin-up is its stop run backwards: a Curve that holds the speed up through a stop gets back up to speed at once. The built-in Tape stop bent its spin-up the opposite way; it now matches, so a Kilohearts Tape Stop imported from REAPER starts up as it did there. A straight Curve (0%) is unchanged, and so is every stop."
    },
    {
      "title": "Lookahead effects stay exactly in time across channels: group buses and sidechained channels are compensated too",
      "version": "2026.8.338",
      "date": "2026-09-29",
      "area": "Sound engine",
      "type": "Improvement",
      "userFacing": true,
      "details": "The last part of rendering upstream early (#500, docs/lookahead.md). An effect that looks ahead (the Distortion, the Ladder filter, and the lookahead limiter and pitch shifter to come) now lands on time wherever it sits: on a channel, on an audio track, at the end of the master, on a group bus, and on a channel sidechained by another. For a bus or a sidechain the channels feeding it are rendered ahead as well, so nothing is shifted against anything else and nothing is heard late. The remaining cases say on the device's Stack card why they are not compensated (a bus with an instrument on it, a channel keyed by a source that depends on it, a device after a lookahead device on the master, an instrument channel with audio clips, TFX lanes). Songs without such an effect render exactly as before.",
      "tickets": [
        500
      ]
    },
    {
      "title": "Lookahead on group buses: the children feeding a bus's lookahead device are rendered ahead, exactly",
      "version": "2026.8.338",
      "date": "2026-09-29",
      "area": "Sound engine",
      "type": "Internal",
      "userFacing": false,
      "details": "Step 4b, part 2, of rendering upstream early (docs/lookahead.md §7). A group bus with a lookahead device is now compensated: its children, and everything they depend on, are rendered by a lane (the same renderer, over the same song, running ahead), and the bus's chain in front of the device reads their sum at its own song time. A group of groups nests lanes, so the leads stack. Bit-exact null tests with the test-only test.peek cover block sizes 64 to 4096, a child with its own lookahead, a group of groups, loops, seeks, skipped bars and 3/4, with each lane started once per play. A group bus that plays its children's notes stays latent with a reason. Nothing changes for songs without a lookahead device: the fingerprint suites and full-length demo hashes are unchanged."
    },
    {
      "title": "Lookahead lanes groundwork: a renderer can run part of the song ahead of itself",
      "version": "2026.8.338",
      "date": "2026-09-29",
      "area": "Sound engine",
      "type": "Internal",
      "userFacing": false,
      "details": "Step 4b, part 1, of rendering upstream early (docs/lookahead.md §7). A lane is a second renderer over the same song, rendering only the channels one consumer needs (a group bus's children, or a key source) with no master, pulled ahead of its owner and capturing what it was made for into rings indexed by the song stream frame (frames since play, the same in every renderer). Lookahead pipelines now know the song stream frame of each slice and can ask their host for a pre slice's group input and sidechain key; tap placement, master-lead re-priming and the export's bars-ahead count account for it. Nothing uses a lane yet: no shipped song makes one, the fingerprint suites and full-length demo hashes are unchanged."
    },
    {
      "title": "A device that looks ahead says so on its Stack card",
      "version": "2026.8.338",
      "date": "2026-09-29",
      "area": "UI",
      "type": "Improvement",
      "userFacing": true,
      "details": "An effect that needs to see its input before it answers (the Distortion's oversampler, the Ladder filter, and lookahead effects to come) now carries one line along the bottom of its Stack card. \"0.9 ms lookahead\" means the song renders the chain in front of it that much early, so it lands on time. \"+0.9 ms late\" in orange means it could not be compensated where it is, and hovering says why (for example a device placed after it on the master). The control API's /stats lists the same, per channel, under audio.lookahead."
    },
    {
      "title": "The Ladder filter's Mix no longer combs, and switching Topology no longer shifts it in time",
      "version": "2026.8.338",
      "date": "2026-09-29",
      "area": "Sound engine",
      "type": "Bug fix",
      "userFacing": true,
      "details": "The built-in Ladder filter's transistor topology is 35 samples (0.8 ms) late from its 2x oversampler and its diode topology was not late at all, so the device's Mix combed on transistor and the sound jumped in time on every switch. The built-in Ladder now pads its diode ladder to the same 35 samples and declares that fixed latency through the lookahead contract (#500): the Mix delays its dry path to match in both topologies, and in the song the chain in front of it is rendered early so it lands on time. The same filter inside instruments (Rat 030 and its presets) is untouched and bit-identical. A Ladder filter added before this update keeps its old behaviour until it is added again. Decision recorded in docs/lookahead.md.",
      "tickets": [
        554
      ]
    },
    {
      "title": "Distortion's Mix no longer combs: part-wet settings blend against a dry signal delayed to match",
      "version": "2026.8.338",
      "date": "2026-09-29",
      "area": "Sound engine",
      "type": "Bug fix",
      "userFacing": true,
      "details": "The built-in Distortion oversamples 4x, which makes its output 41 samples (under a millisecond) late. The device's Mix blended that against the undelayed input, so any setting between dry and wet comb-filtered, with the first notch near 540 Hz. fx.distortion now declares its latency through the lookahead contract (#500): the Mix delays its dry path by the same 41 samples, and in the song the chain in front of the Distortion is rendered 41 samples early so the effect lands on time. Nothing shipped used fx.distortion, so every preset and demo sounds exactly as before."
    },
    {
      "title": "A menu's submenu stays open when the pointer drifts off it, and opens on a click",
      "version": "2026.8.337",
      "date": "2026-09-29",
      "area": "UI",
      "type": "Improvement",
      "userFacing": true,
      "details": "In a popup menu such as the Stack's device picker, a submenu used to close the moment the pointer left the row it hangs off, unless the pointer went straight into it, and a click on that row seemed to do nothing because the submenu it opened shut again at once. A submenu now opens on a click as well as on a pause over its row, and stays open until the pointer lands on another row of the same menu, a click lands outside the menus, or Escape or Left closes it. For the control API and Lua scripts there is also a new menu action that lists the open menu and opens or chooses a row by its path (\"Delay > Reverser\"), so a script no longer has to know where the rows are drawn. GustUI: FruitPopupMenu.OpenLevels and OpenPath.",
      "tickets": [
        561
      ]
    },
    {
      "title": "\"Not live\" on an effect's display is a small label in the corner, off the picture",
      "version": "2026.8.337",
      "date": "2026-09-29",
      "area": "UI",
      "type": "Improvement",
      "userFacing": true,
      "details": "When a panel opens while the song plays from its bounce, the displays that follow the sound say they are not live. The message used to be dim text across the middle of the plot, under an EQ or filter curve and its band points, and on Gain over the level scale's labels. It is now a small pill in the display's top corner, on the header colour, drawn over everything else: top left on the curve displays (their dB scale runs down the right), top right on Gain and the oscilloscope.",
      "tickets": [
        560
      ]
    },
    {
      "title": "The picker no longer lists Bit crush beside Bitcrush, or Pitch shift beside Pitch shifter",
      "version": "2026.8.337",
      "date": "2026-09-29",
      "area": "UI",
      "type": "Improvement",
      "userFacing": true,
      "details": "The older crushers from the ezmuze 3 strip are now listed as Bit crush (ezmuze 3) and Rate crush (ezmuze 3), and the wave bank's tempo-fit shifter as Pitch shift (classic), so they no longer read as duplicates of the new Bitcrush and Pitch shifter in the same drawer. Only the picker rows are renamed: devices already in songs keep their names.",
      "tickets": [
        559
      ]
    },
    {
      "title": "Long device and channel names end in \"...\" instead of being cut mid-word",
      "version": "2026.8.337",
      "date": "2026-09-29",
      "area": "UI",
      "type": "Bug fix",
      "userFacing": true,
      "details": "Stack card titles such as Frequency shifter, Parametric EQ or Channel mixer ran on under the card's presets button, cut mid-word, and shortened channel names in the sequencer stopped with no sign they had been cut: the ellipsis character used has no glyph in the app's fonts and was dropped. Shortened names now end in three dots, a card title stops at its first button, and the full name leads the title's tooltip when it was shortened.",
      "tickets": [
        558
      ]
    },
    {
      "title": "A new Tape stop: stop the tape, spin it back up",
      "version": "2026.8.337",
      "date": "2026-09-29",
      "area": "Modules",
      "type": "New feature",
      "userFacing": true,
      "details": "The built-in effects suite gets a Tape stop (in the Pitch folder). Turn Play off and the tape slows to a halt over Stop time, pitch and speed falling together, then goes silent; turn it on and it spins back up over Start time and catches up with the music. Curve bends the ramp: negative holds the speed up then drops, positive drops at once and coasts. Automate Play (or ride it on the Stack card) for the classic stop on the last beat. Playing, it is the sound untouched. Its display draws the speed curve of the stop or spin-up with a dot riding it. Stack card: Mix, Play, Stop time. It runs on a new fx.tapestop.",
      "tickets": [
        537
      ]
    },
    {
      "title": "A new Reverser: every segment played backwards",
      "version": "2026.8.337",
      "date": "2026-09-29",
      "area": "Modules",
      "type": "New feature",
      "userFacing": true,
      "details": "The built-in effects suite gets a Reverser (in the Delay folder): it cuts the sound into segments and plays each one backwards while the next is recorded, so it runs one segment behind, as reversers do. Time sets the segment (10 ms to 2 s), or with Sync a note value, and synced segments start on the beat grid. Crossfade blends each joint so it does not click; 0% is a hard cut. Its display shows the read head running backwards through the last segment. Stack card: Mix, Time (or Note). It runs on a new fx.reverser.",
      "tickets": [
        536
      ]
    },
    {
      "title": "A new Resonator: a tuned string your sound plucks",
      "version": "2026.8.337",
      "date": "2026-09-29",
      "area": "Modules",
      "type": "New feature",
      "userFacing": true,
      "details": "The built-in effects suite gets a Resonator (in the Filter folder): Pitch (C1 to C7), Decay (how long the ring takes to fall 60 dB, 50 ms to 10 s, the same at every note), Intensity (how much ring joins the sound; 0% is the sound untouched) and Timbre (dark and soft to bright and metallic). With Keytrack on it rings at the notes the channel plays, Pitch then being the offset from middle C. Its display shows the ring's harmonic peaks on a frequency axis. Stack card: Mix, Pitch, Decay. It runs on a new fx.resonator, a tuned feedback loop read through an allpass so it stays in tune.",
      "tickets": [
        535
      ]
    },
    {
      "title": "A new Pitch shifter that is heard on time",
      "version": "2026.8.337",
      "date": "2026-09-29",
      "area": "Modules",
      "type": "New feature",
      "userFacing": true,
      "details": "The built-in effects suite gets a Pitch shifter (in the Pitch folder): Pitch (up to two octaves either way), Jitter (random grain placement that smears the buzz on sustained sounds) and Grain (5 to 100 ms). It is a granular shifter with a 100 ms look-ahead, and it is the first effect to declare it: in a song the chain in front of it is rendered that much early, so it is heard exactly on time. At 0 semitones it is its input, sample for sample. Its display shows the two grains' read heads sweeping about the look-ahead. Stack card: Mix, Pitch. It runs on a new fx.pitchshift2; the wave bank's fx.pitchshift (tempo-fit compensation) is unchanged.",
      "tickets": [
        534
      ]
    },
    {
      "title": "REAPER import: wet and Mix envelopes on built-in effects become Mix lanes, and a Kilohearts Gate keeps its look-ahead",
      "version": "2026.8.337",
      "date": "2026-09-29",
      "area": "Import",
      "type": "Improvement",
      "userFacing": true,
      "details": "An imported REAPER project's wet envelope on a plugin that became a built-in effect, and a Kilohearts plugin's own Mix envelope, now become an automation lane on the device's Mix (the address #556 gave it) instead of being reported as not imported. REAPER's wet lane is the plugin's own mix times the envelope; the plugin's Mix envelope is carried point for point. A Kilohearts Gate with look-ahead switched on now sets the Gate's Lookahead to its full 5 ms, and a look-ahead envelope switches it; before, the look-ahead was named as left out."
    },
    {
      "title": "The Gate can listen ahead, so it is open when a hit arrives",
      "version": "2026.8.337",
      "date": "2026-09-29",
      "area": "Sound engine",
      "type": "New feature",
      "userFacing": true,
      "details": "The built-in Gate has a Lookahead knob (0 to 5 ms). It is built on a new fx.gate2: the same gate as before, running on the Limiter's lookahead core, so the audio always comes out 5 ms later (compensated on channels and the master, like the Limiter) and the gate's gain is read up to that much ahead of it. At a lookahead of 0 it is the old gate exactly, 5 ms later. Gates already in songs keep the old fx.gate and sound bit for bit as they did; only new Gates use fx.gate2. To fit the knob, Tolerance moved off the grid onto the transfer curve, where it was already the second handle.",
      "tickets": [
        557
      ]
    },
    {
      "title": "Automate any effect's Mix: right-click the Mix knob on its card for a lane",
      "version": "2026.8.337",
      "date": "2026-09-29",
      "area": "Sequencer",
      "type": "New feature",
      "userFacing": true,
      "details": "Every effect's universal Mix (the dry/wet knob on its Stack card) can now have an automation lane, like any other control. The lane belongs to the device itself (its id plus the reserved param \"device.mix\"), so it survives recompiles, saving and loading, and copying the device onto cue pads. Songs without such a lane compile and sound exactly as before: a device at 100% Mix still gets no dry/wet blend at all. Along the way, turning the Mix knob is heard on the next bar; before, the engine only picked it up at the next recompile. The REAPER importer can map wet envelopes onto the same address (docs/automation-lane-editing.md section 11).",
      "tickets": [
        556
      ]
    },
    {
      "title": "See your ezmuze central buddies, and read and answer their messages, inside the studio",
      "version": "2026.8.336",
      "date": "2026-09-29",
      "area": "Services",
      "type": "New feature",
      "userFacing": true,
      "details": "ezmuze live comes to the studio. While you are signed in to ezmuze central, the status bar shows how many of your buddies are online and how many messages are unread. Click it, or File > Buddies..., for your buddy list (online first, with where each person is) and your conversations, where you can read and reply. You can accept or decline buddy requests there too. A new message raises a toast, one per burst, and nothing for the conversation you have open. Your buddies see you as \"In ezmuze studio\", and as away while the window is behind something; the studio never says what song you are working on. It checks every 20 seconds while the window is in front and every minute when not, backs off to five minutes when ezmuze central cannot be reached, and does nothing at all while you are signed out. Adding and removing buddies, and file attachments, stay on the ezmuze central website. Works on desktop and in the web build.",
      "tickets": [
        489
      ]
    },
    {
      "title": "An oscilloscope for module panels, in step with what you hear",
      "version": "2026.8.336",
      "date": "2026-09-29",
      "area": "UI",
      "type": "New feature",
      "userFacing": true,
      "details": "The panel editor's Meters and displays palette has an Oscilloscope. It draws the waveform of this device's output, what it is fed, a component inside it, or the master (chosen in the inspector, like the analyser), read at the moment being heard rather than the audio rendered ahead of it. It triggers on a rising zero crossing and shows two or more whole cycles, so a held note stands still, and its header reads the pitch it found and the time it spans; sounds without a pitch scroll over the last 40 ms. The trigger and time base are UI-free and tested (ScopeTrigger, OscilloscopeTests).",
      "tickets": [
        502
      ]
    },
    {
      "title": "Trance gate: chop a sound to a pattern of steps, locked to the song",
      "version": "2026.8.336",
      "date": "2026-09-29",
      "area": "Sound engine",
      "type": "New feature",
      "userFacing": true,
      "details": "A new built-in effect in the Modulation folder. Eight patterns of up to 32 steps, each with its own length, drawn and edited on the step grid: click or drag to paint steps, drag a gate's right edge to hold it, drag the tab to set the length. Pattern picks which one plays; Resolution is a step's note value (1/8 to 1/64, with triplets); Swing delays every second step; Depth is how far a closed gate turns the sound down. Attack, Decay, Sustain and Release shape every gate, and the grid draws that shape inside each one. The pattern runs from the song's first beat, so bounces, loops and live play land on the same steps. Ranges are Kilohearts Trance Gate's, measured, so an import can map straight on. Stack card: Mix, Pattern, Depth. New primitive fx.trancegate; nothing existing changes.",
      "tickets": [
        538
      ]
    },
    {
      "title": "A step grid for effects that play a pattern of steps",
      "version": "2026.8.336",
      "date": "2026-09-29",
      "area": "UI",
      "type": "New feature",
      "userFacing": false,
      "details": "The step grid (#503) is the display and editor for anything that plays a step pattern, first of all the Trance gate (#538). It draws all 32 steps with the ones past the pattern's length dimmed, the level the effect plays inside each gate, a bar under each gate for where it starts and how long it is held, and a playhead tapped from the effect so it moves with what is heard. Press an empty step and drag to paint gates on; press a playing step to paint them off (a gate's first step clears the gate); drag the right edge of a gate to hold it longer or shorter; drag the length tab to set how many steps the pattern plays. One undo step per gesture, written on release. Each pattern is stored on the effect's component as four exact 16-bit masks and its length (StepPattern), so songs, presets and undo keep it bit for bit, and a slot never edited plays the effect's factory pattern. The panel editor offers a step grid for every component that plays step patterns (StepPatterns registry). The editing rules are UI-free and tested (StepGridTests).",
      "tickets": [
        503
      ]
    },
    {
      "title": "The module editor's choice rows are dropdowns, and its knobs, sliders and the mod-source rail take typed values",
      "version": "2026.8.336",
      "date": "2026-09-29",
      "area": "UI",
      "type": "Improvement",
      "userFacing": true,
      "details": "In the module editor, a node's choice row (a filter's type, a taper's curve, a switch) used to be a button that stepped to the next option on each click, with no way to see the list; it is now a dropdown like every other selection control in the app, listing every option with the current one ticked, and choosing one is a single undoable edit. The editor's knob and slider rows, and the knobs on the mod-source rail of a module panel, now take a typed value like the Stack and the panels do: double-click the control (or its value) and type it in its own unit (\"250 ms\", \"3.2k\", \"-6 dB\"), Enter to apply. The dropdown is a shared ChoiceMenu, the Stack card's menu in a form any face can use.",
      "tickets": [
        540
      ]
    },
    {
      "title": "Kilohearts effects in a REAPER project now come in as the new built-in effects, settings and automation included",
      "version": "2026.8.336",
      "date": "2026-09-29",
      "area": "Import",
      "type": "Improvement",
      "userFacing": true,
      "details": "Importing a REAPER project now turns each Kilohearts effect (a kHs plugin, or a snapin inside Multipass, Snap Heap or Phase Plant) into the matching built-in effect instead of an older stand-in or nothing: Filter, Ladder, Nonlinear, Comb and Formant filters, Compressor, Gate, Dynamics, Limiter, Transient shaper, Compactor, Distortion, Delay, Reverb, Chorus, Flanger, Phaser, Ensemble, Gain, Stereo, Haas, Channel mixer, Bitcrush, Phase distortion, Ring mod, Frequency shifter, Trance gate (every pattern, step for step), the 3-band EQ and Slice EQ (onto the Parametric EQ). Their settings carry across, and so do their automation envelopes, onto the module's own knobs: the built-in effects' ranges and knob curves match the plugins', and every conversion was measured against the installed plugins (a new state-laws mode in tools/KiloheartsProbe reads what each plugin stores, which is often not what its knob shows: a gain stored as a factor, a phase as a fraction of a turn). Several were dropped or badly substituted before (a gate, a ring mod, a frequency shifter and a phase distortion were dropped; a flanger or phaser became a chorus at half its mix; a comb or formant filter became a wide-open filter). The plugin's mix becomes the device's Mix; a Mix or wet envelope on one of these has nowhere to go yet and is reported (bug board #556). Pitch shifter, Resonator, Reverser, Tape stop and Shaper keep their current handling until their modules land. Songs imported before are not changed."
    },
    {
      "title": "A new Compactor: a maximiser that evens a part out without flattening it",
      "version": "2026.8.336",
      "date": "2026-09-29",
      "area": "Modules",
      "type": "New feature",
      "userFacing": true,
      "details": "The built-in effects suite gets a Compactor (in the Dynamics folder), built on the new Limiter's lookahead core. It holds a sound to its threshold but never takes off more than its Range, listens to the average level by default (or every peak, or the peaks between samples), and its Attack starts turning down before the sound arrives. Hold keeps the reduction a moment before Release lets go, and Input pushes the sound into it. Like the Limiter its lookahead is compensated, so it stays on time. The display shows the curve with the range as a floor, and a dot for the signal. Stack card: Mix, Threshold, Release and a gain-reduction meter.",
      "tickets": [
        524
      ]
    },
    {
      "title": "A new Transient shaper, with a trace of what it does to each hit",
      "version": "2026.8.336",
      "date": "2026-09-29",
      "area": "Modules",
      "type": "New feature",
      "userFacing": true,
      "details": "The built-in effects suite gets a Transient shaper (in the Dynamics folder), on a new engine primitive, fx.transient. Attack makes the front of each hit snappier (or softer, turned down), Sustain brings the tail and room forward (or dries it out), Speed sets how tight a window it works on, and Clip keeps the boosts under 0 dB. The display scrolls the gain it applies: spikes where it sharpens a hit, humps where it lifts a tail. Stack card: Mix, Attack, Sustain. Also: an effect panel's header levels no longer stay dark when you press Play on a song that had already been bounced.",
      "tickets": [
        522
      ]
    },
    {
      "title": "A new Limiter whose gain slides down before a peak instead of jumping",
      "version": "2026.8.336",
      "date": "2026-09-29",
      "area": "Modules",
      "type": "New feature",
      "userFacing": true,
      "details": "The built-in effects suite gets its Limiter (in the Dynamics folder), on a new engine primitive, fx.limiter2. It looks ahead and turns the gain down in a smooth slide that arrives exactly as the peak does, where the classic limiter drops it in one step (which can click). Nothing comes out over the threshold. Input and Output gain, Release, Lookahead, and a choice of detection (every peak, the average, or the inter-sample peaks a converter makes) are all there. Its 5 ms of lookahead is compensated: the song renders the sound in front of it early, so on a channel or at the end of the master it is on time. The display is the limiter's curve with the threshold to drag and a dot showing the signal; the Stack card shows Threshold, Release and a gain-reduction meter. The classic limiter is listed as Limiter (classic) and every song using it is unchanged.",
      "tickets": [
        512
      ]
    },
    {
      "title": "A new Frequency shifter: move every frequency by the same number of hertz, up or down",
      "version": "2026.8.335",
      "date": "2026-09-28",
      "area": "Modules",
      "type": "New feature",
      "userFacing": true,
      "details": "Frequency shifter (in the Pitch folder) is a new engine primitive, fx.freqshift: every frequency moves by the same number of hertz, which, unlike a pitch shift, pulls a note's harmonics apart into something bell-like or detuned, and by a few hertz makes a slow phasing swirl. Shift runs from -5 kHz to +5 kHz (the Kilohearts Frequency Shifter's range and law, so REAPER imports map), and Spread moves the left down and the right up by up to 50 Hz each for width. It is a single-sideband shifter on the effects kit's Hilbert pair, so the unwanted mirror image stays about 50 dB down; with no shift and no spread it passes audio through bit for bit. The panel shows a 220 Hz note and its harmonics as a spectrum and where the shift moves them (computed by the effect itself), and the Stack card shows Mix and Shift.",
      "tickets": [
        532
      ]
    },
    {
      "title": "A new Ring mod with its own oscillator, and a picture of where it moves the notes",
      "version": "2026.8.335",
      "date": "2026-09-28",
      "area": "Modules",
      "type": "New feature",
      "userFacing": true,
      "details": "Ring mod (in the Modulation folder) multiplies the sound by an internal sine carrier, on a new engine primitive, fx.ringmod2: every tone comes out at its sum and difference with the carrier, the metallic, bell-like sound of a ring modulator. Frequency sets the carrier (10 Hz for a tremolo-like throb up to 10 kHz), Bias keeps some of the original (100% is the dry sound, bit for bit), Rectify folds the carrier so the sidebands move twice as far, and Spread offsets the right side's carrier for width; everything glides. Ranges and laws are Kilohearts Ring Mod's, so REAPER imports map. The panel shows a 220 Hz note and its harmonics as a spectrum and, over it, where the ring moves them (computed by the effect itself), with the move in words; the Stack card shows Mix, Frequency and Bias. The older ring modulator module, whose carrier comes from another source, is unchanged.",
      "tickets": [
        531
      ]
    },
    {
      "title": "A new Phase distortion: the sound bends its own waveform, with a picture of the bend",
      "version": "2026.8.335",
      "date": "2026-09-28",
      "area": "Modules",
      "type": "New feature",
      "userFacing": true,
      "details": "Phase distortion (in the Distortion folder) is a new engine primitive, fx.phasedist: a low-passed copy of the input turns the input's own phase, so loud low content bends the waveform of everything above it, from a Casio-style warp on one note to grit and movement on a chord. Drive sets how far, Tone how low the part that does the bending is, Normalise makes the warp the same at any level, Bias adds a fixed phase turn at every frequency and Spread turns the two sides apart for width; everything glides, and with no drive, bias or spread it passes audio through bit for bit. Ranges and laws are Kilohearts Phase Distortion's, so REAPER imports map. The panel draws a test tone warped by the effect's own arithmetic, and the Stack card shows Mix, Drive and Tone. Under it: a Hilbert transformer in the effects DSP kit (two allpass chains 90 degrees apart, within a degree from 30 Hz to 20 kHz), which the frequency shifter uses too, and a degrees unit for knob readouts and typed entry (+90°).",
      "tickets": [
        530
      ]
    },
    {
      "title": "A new Bitcrush: any sample rate, any bit depth, with dither and an anti-alias switch",
      "version": "2026.8.335",
      "date": "2026-09-28",
      "area": "Modules",
      "type": "New feature",
      "userFacing": true,
      "details": "Bitcrush (in the Distortion folder) is the effects suite's crusher, on a new floating-point engine primitive, fx.crush. Rate is continuous from 172 Hz to 44.1 kHz (a new sample is held whenever a phase counter wraps, so the rate can sit anywhere and glides as you turn it), Bits is continuous from 1 to 16 (2.5 bits exists), Dither trades the crusher's buzz for hiss, and Anti-alias filters the input to the rate first so only the steps fold back. The top of Rate and Bits is off, and there it passes audio through bit for bit. Ranges and knob laws are Kilohearts Bitcrush's, read off the plugin, so REAPER imports map. The panel draws a test tone before and after the crusher, computed by the crusher itself, so the steps and levels you see are the ones you hear, and the Stack card shows Mix, Rate and Bits. The 3.0 crushers (Bit crush, Rate crush) are unchanged.",
      "tickets": [
        528
      ]
    },
    {
      "title": "A new Channel mixer: send each side to either output, at any amount, flipped or not",
      "version": "2026.8.335",
      "date": "2026-09-28",
      "area": "Modules",
      "type": "New feature",
      "userFacing": true,
      "details": "Channel mixer (in the Utility folder) is a 2x2 matrix on a new engine primitive, fx.chmix: four knobs, L to L, R to L, L to R and R to R, each from -100% to +100%, set how much of each input reaches each output, and a negative amount adds it flipped. So the one module does a swap, mono, a one-sided polarity flip, a mid/side-style sides-only, and anything between. Every amount glides, and at its defaults (the identity) it passes audio through bit for bit. The ranges and defaults are Kilohearts Channel Mixer's, so REAPER imports map. The panel draws the matrix itself, outputs down and inputs across, each cell a bar from its centre (right for positive in the accent colour, left for negative in the negative colour) with the amount in it, and names familiar settings (\"as it was\", \"left and right swapped\", \"mono\"). The Stack card shows Mix and all four amounts.",
      "tickets": [
        520
      ]
    },
    {
      "title": "A new Haas widener: delay one side by a few milliseconds, and see which side arrives first",
      "version": "2026.8.335",
      "date": "2026-09-28",
      "area": "Modules",
      "type": "New feature",
      "userFacing": true,
      "details": "Haas (in the Utility folder) delays one side of the sound by 0.1 to 100 ms so the pair is heard wider, leaning towards the side that arrives first. It is a new engine primitive, fx.haas, reading the delayed side with the effects kit's interpolated read head (the Delay's), so turning Delay while it plays glides instead of clicking, and switching Channel crossfades the sides over a few milliseconds. Range, knob law and defaults are Kilohearts Haas's (5 ms, right side late), so REAPER imports map. The panel shows each side's arrival as a click on a millisecond axis, the late side in the accent colour with the gap marked, and says which way the sound leans (or, past 30 ms, that it is heard as an echo). The Stack card shows Mix, Delay and Channel. Channel reads the side it is on, Left or Right, on the panel and the card; on the Stack card, a built-in effect's two-way switch between named things is now a dropdown of the two, rather than a lit button.",
      "tickets": [
        519
      ]
    },
    {
      "title": "A new Stereo: mid level, width up to 600%, balance and swap, with a live correlation meter",
      "version": "2026.8.335",
      "date": "2026-09-28",
      "area": "Modules",
      "type": "New feature",
      "userFacing": true,
      "details": "Stereo (in the Utility folder) is the effects suite's stereo utility, on a new engine primitive, fx.stereo2, working in mid/side. Mid sets the level of what the two sides share, Width the level of what differs (0% is mono, 100% as it was, up to 600%), Pan is a balance and Swap exchanges left and right; every control glides, so a swap fades rather than clicks. At its defaults it passes audio through bit for bit. The ranges and knob curves are Kilohearts Stereo's, read off the plugin (100% width at the middle of the knob, via a new knob curve that puts a sixth of the range at the centre), so REAPER imports map. The panel shows the stereo image as a fan (its spread is the width, its centre the balance, its reach in the middle the mid level) beside the live phase correlation and balance, and the Stack card shows Mix, Width and Pan. The stock rack's Stereo is listed as \"Stereo (classic)\" beside it and is unchanged, as are songs that use it.",
      "tickets": [
        518
      ]
    },
    {
      "title": "A new Gain: trim in decibels, balance, polarity and mono, with a level meter per side",
      "version": "2026.8.335",
      "date": "2026-09-28",
      "area": "Modules",
      "type": "New feature",
      "userFacing": true,
      "details": "Gain (in the Utility folder) is the effects suite's gain stage, on a new engine primitive, fx.gain2. Gain runs from -30 to +30 dB, linear in decibels with 0 dB at the middle of the knob, the same law as Kilohearts Gain so REAPER imports can map onto it; Pan is a balance (moving it turns the other side down, and centred it changes nothing); Invert L and Invert R flip one side's polarity and Mono folds both sides to one, after any flip, which is the quick way to check two mics for phase. Every control glides over a few milliseconds, so a polarity flip is a very fast fade rather than a click. At its defaults it passes audio through bit for bit. The panel's display shows each side's output level on a -60 to +6 dB scale with a tick where a full-scale input lands after the gain and the balance, and the Stack card shows Mix and Gain. The stock rack's gain is listed as \"Gain (classic)\" beside it and is unchanged, as are songs that use it.",
      "tickets": [
        517
      ]
    },
    {
      "title": "A Formant filter that makes anything say a vowel",
      "version": "2026.8.335",
      "date": "2026-09-29",
      "area": "Modules",
      "type": "New feature",
      "userFacing": true,
      "details": "The built-in effects suite gets its Formant filter (in the Filter folder): three resonant bands parked on the formants of a sung vowel, so a pad, a bass or a drum loop takes on the character of a voice. Vowel morphs continuously through A, E, I, O and U, Q sets how narrow the formants are (3 to 24), and Shift moves all three together by up to two octaves, for a smaller or a larger singer. Lows and Highs let the body under the lowest formant and the air over the highest back through. The display shows the vowel's three formants, numbered, over the channel's live spectrum. It arrives at 70% on its Mix, as the older formant effect did. The Stack card shows Vowel and Q. Behind it: the existing fx.formant primitive, with Lows and Highs appended as ports whose defaults leave it exactly as it was.",
      "tickets": [
        527
      ]
    },
    {
      "title": "A Comb filter you can tune to a note",
      "version": "2026.8.335",
      "date": "2026-09-29",
      "area": "Modules",
      "type": "New feature",
      "userFacing": true,
      "details": "The built-in effects suite gets its Comb filter (in the Filter folder): a ringing comb whose teeth sit exactly on the pitch you give it and its harmonics, from 20 Hz to 20 kHz. Type a note into Pitch (\"A3\") and it lands on that note, not on the nearest whole number of samples as the older comb did. Feedback sets how long it rings, Damping lets the high teeth die first like a plucked string, Polarity Minus moves the teeth to the odd harmonics an octave down for a hollow, reedy tone, and Stereo detunes the two sides apart so the teeth interleave across the image. A tooth never rises above the input however high the feedback, Feedback 0 passes the sound through untouched, and sweeping Pitch glides. The display is the comb's own teeth over the channel's live spectrum. The Stack card shows Pitch and Feedback. Behind it: a new fx.comb2 primitive on the DSP kit's fractional delay line; the old fx.comb is unchanged.",
      "tickets": [
        526
      ]
    },
    {
      "title": "A Ladder filter, as a 303's diode ladder or a Moog's transistor ladder",
      "version": "2026.8.335",
      "date": "2026-09-29",
      "area": "Modules",
      "type": "New feature",
      "userFacing": true,
      "details": "The built-in effects suite gets its Ladder filter (in the Filter folder), with Cutoff, Resonance and Drive on its face and its Stack card. Topology picks the ladder: Diode is the squelchy, self-oscillating 303 filter the synths already use, exactly as it has always sounded; Transistor is a Moog-style four-pole ladder, a true 24 dB per octave that keeps more of its low end as the resonance rises, runs properly oversampled so a driven top end stays clean, and self-oscillates in tune at the top of the dial. The transistor ladder is about 0.8 ms later than the dry sound because of its oversampling. Every knob on the transistor ladder glides rather than stepping. The display is the ladder's own curve over the channel's live spectrum. Behind it: a topology port appended to fx.diodeladder whose default runs the diode ladder bit for bit as before (held to a frozen copy of the old code in the tests), with the transistor ladder's feedback solved within the sample.",
      "tickets": [
        525
      ]
    },
    {
      "title": "A new Filter: seven shapes, up to 48 dB per octave, with drive in the loop",
      "version": "2026.8.335",
      "date": "2026-09-29",
      "area": "Modules",
      "type": "New feature",
      "userFacing": true,
      "details": "The built-in effects suite gets its Filter (in the Filter folder): low pass, band pass, high pass, notch, low shelf, peak and high shelf, at 12, 24, 36 or 48 dB per octave, from 20 Hz to 20 kHz. Resonance is the filter's Q (0.71 is flat, higher puts a peak at the cutoff) and Gain lifts or cuts the shelves and the peak. Drive pushes the sound into the filter's own loop, so a loud signal squashes the resonance and grows grit instead of whistling on top of a clean filter: the job a separate nonlinear filter does elsewhere, in the same module. Every knob glides rather than stepping, and the display is the filter's own curve over the channel's live spectrum. The Stack card shows Cutoff, Resonance, Type and Drive. Ranges follow Kilohearts' Filter, so imported settings will land where they were. The older stock Filter is now listed as \"Filter (classic)\" in the device picker; placed devices and saved songs keep their name. Behind it: a new fx.svf engine primitive on the DSP kit's TPT filter (with a saturating twin of its core), tested against its analytic curves and through the effect harness.",
      "tickets": [
        508
      ]
    },
    {
      "title": "Dragging a Korben band split can be undone",
      "version": "2026.8.335",
      "date": "2026-09-29",
      "area": "UI",
      "type": "Bug fix",
      "userFacing": true,
      "details": "Dragging a split on Korben's spectrum moved the crossover live, then recorded the undo step when the mouse was released, reading the split's starting value at that moment, which the drag had already overwritten. The step recorded a move from the new frequency to itself, so Undo left the split where it was dragged. The drag now remembers where the split started and records the real move (a drag that ends where it began records nothing). The split bars are also redrawn from the setting whenever values change, so an undo or redo moves the bar and its label, not just the sound.",
      "tickets": [
        548
      ]
    },
    {
      "title": "Dragging an XY pad is one undo step",
      "version": "2026.8.335",
      "date": "2026-09-29",
      "area": "UI",
      "type": "Bug fix",
      "userFacing": true,
      "details": "An XY pad on a module panel wrote its two values as two separate edits on every frame of a drag. An edit only merges with the next one when it changes the same value, so the x and y edits took turns on the history and neither merged: a one-second drag became about a hundred undo steps. The pad now writes both values as one edit, the way the EQ curve's drag does (#497), and a whole drag undoes in one step.",
      "tickets": [
        551
      ]
    },
    {
      "title": "Opening a device's panel from its Stack card no longer leaves the card drawn over the first one",
      "version": "2026.8.335",
      "date": "2026-09-29",
      "area": "UI",
      "type": "Bug fix",
      "userFacing": true,
      "details": "A press on a card's panel, edit or presets button also landed on the card's name bar, which started a reorder drag on the press. When the button opened a window, the window took the release, so the drag never ended and the card was drawn at the drag's position (the start of the row, over the first card) until the Stack rebuilt. A press on the name bar now only arms a reorder, and it becomes a drag once the pointer has moved four pixels with the button held; a click that never moves draws nothing. Dragging a card by its name to reorder the chain works as before.",
      "tickets": [
        550
      ]
    },
    {
      "title": "Effect meters and displays on channels playing sample clips show what the effect is doing",
      "version": "2026.8.335",
      "date": "2026-09-29",
      "area": "Sound engine",
      "type": "Bug fix",
      "userFacing": true,
      "details": "A channel whose bars play on its sample graph (sample clips, or a pattern played as one) runs its whole effect chain on that graph, but a live meter read asked the channel's full graph, whose copy of the same effect never runs: an Output meter sat at -120 dB and a meter published by the effect stayed at its reset value while the effect was plainly working. The per-bar hand-off never recorded sample-graph bars (it leaves them alone on purpose, #404), so the #496 fix, which asks the graph that ran the effects, could not apply. The renderer now notes when a bar's effects ran on the sample graph (without touching the hand-off, so nothing sounds different) and asks that graph first. The Ensemble's display, which ran on a stand-in clock while its LFO meters read nothing, now follows its real LFOs.",
      "tickets": [
        553
      ]
    },
    {
      "title": "A new Dynamics effect: lift the quiet, tame the loud, on one curve",
      "version": "2026.8.334",
      "date": "2026-09-29",
      "area": "Modules",
      "type": "New feature",
      "userFacing": true,
      "details": "The built-in effects suite gets Dynamics (in the Dynamics folder), on a new engine primitive, fx.dynamics. It has two thresholds: above the high one the high ratio turns loud sounds down, below the low one the low ratio brings quiet sounds up (a ratio under 1:1 does the opposite, expanding instead), and between them nothing changes. A knee rounds both corners, Attack and Release set how fast it reacts, and Input and Output gain sit either side. The display draws the whole law with both thresholds and the knee as handles you drag and a dot showing the signal while it plays; upward gain is capped at +24 dB so silence and hiss are not raised without limit. Stack card: Mix, both thresholds and both ratios.",
      "tickets": [
        523
      ]
    },
    {
      "title": "A new Gate, with hold, range, flip and a side-chain",
      "version": "2026.8.334",
      "date": "2026-09-29",
      "area": "Modules",
      "type": "New feature",
      "userFacing": true,
      "details": "The built-in effects suite gets its Gate (in the Dynamics folder), on a new engine primitive, fx.gate. It opens when the level passes the threshold and closes only when the level has fallen through the tolerance band below it and the hold time has passed, so a note hovering at the threshold does not chatter. Range says how far down a closed gate goes (all the way is silent), Attack and Release how fast it opens and closes, Flip swaps them (cut the loud, pass the quiet), and Side-chain makes it listen to the channel's key source instead. The display draws the gate's law with the threshold and tolerance as handles you drag and a dot showing the signal while it plays; a lamp beside it lights while the gate is open, and the Stack card shows the same lamp. Lookahead waits for the upstream render-ahead work (#500).",
      "tickets": [
        521
      ]
    },
    {
      "title": "A new Distortion with seven curves, and a display that shows the one you picked",
      "version": "2026.8.334",
      "date": "2026-09-29",
      "area": "Modules",
      "type": "New feature",
      "userFacing": true,
      "details": "The built-in effects suite gets its Distortion (in the Distortion folder), on a new engine primitive, fx.distortion. Pick a curve (overdrive, saturate, foldback, sine, hard clip, quantize or asymmetric), push the signal into it with Drive (-6 to +20 dB), shift it off centre with Bias, darken or brighten it with Tone, bring back the loud and quiet with Dynamics, and drive the two sides differently with Spread. It runs four times oversampled, so the harmonics it makes above hearing are filtered off rather than folding back as harsh aliases. The display draws the chosen curve with your drive and bias, with a dot showing how far up it the signal reaches while playing. Stack card: Mix, Type, Drive, Tone. The old Saturator is unchanged.",
      "tickets": [
        511
      ]
    },
    {
      "title": "Automated knobs on effects after a synth now move while the song plays",
      "version": "2026.8.334",
      "date": "2026-09-29",
      "area": "UI",
      "type": "Bug fix",
      "userFacing": true,
      "details": "When a synth's notes play from the pattern cache, the effects after it run on a second copy of the channel. The live readouts that make an automated knob move in amber (and a modulated control's ghost notch, an LFO's playhead, an envelope's live dot) were read from the copy that was not running, so an automated knob on such an effect stood still. They are now read from the copy that ran, the same fix the meters got in #496 (NaiveSongRenderer.LiveGraph).",
      "tickets": [
        547
      ]
    },
    {
      "title": "Quitting the desktop app no longer risks a crash or a hang on the way out",
      "version": "2026.8.334",
      "date": "2026-09-29",
      "area": "Desktop",
      "type": "Bug fix",
      "userFacing": true,
      "details": "Closing the app now stops everything that runs on its own thread before tearing anything down: the welcome screen's background video, audio input, the preview voices and the song's output voice, and the threads that render ahead and bounce to disk. Before, they were all still running while the parts under them were destroyed, and a DirectX instance was seen to crash in the C runtime (abort, 0xc0000409) a second after closing. The same teardown also hung the process for good about one exit in eight in a reduced test, which was a deadlock in KNI's XAudio2 voices: a voice's buffer-end callback waited for a lock that the code destroying a voice (or the audio engine) was holding while it waited for that callback. The callback no longer waits; it hands the buffer back on the next frame instead. That same deadlock could also freeze the app mid-session when one sound was disposed while another played, as happens on a song switch. See docs/desktop-build.md, Quitting.",
      "tickets": [
        549
      ]
    },
    {
      "title": "Lookahead on audio tracks, in exports and in the bounce cache",
      "version": "2026.8.334",
      "date": "2026-09-29",
      "area": "Sound engine",
      "type": "Internal",
      "userFacing": false,
      "details": "Step 3 of rendering upstream early (docs/lookahead.md §5). An audio track (no instrument, sample clips into its effects) with a lookahead device now compensates it: the part of its chain up to the device runs ahead on its own clock and renders the track's sample clips itself, on voices of its own. An export prepares the sample clips of the bars the song is rendered ahead (NaiveSongRenderer.LookaheadBarsAhead), and the desktop bounce cache invalidates that many bars further back, since bar K-1 has already read bar K. Bit-exact null tests with the test-only test.peek cover block sizes, a clip across a bar line, skipped bars, loops, seeks, series devices at Mix 0.5 and a whole export. An instrument channel that also has audio clips stays latent with a reason. Nothing changes for songs without a lookahead device: the fingerprint suites and full-length demo hashes are unchanged."
    },
    {
      "title": "Lookahead on the master: a lookahead device at the end of the master chain lands on time",
      "version": "2026.8.334",
      "date": "2026-09-29",
      "area": "Sound engine",
      "type": "Internal",
      "userFacing": false,
      "details": "Step 2 of rendering upstream early (docs/lookahead.md §4). When the master chain ends in devices that declare latency, the renderer runs its whole render (channels, mix, master strip and chain) that many frames ahead of the output and hands out the chain's result that much later, so the output is on time. The playhead map, the heard position, the final mixdown's gains and channel taps stay exact; with looping off the chain runs on over silence so the song's last frames still leave it and playback stops where it did. Bit-exact null tests with the test-only test.peek cover block sizes, loops, looping off, seeks, mix-ahead, series devices at Mix 0.5 and a compensated channel underneath. A device after the lookahead device, real-time TFX lanes on the master, or the whole-bar legacy limiter leave it latent with a reason. No shipped song has a master lookahead device; the fingerprint suites and full-length demo hashes are unchanged."
    },
    {
      "title": "Lookahead: the chain in front of a lookahead device now renders early on its own clock, so the device lands on time",
      "version": "2026.8.334",
      "date": "2026-09-28",
      "area": "Sound engine",
      "type": "Internal",
      "userFacing": false,
      "details": "Step 1 of rendering upstream early (docs/lookahead.md). In the song, a channel whose chain has a device declaring latency now splits after the last such device into two graphs on their own bar clocks and tick grids: the part up to and including the device runs that many frames ahead, following the transport's own loop rules, and hands its output through a stream-indexed float FIFO to the rest of the chain, which runs on time. Series devices sum their leads; polyphonic instruments, loops (with no re-prime at a wrap), seeks, skipped bars (undone exactly from a snapshot), bar-ahead, parallel channels and mix-ahead are covered by bit-exact null tests with the test-only test.peek node at block sizes of 64 to 4096. A compensated channel always plays live on the plain path. Group buses, sidechain-keyed channels, channels with audio clips, TFX lanes, the master and wires that cross the split run latent with a reason, until later steps. No shipped song has a lookahead device, so nothing that existed before changes: the fingerprint suites and full-length demo hashes are unchanged."
    },
    {
      "title": "Lookahead groundwork: devices can declare a fixed latency, and it is reported rather than hidden",
      "version": "2026.8.334",
      "date": "2026-09-28",
      "area": "Sound engine",
      "type": "Internal",
      "userFacing": false,
      "details": "Step 0 of rendering upstream early for lookahead effects (docs/lookahead.md). A primitive can now declare a static maximum latency (ModuleDescriptor.DeclaredLatencyFrames) that it always pads to; a chain device's latency is the longest declared path through it (LookaheadModules.DeviceLatencyFrames). ChannelModuleGraph takes a LookaheadMode that defaults to Latent, in which a declaring device is simply late and the graph reports \"+x ms, not compensated: reason\" (ChannelModuleGraph.Lookahead, NaiveSongRenderer.LookaheadReports). A declaring device's Mix and TFX blend now delay their dry path with a new internal util.align node so dry and wet stay aligned. A hidden, test-only test.peek node (a pure declared delay) is the reference for the null tests to come. Nothing that existed before declares latency, the old fx.limiter included: a registry test and a test over every shipped demo hold that, and the fingerprint suites and full-length demo hashes are unchanged."
    },
    {
      "title": "CPU budgets in the effect tests are measured everywhere but only enforced on request",
      "version": "2026.8.334",
      "date": "2026-09-29",
      "area": "Build",
      "type": "Internal",
      "userFacing": false,
      "details": "The effect harness's Cpu_StaysInBudget and the DSP kit's oversampler cost check compared wall-clock time against fixed budgets. The CI runner is slower than a development machine and a local full run shares the CPU with builds, so they failed healthy effects: CI run 36506207737 failed every Phaser budget check and the oversampler check on an unchanged tree, blocking the beta. They now go through PerfGate: the measurement always runs and is written to the test output, and it fails the run only when EZMUZE_PERF=1 is set on a quiet machine."
    },
    {
      "title": "Ensemble is a built-in effect, with its three voices drawn moving",
      "version": "2026.8.334",
      "date": "2026-09-28",
      "area": "Modules",
      "type": "New feature",
      "userFacing": true,
      "details": "The string machine's triple-BBD ensemble (fx.ensemble, the one Bueller is built on) is now a built-in effect in the Chorus folder: Detune, Spread and Motion, three voices as the circuit has, fully wet by default. Its display draws the three voices (left, centre, right) swinging on their delay axis. Its sound is unchanged: the primitive only gained read-only meters for its output level and its two LFOs (a new MeterKind.Phase, which is never drawn as a gauge). Stack card: Mix, Detune, Spread.",
      "tickets": [
        516
      ]
    },
    {
      "title": "A new Phaser: 2 to 12 stages, feedback, and its notches drawn as they sweep",
      "version": "2026.8.334",
      "date": "2026-09-28",
      "area": "Modules",
      "type": "New feature",
      "userFacing": true,
      "details": "The built-in effects suite gets a Phaser (in the Phaser folder), on a new fx.phaser2: 2 to 12 allpass stages (every two make a notch; changing Order crossfades rather than clicks), a Centre from 20 Hz to 20 kHz swept up to three octaves each way by Depth, Rate in Hz or synced, Spread between the sides and Feedback from -100% to +100%. Its display is the phaser's response on a frequency axis with the notches where the running filter puts them (tested against the DSP), sliding as the LFO turns. Stack card: Mix, Rate or Note, Depth, Feedback; it starts at 50% Mix.",
      "tickets": [
        515
      ]
    },
    {
      "title": "A new Flanger: a real swept delay, with feedback and a barber-pole scroll",
      "version": "2026.8.334",
      "date": "2026-09-28",
      "area": "Modules",
      "type": "New feature",
      "userFacing": true,
      "details": "The built-in effects suite gets a Flanger (in the Flanger folder), on a new fx.flanger2: a short delay (0.1 to 10 ms) swept up to three octaves either side by an LFO in Hz or synced to a note value, Feedback from -100% (hollow) to +100% (the jet), a Stereo offset between the sides, and Scroll, the barber-pole flanger whose sweep only ever rises (or falls) without a seam. Its display is the flanger's comb on a frequency axis, drawn at the LFO's present position with the band the sweep covers and a dot where it is, using the device's own Mix. Stack card: Mix, Rate or Note, Depth, Feedback; it starts at 50% Mix. The old fx.flanger (which is really a phaser) is untouched.",
      "tickets": [
        514
      ]
    },
    {
      "title": "A new Chorus: up to four voices, sync, and a display of the voices swinging",
      "version": "2026.8.334",
      "date": "2026-09-28",
      "area": "Modules",
      "type": "New feature",
      "userFacing": true,
      "details": "The built-in effects suite gets a real Chorus (in the Chorus folder), on a new fx.chorus2: one to four voices on a short delay (0.5 to 50 ms), swung by an LFO either side of it by Depth, spread evenly round the cycle, with the right side's swing offset by Spread and optional Feedback. Rate is 0.01 to 20 Hz, or with Sync a note value locked to the song's beat (a new EffectLfo in the DSP kit: phase-locked to the song, and a seek or loop is pulled in over 60 ms rather than clicking). Its display shows every voice as a dot moving along its track on a delay axis, left above the line and right below, tapped from the effect so it moves with what you hear. Stack card: Mix, Rate or Note, Depth, Spread; it starts at 50% Mix. The stock rack's Chorus, a static comb, keeps sounding exactly as before and is now listed as \"Chorus (classic)\" (in its folder and in Legacy). Tested through the effect harness (bit-identical at Mix 0, no zipper on any control, CPU, both sample rates, channels apart) and the LFO against the song at four tempos.",
      "tickets": [
        513
      ]
    },
    {
      "title": "Effect snapshots copy the DSP kit's delay lines; module_ui can open a device already in a channel",
      "version": "2026.8.334",
      "date": "2026-09-28",
      "area": "Core",
      "type": "Internal",
      "userFacing": false,
      "details": "StateCopier treated the DSP kit's namespace (Ezmuze.Audio.Dsp) as a shared dependency, so a bar-line snapshot of a kit-built effect shared its FractionalDelayLine with the live graph and restored nothing; the kit is state now. The remote control API's module_ui takes an optional channel, as module_editor does, and opens that channel's own device's panel without adding a channel: with add_device, the way to watch an effect's display on real music (docs/remote-control-api.md)."
    },
    {
      "title": "A new 8-band Parametric EQ, drawn over the sound it is shaping",
      "version": "2026.8.333",
      "date": "2026-09-28",
      "area": "Modules",
      "type": "New feature",
      "userFacing": true,
      "details": "The built-in effects suite gets its main EQ (in the EQ folder): eight bands, each a bell, a low or high shelf, a low or high cut (12, 24, 36 or 48 dB per octave) or a notch, with its own frequency, gain and Q, and a choice of both channels, the mid only or the side only; then an output gain. Its display is the EQ's own curve over a live spectrum of the channel it sits on (what comes in, faintly, and what goes out), in step with what you hear. Drag a band's numbered point to move it (across for frequency, up and down for gain), turn the wheel over it or Alt-drag for its Q, double-click to flatten it, right-click to switch it off, and click anywhere empty to bring in a new band there. Under the curve, a tab per band opens that band's controls. A new EQ starts flat, with a low shelf, two bells and a high shelf ready to grab, and passes the sound through bit for bit until you move something. Its Stack card shows the gains of the first four bands that are on, then the output, and changes as you switch bands on and off. Ranges follow Kilohearts' Slice EQ (20 Hz to 20.5 kHz, +/-30 dB, Q 1/32 to 32), measured off the plugin, so imported settings will land where they were. Behind it: a new fx.peq engine primitive on the DSP kit's TPT filter, smoothed, tested through the effect harness at 44.1 and 48 kHz and against its analytic curves.",
      "tickets": [
        505
      ]
    },
    {
      "title": "EQ curves you can drag, with the channel's spectrum behind them",
      "version": "2026.8.333",
      "date": "2026-09-28",
      "area": "UI",
      "type": "New feature",
      "userFacing": true,
      "details": "A new display for effect panels: a frequency-response editor that draws an EQ's combined curve on a log frequency axis (20 Hz to 20 kHz) and a dB grid, the selected band's own shape faintly in its colour, and a draggable point per band, over the same live spectrum Korben shows, listening to the effect's own channel. Hovering reads out the frequency and level under the pointer, or a band's settings in full. The dB scale grows from +/-12 to +/-30 only when a band needs it. It can also be a picture only, for a filter. The component is GustUI's FrequencyResponseElement, bound to a module's controls by the panel's new EqCurve element, so the curve, the knobs under it, the Stack card and automation all move the same values; a drag is one undo step however many values it moves.",
      "tickets": [
        497
      ]
    },
    {
      "title": "The 3-band EQ shows its curve, and its low and high points drag the splits",
      "version": "2026.8.333",
      "date": "2026-09-28",
      "area": "Modules",
      "type": "New feature",
      "userFacing": true,
      "details": "The built-in 3-band EQ's display is now its response curve over the channel's spectrum, drawn from the exact filters the EQ runs. Drag the low and high points across to move the splits and up and down for the gain; the mid point sets the mid's gain, and its frequency follows the splits. Its Stack card is still Low, Mid and High.",
      "tickets": [
        506
      ]
    },
    {
      "title": "Songs in 3/4, 6/8 and other time signatures play every note where it was written",
      "version": "2026.8.333",
      "date": "2026-09-29",
      "area": "Sound engine",
      "type": "Bug fix",
      "userFacing": true,
      "details": "In a song that was not in 4/4, a bar played live placed its notes as if the bar had four beats: in 3/4 a note on beat 3 sounded half a beat early, and the same song could place it differently on a bar played from the pattern cache. The channel's main graph was the one graph built without the song's beats-per-bar; it now takes it like the rest, so live and cached bars agree. The unwired bar-ahead producer (#248) had the same omission and is fixed too. Songs in 4/4, including every demo, are unchanged.",
      "tickets": [
        546
      ]
    },
    {
      "title": "A new built-in Compressor, set by dragging its curve",
      "version": "2026.8.333",
      "date": "2026-09-29",
      "area": "Modules",
      "type": "New feature",
      "userFacing": true,
      "details": "The built-in effects suite gets its Compressor (in the Compressor folder). Its display is the transfer curve: drag the threshold along it, drag the knee handle to soften the corner, and watch a dot show where the signal is while the song plays, with the gain reduction beside it. Threshold, Ratio, Attack, Release, Makeup (now in decibels) and a side-chain high-pass that stops low end pumping it are knobs; RMS detection and Auto makeup (about half of what it takes off a full-scale signal) are switches. Its Stack card shows Mix, Threshold, Ratio, Attack, Release and Makeup with a small gain-reduction meter. It runs on the same compressor as the classic one, with three new settings added whose defaults leave every existing compressor sounding exactly as it did (fx.compressor ports makeupDb, autoMakeup and scHighPass, appended; CompressorV2Tests hold the defaults to the bit).",
      "tickets": [
        507
      ]
    },
    {
      "title": "Dynamics effects can show their transfer curve, with a live dot",
      "version": "2026.8.333",
      "date": "2026-09-29",
      "area": "UI",
      "type": "New feature",
      "userFacing": true,
      "details": "A new panel display (UiElementKind.TransferCurve) draws a dynamics effect's law, input level against output level with its knee, for a compressor, a limiter, a gate or expander, upward and downward dynamics with two thresholds, or a distortion's static curve. The threshold and knee are handles you drag, and while the song plays a dot shows the signal's level going in and coming out, from the meters the effect declares. The arithmetic (TransferCurveMath) is the compressor's own gain computer, tested to the float, and a distortion registers its curve in TransferShapes so the picture is the law; the Saturator's is registered. The Shaper's editable points are left as a seam.",
      "tickets": [
        498
      ]
    },
    {
      "title": "Stack cards can show a meter, and panels can be given meters and analysers in the editor",
      "version": "2026.8.333",
      "date": "2026-09-29",
      "area": "UI",
      "type": "New feature",
      "userFacing": true,
      "details": "An effect can put a compact meter on its Stack card beside its controls: the new Compressor shows its gain reduction there. The panel editor's palette has a Meters and displays group: in and out level bars, a meter for each part of the device that reports one, a transfer curve for each compressor or distortion inside it, and an Analyser, with inspector choices for what each reads or listens to (the master, this device, what it is fed, or one part of it). For automation, the control API gains add_device, which adds a factory module to an existing channel.",
      "tickets": [
        496
      ]
    },
    {
      "title": "Meters no longer show full scale when there is nothing to read, and effect meters move on every channel",
      "version": "2026.8.333",
      "date": "2026-09-29",
      "area": "UI",
      "type": "Bug fix",
      "userFacing": true,
      "details": "A level meter whose device was not running (the song stopped before it played, the device bypassed, the web audio not answering yet) drew a full bar; it now rests empty, and a compressor that has not played yet reads silent rather than full scale. Separately, an effect after a synth whose notes play from the pattern cache never moved its meters at all, because the meters were read from a copy of the channel that was not running; they are now read from the one that is. And a module panel with meters or a live curve open now keeps the song playing live rather than from the bounce, so its meters have something to show.",
      "tickets": [
        541
      ]
    },
    {
      "title": "Effect panels show live in and out levels in their header",
      "version": "2026.8.333",
      "date": "2026-09-29",
      "area": "UI",
      "type": "Bug fix",
      "userFacing": true,
      "details": "The header of the built-in effects' panels drew a small, still glyph where the in and out meters belong. It is now two level bars, what goes into the device over what comes out, measured from the device's own audio, so every effect has them whether or not it reports meters of its own. The 3-band EQ and Reverb panels are refreshed to pick this up.",
      "tickets": [
        545
      ]
    },
    {
      "title": "A new Delay: smooth time changes, tempo sync, tone, ping-pong and ducking",
      "version": "2026.8.333",
      "date": "2026-09-28",
      "area": "Sound engine",
      "type": "New feature",
      "userFacing": true,
      "details": "Delay (in the Delay folder) is the effects suite's delay, on a new engine primitive, fx.delay2; the stock rack's delay is listed as \"Delay (classic)\" beside it and is unchanged, as are songs that use it. Turning Time while it plays bends the repeats like a tape delay instead of clicking (the old delay reads whole samples and clicks on every change: rendering the synth demo with Time automated, its worst click measured about fifty times the level of the same render held still, against no measurable click from the new one), and a leap too far to glide crossfades. Sync swaps Time for a Note knob (straight, dotted and triplet note values from 1/64 to 8 bars, at the song's tempo); the panel and the Stack card show whichever is live. Tone darkens (left) or thins (right) the repeats, each one more than the last, with separate low and high cuts; Ping-pong bounces the repeats between the sides; Width and Offset set the stereo image; Duck pushes the repeats down while you play and brings them back in the gaps. At 100% feedback it holds rather than running away. The panel's display draws the echoes as bars on a time axis, left above and right below, and follows the knobs and automation. Ranges match Kilohearts Delay's so REAPER imports can map onto it. It arrives at 35% Mix. Under the hood: panels and Stack cards can swap two controls on a switch, a built-in effect can have a stepped knob with named positions, and the delay primitive is two lines with a crossfeed so the Dual delay can reuse it.",
      "tickets": [
        509
      ]
    },
    {
      "title": "A new built-in Reverb, with a display that shows how long and how dark the tail is",
      "version": "2026.8.333",
      "date": "2026-09-28",
      "area": "Modules",
      "type": "New feature",
      "userFacing": true,
      "details": "The built-in effects suite gets its Reverb (in the Reverb folder): the plate tank from HALL 9000, as a simple module without HALL's shimmer, freeze, ducking or gating. Decay is set in seconds (0.2 to 18 s, the time the tail takes to fall 60 dB), with Size, Damping and Pre-delay beside it and Diffusion, Width, Low cut and Modulation as smaller knobs. The display draws the decay itself: silent through the pre-delay, then the tail falling to -60 dB at its decay time, with a fainter line for the highs that Damping darkens, and it moves as you turn the knobs. Where a very small room cannot hold a long tail the display shows the shorter tail you actually get. A new Reverb starts at 30% on the Mix knob, and its Stack card shows Mix, Decay, Size, Damping and Pre-delay. Behind it: EffectDeclaration.DefaultMix and ModuleDefinition.DefaultMix (the dry/wet a new device starts at, never written when unset), UiElementKind.DecayGraph with ReverbDecayShape, display targets on effect declarations, and a DeviceEffect adapter that runs a built-in effect module through the effect test harness.",
      "tickets": [
        510
      ]
    },
    {
      "title": "The two older Reverbs and Compressors are now told apart in the device picker",
      "version": "2026.8.333",
      "date": "2026-09-28",
      "area": "UI",
      "type": "Bug fix",
      "userFacing": true,
      "details": "The device picker listed two different effects called \"Reverb\" and two called \"Compressor\" (in their own folders and again under Legacy), with nothing to say which was which, and the new built-in Reverb would have made it three. The older ones are now listed as \"Reverb (classic)\" and \"Compressor (classic)\" (the stock rack effects) and \"Reverb (ezmuze 3)\" and \"Compressor (ezmuze 3)\" (the 3.0 strip effects). Only the picker's name changes: a device you place keeps its old name on its card, and every saved song, preset and import is unchanged. Also fixes a launch-time library cleanup that matched shipped modules by name alone and would have deleted the stock Reverb from the library as a duplicate of the new one.",
      "tickets": [
        542
      ]
    },
    {
      "title": "Moving the plate reverb's controls no longer crackles",
      "version": "2026.8.333",
      "date": "2026-09-28",
      "area": "Sound engine",
      "type": "Bug fix",
      "userFacing": true,
      "details": "fx.plate, the reverb inside HALL 9000 and the new built-in Reverb, took its controls once every 64 samples, so turning or automating Size crackled even slowly, and Mix, Decay and Width stepped audibly when moved fast. Size, Decay, Mix, Width, Pre-delay, Modulation and Diffusion now glide smoothly sample by sample (Size and Pre-delay over about 40 ms, the rest over about 15 ms). With the controls left where they are, the reverb sounds exactly as before, to the bit, so songs and presets made with HALL 9000 are unchanged; a test renders a frozen copy of the old tank beside the new one to prove it. Measured through the effect harness, a gentle Size sweep went from -49 dB of crackle to -88 dB.",
      "tickets": [
        539
      ]
    },
    {
      "title": "Built-in effect knobs read and take typed values like every other control",
      "version": "2026.8.332",
      "date": "2026-09-28",
      "area": "UI",
      "type": "Internal",
      "userFacing": false,
      "details": "Integration of the effect template (#493) with typed value entry (#499), which each brought their own unit handling. An effect's knob now declares a ValueUnit (EffectKnob.Unit, written to the dial as the new ModuleInstance.DisplayValueUnit) instead of a text suffix, decimals and a display scale, so its readout comes from the same one-formatter-per-unit ValueUnits as the rest of the app (the 3-band EQ's splits read \"2.2 kHz\" rather than \"2200 Hz\", its gains \"+3 dB\" rather than \"+3.0 dB\"). ParamText now applies a dial's response curve (ModuleInstance.DisplayCurve) to the readout and inverts it for typed text, so a value typed into an exponential knob lands where the knob then reads it. Module builder readouts are unchanged: DisplayValueUnit defaults to Auto, which keeps the hand-written suffix and decimals, and setting a readout by hand returns a dial to Auto."
    },
    {
      "title": "Type a value into any knob, readouts show real units, and Stack choices are dropdowns",
      "version": "2026.8.332",
      "date": "2026-09-28",
      "area": "UI",
      "type": "Improvement",
      "userFacing": true,
      "details": "Double-click a knob or slider, or the value under it, in a module panel or on a Stack card to type a value: \"3.2k\", \"3200\" or \"3.2 kHz\" for a frequency, \"-18\" or \"-18 dB\", \"50%\", \"250ms\" or \"1.5s\", \"A4\" or \"440 Hz\" for a note, and \"1/8.\" or \"1/4t\" for a synced choice. A comma works as the decimal point too. The value is kept inside the control's range and snapped to its steps; Enter applies it as one undoable edit, Escape or a click elsewhere leaves the control alone, and text that means nothing turns the box's border orange so it can be corrected. Readouts under knobs now come from one formatter per unit (Hz/kHz, dB, %, ms/s, 4 : 1, semitones, notes, note values), and a dial shows the value it actually sets: the stock Filter's cutoff reads \"1000 Hz\" rather than \"0.08\", and a Mix reads \"100%\". Choice controls on Stack cards (a synth's waveform, a filter's kind) open a dropdown listing every option, with the current one ticked, instead of stepping to the next option on each click.",
      "tickets": [
        499
      ]
    },
    {
      "title": "A new 3-band EQ, and every older effect is also listed under a new Legacy folder",
      "version": "2026.8.332",
      "date": "2026-09-28",
      "area": "Modules",
      "type": "New feature",
      "userFacing": true,
      "details": "The first effect of the new built-in effects suite: a 3-band EQ (in the EQ folder) with Low, Mid and High gain and two splits that set where the bands meet; the mid band follows the splits. Its panel is the new standard effect panel: a header with the name and level-meter slot, one display area (the frequency curve arrives in a later update), the main knobs with their values in real units, and smaller knobs for the settings you set once. Knobs read in dB and Hz, and frequency knobs move by octaves. Its Stack card shows Mix, Low, Mid and High only, and Stack cards now show real units for any module that declares them. Every effect from before the suite (the stock rack, the 4-tap echo and the 3.0 effects) stays where it was and is also listed under a new Legacy folder at the bottom of the device picker. Behind it: a declaration-based template for built-in effects (Ezmuze.Audio.EffectTemplate / EffectPanel / BuiltInEffects), a math.taper response-curve node, ModuleInstance.DisplayCurve, app-theme colour tokens (@surfacePanel etc.) and per-element tooltips in module panel layouts.",
      "tickets": [
        493
      ]
    },
    {
      "title": "Korben's spectrum shows the channel it is on, in time with what you hear",
      "version": "2026.8.332",
      "date": "2026-09-28",
      "area": "Sound engine",
      "type": "New feature",
      "userFacing": true,
      "details": "A device can now hand a display a short stretch of its own audio (a per-device tap), and Korben's analyser uses it to show what Korben is FED: on the master that is the mix, as before, and on a drum bus or any other channel it is that channel. Until now it always showed the master, which on any other channel was the wrong picture. The picture stays in step with the music: audio is rendered well ahead of the speakers (up to about 0.75 s by the desktop render-ahead, more when the master needs a bar ahead, a few blocks in the browser), so every captured sample carries its place in the output stream and the display reads the stretch the speakers are playing, not the newest. Capture happens only while a display is on screen, costs nothing otherwise, only ever comes from the renderer being listened to (never a bounce, export or preview), and works the same in the browser, where the audio worker sends the heard window with its regular status report. A song already fully bounced to disk is played live while such a display is open; one opened during disk playback says 'Not live' until Play is pressed again, rather than restarting every effect mid-song. This is what the upcoming EQ's spectrum and the scope are built on. Details: docs/effects-meters-and-taps.md.",
      "tickets": [
        501,
        543
      ]
    },
    {
      "title": "Effects can show gate and stereo-correlation meters, and Lua components can show meters of their own",
      "version": "2026.8.332",
      "date": "2026-09-28",
      "area": "Sound engine",
      "type": "Improvement",
      "userFacing": true,
      "details": "Meters are now declared by the effect itself and read generically, instead of the engine keeping a list of the compressor, limiter and HALL 9000 meters it knew about. Those three read exactly the same values as before. New meter kinds: gate state (a lamp) and stereo correlation (from a centre line), and balance meters are now drawn from the centre as they always should have been. Any effect can also have the engine measure its output level, correlation or balance without its sound changing. A Lua component can declare up to four meters in its script and publish them with dsp.meter(n, value). Works on desktop and in the browser. Details: docs/effects-meters-and-taps.md.",
      "tickets": [
        494
      ]
    },
    {
      "title": "A shared DSP kit and an effect test harness for the new built-in effects",
      "version": "2026.8.332",
      "date": "2026-09-28",
      "area": "Sound engine",
      "type": "Internal",
      "userFacing": false,
      "details": "Foundations for the built-in effects suite (#491), neither heard yet on its own. The DSP kit (src/Ezmuze.Audio/Dsp, namespace Ezmuze.Audio.Dsp) adds per-sample parameter smoothers (one-pole and linear ramp), a fractional delay line with linear, Hermite and allpass readers, 2x and 4x linear-phase polyphase oversamplers (35 and 41 samples of latency), a TPT state-variable filter with nine responses from one core plus a TPT one-pole, a DC blocker, and tempo divisions (1/64 to 8 bars, straight, dotted and triplet). The effect harness (tests/Ezmuze.Tests/EffectHarness) is a test base any effect plugs into: bit-identical neutral and bypass, silence without denormals, a zipper check for fast parameter sweeps, an aliasing check for nonlinear effects, a CPU budget per 64/256-sample block, stereo independence and mono compatibility, all at 44.1 and 48 kHz. It runs on every kit piece, on fx.eq3 and on fx.plate (unchanged), and found that fx.plate's Size zippers when automated (#539). The test project now JITs loops straight to optimised code so CPU budgets measure what ships. docs/effects-dsp-kit.md.",
      "tickets": [
        492,
        495
      ]
    },
    {
      "title": "Only one New song dialog can be open at a time",
      "version": "2026.8.331",
      "date": "2026-09-28",
      "area": "UI",
      "type": "Bug fix",
      "userFacing": true,
      "details": "Asking for a new song while the New song dialog was already open stacked a second, identical dialog on top of the first. Pressing Create on the top one then left the other one showing, sharp over the dimmed and blurred app, so it looked like a frozen picture of the dialog you had just answered, and anything that opened next (a module panel, for one) went underneath it. Now a second request flashes the dialog that is already open instead of opening another. The dimmed backdrop itself was never stuck: it was the leftover dialog holding it up.",
      "tickets": [
        544
      ]
    },
    {
      "title": "Store modules install on the web, and are still there after you refresh",
      "version": "2026.8.330",
      "date": "2026-09-28",
      "area": "Web",
      "type": "Bug fix",
      "userFacing": true,
      "details": "In the browser, installing a module from the store never worked: the download arrived, but the browser has to prepare its (asynchronous) WebCrypto reader before a sealed package can be checked and opened, and the app never did, so every install failed, nothing reached the Add menu, and the sign-in sync silently failed to bring back the modules you own. Installing now prepares the package first and only reports success once it is saved in the browser's own storage (IndexedDB). Each time you open the app, your installed modules are read back from that storage (nothing is downloaded again) and made ready to open, with a progress bar while it happens. A module the browser has lost from its storage is fetched again by the sign-in sync instead of sitting in the menu unable to open. The desktop app is unchanged.",
      "tickets": [
        490
      ]
    },
    {
      "title": "On the web, the display scale setting changes the size of the interface, and it stays sharp",
      "version": "2026.8.329",
      "date": "2026-09-28",
      "area": "Web",
      "type": "Bug fix",
      "userFacing": true,
      "details": "In the browser, File > Preferences > display scale changed how sharp the app was instead of how big it was: at Windows 150%, a custom 100% made the whole interface soft (the browser stretched a low-resolution picture), 200% looked no different, and the mouse could drift away from what was drawn. The web now works like the desktop app: the interface is always drawn at your screen's real resolution, and the setting makes everything bigger or smaller. Set by system is unchanged. Clicks also land on the right control at every setting, including quick clicks, which at custom scales used to hover the right button and miss it (GustUI InputManager).",
      "tickets": [
        487
      ]
    },
    {
      "title": "The store's Modules and Content packs tabs list again instead of showing a catalogue error",
      "version": "2026.8.329",
      "date": "2026-09-28",
      "area": "Services",
      "type": "Bug fix",
      "userFacing": true,
      "details": "Both tabs showed only \"Could not read the module catalogue: ... System.Guid Path: $[6].product.moduleId\". The catalogue lists preset packs alongside modules, and a preset pack has no module id, so the server sends null for it; the app declared that field as a required GUID, and the first preset pack published to the store (Grrowl) made the whole list unreadable. The module id (and the licence id, which is null for a product with no store licence yet) are now optional, and the store's catalogues (modules, module licences and the pack products) are read one entry at a time: an entry this build cannot read is left out and counted in the log, and everything else still shows.",
      "tickets": [
        481
      ]
    },
    {
      "title": "Remote control: clicking into a minimised window now fails clearly instead of reporting success",
      "version": "2026.8.328",
      "date": "2026-09-27",
      "area": "Desktop",
      "type": "Bug fix",
      "userFacing": false,
      "details": "A minimised window lays GustUI's root out at 0x0, so POST /click (and /move, /mousedown, /mousemove, /scroll, POST /hover) hit nothing yet answered {\"ok\":true}, leaving a script waiting for a result that never started. Those pointer endpoints now answer 409 with \"window is minimised - POST /window {\\\"action\\\":\\\"restore\\\"} first\" (or a no-layout message when the root is 0x0 for another reason). Lua's ezmuze.click raises it as an error and the MCP click tool returns it with isError. The app does not restore itself, so it never jumps over what the user is doing. /key, /mouseup and DELETE /hover are not refused: keys go to the focused element and still arrive while minimised, and a release must always be possible. Documented under Known limitations in docs/remote-control-api.md; the gate is ControlPointerGate in ControlArgs.cs, unit-tested in ControlArgsTests.",
      "tickets": [
        488
      ]
    },
    {
      "title": "The web status-packet allocation test no longer fails when the full suite is busy",
      "version": "2026.8.327",
      "date": "2026-09-28",
      "area": "Build",
      "type": "Internal",
      "userFacing": false,
      "details": "WorkerStatusPacketTests (#445) checks the live-value queries allocate nothing by counting this thread's bytes. In a parallel full-suite run another class's gen-2 GC trimmed the shared pools mid-measure and it failed once, the same cause as #478. The class now runs in the non-parallel ProcessHeap collection, warms three times, and passes if one of three 100-call measurements is allocation-free: steady-state allocation still fails every attempt."
    },
    {
      "title": "Opening a song shows what it is doing, instead of looking hung",
      "version": "2026.8.327",
      "date": "2026-09-28",
      "area": "UI",
      "type": "Improvement",
      "userFacing": true,
      "details": "Opening a big song froze the window for up to two seconds on desktop, and in the browser left an empty screen for five or more while it downloaded and then froze, with nothing to say the app was working. Every way of opening a song (a demo, File > Open, a recent song, reopening the last one at start-up, a shared link, crash recovery, a legacy import) now shows an 'Opening song' card over the blurred app: the song's name, what it is doing ('Downloading — 6.2 of 14.8 MB', 'Getting packs — 2 of 3', 'Preparing audio — 12 of 37 samples', 'Building the timeline'), and a bar that fills only when there is something real to count. It appears after 300 ms of waiting, or straight away before a big song's unavoidable freeze, so small songs still open without a flash. Cancel works until the moment the old song is let go. On desktop, reading the file and decoding its samples now run off the main thread, so the window keeps drawing through them; only building the timeline still holds it. In the browser the demo download is streamed so its progress is real.",
      "tickets": [
        480
      ]
    },
    {
      "title": "The credits screen never shows a supporter's email address",
      "version": "2026.8.326",
      "date": "2026-09-28",
      "area": "UI",
      "type": "Bug fix",
      "userFacing": true,
      "details": "Supporters are listed by their ezmuze central username, and some older accounts use their email address as their username, so Help > Credits could print one. A name containing an @ is now never shown: that supporter is left off the list rather than shown by part of the address. This applies to the live list, a previously saved copy and the list built into the app, and the saved copy no longer keeps such a name.",
      "tickets": [
        485
      ]
    },
    {
      "title": "The credits screen on the web shows the live supporters list",
      "version": "2026.8.326",
      "date": "2026-09-28",
      "area": "Web",
      "type": "Bug fix",
      "userFacing": true,
      "details": "Help > Credits fetches the current supporters from www.ezmuze.co.uk. On the web build the browser refused that request, because the site does not allow requests from other origins, so the screen only ever showed the snapshot built into the app. The request now goes through the web host's service proxy, the same route pack art, previews and the store already use. The desktop apps are unchanged and still fetch it directly.",
      "tickets": [
        484
      ]
    },
    {
      "title": "Demo songs download about half the size on the web",
      "version": "2026.8.325",
      "date": "2026-09-28",
      "area": "Web",
      "type": "Performance",
      "userFacing": true,
      "details": "The web build sent the demo songs uncompressed, even to browsers that asked for compression, so Elevation was a 14.8 MB download before it could open. Publishing now writes Brotli and gzip copies beside each demo and the web host sends those: Elevation drops to 8.5 MB, The Dark from 7.9 to 3.8 MB, Too many chords from 1.4 to 0.3 MB and the synth demo from 1.1 to 0.4 MB. The browser unpacks them itself, so nothing in the app changes. The SDK compresses every other asset already; it skipped the demos because their .ezproj extension makes it treat them as project files. The host also keeps such a file's own content type when serving a compressed copy, instead of relabelling it application/x-gzip.",
      "tickets": [
        482
      ]
    },
    {
      "title": "The \"Rendering waveforms\" readout waits the same 1.5 seconds at any frame rate",
      "version": "2026.8.325",
      "date": "2026-09-28",
      "area": "UI",
      "type": "Bug fix",
      "userFacing": true,
      "details": "The progress toast for rendering pattern waveforms only appears once the work has run for 1.5 seconds, so a quick render does not flash a bar on and off. That wait was counted in frames at an assumed 60 per second, so it lasted 3 seconds on a window running at 30 fps and about 0.6 seconds on a 144 Hz display, where the flicker it exists to prevent came back. It now measures real elapsed time.",
      "tickets": [
        483
      ]
    },
    {
      "title": "The web app loads its opening song again",
      "version": "2026.8.324",
      "date": "2026-09-28",
      "area": "Web",
      "type": "Bug fix",
      "userFacing": true,
      "details": "Since 2026.8.316 the browser build failed to load Three Blind Mice, the song it opens with, and fell back to placeholder data. The one-time filing of imported presets into import folders (#458) remembered that it had run under a storage key the browser's preset store refuses, so start-up stopped at 'Invalid preset key.' before the song was built. The flag now lives inside the preset library's own keys, a desktop install that already ran the filing is still recognised, and a test checks every fixed preset-library key against the browser store's rule.",
      "tickets": [
        479
      ]
    },
    {
      "title": "Two load-sensitive test groups no longer fail at random in a busy full-suite run",
      "version": "2026.8.324",
      "date": "2026-09-28",
      "area": "Build",
      "type": "Internal",
      "userFacing": false,
      "details": "Checksumming_a_demo_pattern_allocates_nothing: PatternCacheChecksum encodes long strings (Lua source, a hosted plugin's ExternalState) into a buffer rented from ArrayPool<byte>.Shared, which hands a warmed-up thread its own cached array without allocating. But a gen-2 GC under high memory load makes the pool's trim callback empty every thread's cache, and the next Rent allocates a fresh 16 or 64 KB array. Another test class's garbage set that off inside the measuring window. The product behaves as designed, so the test changed: it moves to its own class in the non-parallel ProcessHeap collection, measures each round, and excuses at most two rounds per gen-2 GC seen (plus one for a callback arriving late). A real regression allocates on every round and still fails; checked by reintroducing a per-component List. SoundFontDeferredLoadTests: the piano's ~5 s decode runs on the BelowNormal-priority heavy-loads thread, and BackgroundLoads.WaitForIdle waits on a process-wide queue. With every core busy running Normal-priority test threads in parallel, Windows barely schedules the loader, and the 60 s waits timed out, twice per test. The class moves to the non-parallel HeavyLoads collection, the waits (still on the queue going idle, never a sleep) get a 5-minute backstop, and the 1.5 s bound on the first bar now fails only if the piano had loaded by the time the render returned, which is what a render waiting for the load looks like. The zero-heavy-loads-on-the-render-thread check is unchanged and exact.",
      "tickets": [
        478
      ]
    },
    {
      "title": "Browser playback spends less time on meters and the playhead, leaving more for the audio",
      "version": "2026.8.324",
      "date": "2026-09-28",
      "area": "Web",
      "type": "Performance",
      "userFacing": true,
      "details": "In the browser, the audio worker reports the playhead, channel meters, the spectrum and master-input samples, and any live values the UI asked for about 21 times a second (once per 2048-frame block). The worker renders the audio on the same single-threaded .NET runtime. Each report built three new float arrays (about 3,100 floats) and serialised them to about 75 KB of JSON. It also re-parsed the live-value query list, parsing every GUID and building a dictionary. The UI then parsed all of it back. The report is now a packed binary packet (WorkerStatusPacket) written into one reused buffer and transferred to the page. The page copies it into a reused C# buffer through a MemoryView import. The UI sends the query list only when it changes, and the worker parses it once. No SharedArrayBuffer or cross-origin isolation is needed. Measured in headless Edge on a Release AOT publish while The Dark played for 30 seconds (about 650 reports): the worker's report fell from a mean of 1.64 ms (p95 2.2 ms) to 0.09 ms (p95 0.2 ms), against a mean render of about 5 ms a block. The UI side fell from 1.25 ms to 0.04 ms, and the payload from 75 KB to 12.5 KB. On desktop .NET, with the same renderer and 62 queries, the worker path went from 98 KB allocated per report to none, and the UI path from 13 KB to none. Rendered peaks were identical and there were no underruns.",
      "tickets": [
        445
      ]
    },
    {
      "title": "The Windows (DirectX) build no longer carries a vulnerable, unused .NET Core 1.0 runtime package",
      "version": "2026.8.324",
      "date": "2026-09-27",
      "area": "Build",
      "type": "Bug fix",
      "userFacing": false,
      "details": "Restoring the DirectX head warned NU1903 three times (high-severity advisories on Microsoft.NETCore.App 1.0.5 and Microsoft.NETCore.Jit 1.0.7). The chain: the KNI fork's WindowsDX11 project references SharpDX 4.0.1, whose .NETCoreApp1.0 dependency group pulls in the Microsoft.NETCore.App 1.0.5 metapackage, the whole .NET Core 1.0 runtime as NuGet packages. On net10.0 the runtime is the shared framework, so that graph did nothing except raise the warnings and copy about 14 MB of dead files into the build (Roslyn 1.3 and libuv for seven platforms). The DX csproj now prunes Microsoft.NETCore.App (PrunePackageReference). That removes it and the 15 packages only it pulled in; SharpDX and every other resolved package are unchanged, and every other project's graph is identical. Pinning a patched version was rejected because the first one that fixes all three advisories (2.0.3) carries netcoreapp2.0 reference assemblies and build props. dotnet list package --vulnerable is now clean across src, tests and tools. See docs/build-hygiene.md.",
      "tickets": [
        477
      ]
    },
    {
      "title": "Build hygiene: an .editorconfig, nullable warnings as errors where already clean, central package versions",
      "version": "2026.8.323",
      "date": "2026-09-28",
      "area": "Build",
      "type": "Internal",
      "userFacing": false,
      "details": "First, behaviour-neutral slice of build hygiene. Added an .editorconfig that describes the existing style (4-space C#, Allman braces, usings outside namespaces, tabs only in the vendored WavPack port) and enforces nothing in the build. Directory.Build.props states the analyzer settings the SDK already used (analyzers on, AnalysisMode Default, AnalysisLevel left to the TFM, because 'latest' means 9.0 on SDK 10.0.100). Directory.Build.targets makes nullable warnings errors in every project with Nullable=enable that builds clean: Core, Legacy, Reaper, Services, the four service plugins, Studio.Host, Audio.Gpu.D3D11 and the tools. Core's 7, Reaper's 1, Services' 1 and ImportReaper's 1 warnings were fixed with annotations only (no IL change). Ezmuze.Audio (24), Services.Splice (80), Ezmuze.Tests (261) and the GpuDspSpike tool (5) opt out until they are clean. Central package management: Directory.Packages.props now holds every NuGet version. It was a pure move, and every project's resolved package graph (obj/project.assets.json, 49 projects, 412 packages) is identical before and after. Still open on the ticket: AnalysisMode Recommended (measured at about 3,170 new warnings, so not switched on) and the nullable rollout to the app heads (2,020 warnings in 158 files). docs/build-hygiene.md has the per-folder plan and all the counts."
    },
    {
      "title": "The Wave bank sampler plays a loop at the length it says it is",
      "version": "2026.8.323",
      "date": "2026-09-28",
      "area": "Sound engine",
      "type": "Bug fix",
      "userFacing": true,
      "details": "A wave-bank device with Fit set to Tempo sized every sample by rounding its raw length to the nearest beat, and ignored the bars the sample declares (from a tempo in its name, the service it came from, or its ACID chunk). So an 8-bar loop at 120 BPM in a 124 BPM song played as 8.25 bars, 3% slow, while the same loop dropped as a sample clip was exactly 8 bars. The device now uses the same rule as a drop: the declared bars when there are some, four for a legacy pack loop, the nearest beat otherwise. No shipped demo declares bars on any sample, so none of them changes; a saved song whose declared loop plays through such a device now plays it at the length it takes as a clip. The pattern cache key includes a declared size, so a cached pattern re-renders when a tempo is worked out later. Sample clips are untouched: they play their stored length and never go through this fit. Tested in WaveBankDeclaredFitTests.",
      "tickets": [
        476
      ]
    },
    {
      "title": "Spectral (GPU offline) stretch puts hits on the beat in both directions",
      "version": "2026.8.322",
      "date": "2026-09-28",
      "area": "Sound engine",
      "type": "Bug fix",
      "userFacing": true,
      "details": "A clip on the Spectral (GPU offline) algorithm with Keep pitch on placed its hits late when it was slowed down and early when it was sped up: about 220 frames (5 ms) late at 0.8x and 280 early at 1.25x, measured on a click loop against the same lap played as varispeed. The offline phase vocoder puts its frames on the grid by their start, but what a 2048-point Hann frame carries is centred half a window in, so every moment came out 1024 x (1 - stretch) frames off. The render now trims its output by that much (and pads further for stretches past 3x so the trim never starts before the buffer). Averaged over many hits it now lands within 100 frames at 0.67x to 1.5x, where it was 340 late to 540 early. Single hits near 1x still wander about 200 frames either way: the vocoder has no phase locking, which is its sound rather than an offset. The algorithm moves to version 3 so cached laps re-render; its id and everything else are unchanged. Tested in StretchAlgorithmTests.",
      "tickets": [
        474
      ]
    },
    {
      "title": "MNTS v5's live shifter reports its real delay",
      "version": "2026.8.322",
      "date": "2026-09-28",
      "area": "Sound engine",
      "type": "Bug fix",
      "userFacing": false,
      "details": "The stretch registry reported the MNTS v5 family's live latency as 2560 frames, the offset the shifter reads its input back at. Its output really comes out 2640 to 2760 frames late at pitch ratio 1, depending on where a hit falls against its 36 ms segment grid, 2705 on average: about half its 6 ms join crossfade on top. It now reports 2700 (Mnts5LiveShifter.ReportedLatencyAt44k); the read-back offset is unchanged, so nothing sounds different. No clip plays through this shifter today, because every MNTS entry renders clips from the region with no latency, so this only matters to a future caller. LatencyFrames stays a 44.1 kHz number used unscaled, which is right because the engine runs at 44.1 kHz only (both audio bridges and SongRender are fixed at it); the field's doc now says what running at another rate would need. Tested in StretchAlgorithmTests (the average delay over 30 positions is within 16 frames of what is reported, and none is more than 100 away).",
      "tickets": [
        475
      ]
    },
    {
      "title": "Dragging a loop shows the length it will be placed at",
      "version": "2026.8.322",
      "date": "2026-09-28",
      "area": "UI",
      "type": "Bug fix",
      "userFacing": true,
      "details": "While a sample from the song's bank or from Freesound was dragged onto the timeline, the ghost showed a length from the wave-bank fit law (the loop's raw length in the song's bars, to the nearest beat), while the drop placed the length the loop declares. For a loop at another tempo those differ: an 8-bar loop tagged 120 BPM showed 9 cells in a 124 BPM song and placed 8. Both ghosts now ask the drop's own rule. The drop itself was already right: a loop that states its tempo (a tag like 124bpm in its name, the service's tempo, a WAV ACID chunk, a pack) is fitted to its own whole bars at any song tempo. The nearest-beat rule stays for samples that say nothing, so a genuine bar-and-a-half loop keeps its length, and it stays for two places that refit SAVED songs: converting a song older than v37 when it is first opened, and live wave-bank devices. Songs from v37 on store every sample clip's length and are never refitted on load. Recorded in docs/sample-clips-design.md 3.3.1; tested in DeclaredTempoFitTests (the ticket's tempos, the name, service and ACID paths, and every shipped demo opening with the lengths it was saved at) and SampleTempoTests (more names that are and are not tempo tags).",
      "tickets": [
        473
      ]
    },
    {
      "title": "The sequencer's source is split into partial files by subsystem",
      "version": "2026.8.321",
      "date": "2026-09-28",
      "area": "UI",
      "type": "Internal",
      "userFacing": false,
      "details": "SequencerView.cs had grown to about 20,000 lines in one file. Step 1 of #452 moves its members, whole and verbatim, into partial files named for what they do: Transport, TimelineInput, DragDrop, Blocks, BlockEdits, Selection, Zoom, History, Build, Gutter, Rows, Lanes, Tfx, Channels, Tricks, Stack, Timeline, Menus and Import. SequencerView.cs keeps the fields, the constructor, Update, Layout and the small helpers. Nothing changed but where the code lives: a Roslyn check showed the multiset of member declarations and their exact text is identical before and after. The split was scripted, so it can be redone on top of newer changes rather than merged by hand. The shortcut-documentation test now treats a screen's partial files as one screen. Step 2, extracting the row controls and a block binder into real classes, is still to do, and #452 stays open for it."
    },
    {
      "title": "Pitch-kept loops on the Phase vocoder play on the beat instead of 9 ms late",
      "version": "2026.8.321",
      "date": "2026-09-28",
      "area": "Sound engine",
      "type": "Bug fix",
      "userFacing": true,
      "details": "A sample clip with Keep pitch on, on a song using the Phase vocoder (every shipped demo does), played about 9 ms behind the grid, so it sat late against drums and against the same loop with Keep pitch off. The clip cache and the live voice both compensate a shifter's delay by the latency the stretch registry reports, and the phase vocoder reported 1639 frames: smb's input-FIFO latency, fftFrameSize minus one hop. Its output is really a whole frame, 2048, behind its input, so every compensated lap was 409 frames short. It now reports 2048 (FFTStretch.LatencyFrames), and the Phase vocoder and Spectral (GPU offline) entries move to version 2 so cached laps re-render. Measured on a click loop, the pitch-kept hits went from 347 to 512 frames late of the same lap played as varispeed to -62 to +124 (mean +31), where the other engines already sat. Signalsmith, Signalsmith Lite, Overlap-add and the MNTS v5 family were already on time and are unchanged. Tested in StretchAlgorithmTests (every fixed-latency live shifter comes out where the registry says; every engine's pitch-kept hits land where varispeed puts them).",
      "tickets": [
        471
      ]
    },
    {
      "title": "The Dark demo: the strings loop plays over 8 bars again",
      "version": "2026.8.321",
      "date": "2026-09-28",
      "area": "Sound engine",
      "type": "Bug fix",
      "userFacing": true,
      "details": "The Dark's Strings loop (Garden Strings) is 8 bars at 120 BPM, and the REAPER session plays it at 124 with a playrate of 1.0333, exactly 8 bars. Since the loop fit moved to the nearest beat (2026-09-08) and the loops became sample clips, it was fitted to 8.25 bars: 3% slow, restarting a quarter bar late at every clip. The 8 clips and their definitions are now fitted to 8 bars (set in the studio and saved back), and TheDarkLoopFitTests pins it. The same rule mis-fits most loops whose tempo is a few percent off the song's; that is filed as its own ticket (#473) rather than changed here.",
      "tickets": [
        472
      ]
    },
    {
      "title": "Opening a big folder in the song picker no longer freezes the app",
      "version": "2026.8.321",
      "date": "2026-09-27",
      "area": "UI",
      "type": "Performance",
      "userFacing": true,
      "details": "On desktop the song picker (File > Open, Save, Import) read a folder on This computer on the same thread that draws the app, so the window stopped for as long as the disk took: 80-190 ms for a folder of 2,000 songs on a warm disk, and much longer on a cold, network or sleeping drive. The folder is now read in the background and the list fills in when it arrives; the worst frame while opening that folder went from 80-190 ms to 3-17 ms, the same as with nothing happening. A folder that takes longer than a moment says Loading… under the list. If you move on (Back, another folder, another source) before a slow folder arrives, it is dropped and never overwrites the folder you are now looking at. Sorting, selection, typed paths, Back and the Demos cards are unchanged. The web edition's browser storage has no folders to read and is unchanged.",
      "tickets": [
        470
      ]
    },
    {
      "title": "Elevation demo: the loops play in time again",
      "version": "2026.8.321",
      "date": "2026-09-27",
      "area": "Sound engine",
      "type": "Bug fix",
      "userFacing": true,
      "details": "Ten of Elevation's sample loops had drifted out of time. The REAPER session stretches every one of them to exactly 4 or 8 bars. The port got there with the old power-of-two loop fit, and when the fit moved to the nearest beat (2026-09-08) and the loops became sample clips, they were fitted to the nearest beat instead: Trap Melody to 3.5 bars (12.5% fast, restarting half a bar early), RKU Drums and the three DS vocals to 7.75, Perc Almond and the guitar to 9.75, Faraway Lead to 8.25, Foley Beat and Assa Hat to 3.75. Each is now fitted to the bars the session plays it over (set in the studio and saved back). Measured per bar against the source placed the way REAPER places it, the channels went from medians of -114 to -196 ms (Trap, RKU, Foley, vocals) to 0 ms for the unstretched-pitch loops and +4 to +16 ms for the pitch-kept ones. Against its REAPER stem, the Trap Melody onset correlation now peaks at -6 ms (was +116). A test pins the ten lengths (ElevationLoopFitTests). docs/elevation-demo-port.md has the tables.",
      "tickets": [
        466
      ]
    },
    {
      "title": "Freesound tells you when your sign-in needs renewing, even while previews keep working",
      "version": "2026.8.321",
      "date": "2026-09-27",
      "area": "Services",
      "type": "Bug fix",
      "userFacing": true,
      "details": "When Freesound refused to refresh a stored sign-in, the download fell back to the preview on the app key, and the successful preview cleared the \"Freesound refused to refresh the sign-in; log on again.\" message, so nobody was ever told. The refusal is now remembered: the message stays in the Services tab after the fallback and after later previews, and the account row reads \"Sign-in expired\" (preview quality until you log on) instead of claiming original-quality downloads. A working sign-in or refresh, or signing out, clears it. The state stays Connected on purpose: browsing on the app key still works, and Invalid would swap the Freesound results for a sign-in prompt. Tested in ServiceConnectorTests.",
      "tickets": [
        467
      ]
    },
    {
      "title": "A plugin that stops responding while playing no longer freezes the window",
      "version": "2026.8.321",
      "date": "2026-09-27",
      "area": "Sound engine",
      "type": "Bug fix",
      "userFacing": true,
      "details": "Developer builds with VST3 hosting only. A plugin that never returned from its process call held the render-ahead thread, and with it the renderer's lock, so the window froze on the next edit or sooner, on the audio underrun that renders on the game thread. The stuck thread still cannot be stopped from inside the app, but nothing on the game thread waits for it any more. The plugin watchdog now names the stuck thread; the underrun fallback waits at most 50 ms for the renderer and plays silence past that (nothing is published to the ring, so the playhead stays right); every other way into the renderer waits in slices and gives up once the thread holding it is known to be stuck: an edit is refused with a toast (\"A plugin has stopped responding and is holding up playback, so edits are paused. Save your work and restart ezmuze studio.\"), meters and readouts read without the lock, and state changes are skipped. The plugin is shut out of the next compile of its channel, a hung node returns silence without calling in, and if its call ever comes back the render carries on and edits work again. Saving never needed the renderer, so File > Save works throughout. A render that is merely slow is still waited for; the first five seconds of a hang, before the watchdog calls it, still stall the window. No thread hop per block: The Dark renders in the same time before and after. docs/vst-support-design.md section 14.10.",
      "tickets": [
        465
      ]
    },
    {
      "title": "Big folders and long sample lists open instantly and scroll smoothly",
      "version": "2026.8.320",
      "date": "2026-09-28",
      "area": "UI",
      "type": "Performance",
      "userFacing": true,
      "details": "The song picker (File > Open, Save, Import) and the Samples browser used to build every row of a listing up front, with a waveform row per sample, however many there were. They now build only the rows you can see plus a few either side, and reuse them as you scroll (GustUI's new VirtualListElement). Measured with 2,000 files: opening the folder froze the frame for 810-930 ms and now takes 220-400 ms, most of it reading the folder from disk; with the folder open the app ran at 8-12 fps (80-120 ms to draw a frame) and now runs at about 400 fps (1.4 ms); the list went from about 10,000 elements to about 110. In the Samples browser with 2,000 results, a new listing froze for 460-710 ms and now takes 7-13 ms, and clicking a sample froze for 1.1-1.9 s and now takes 8-26 ms. Nothing looks or behaves differently: sorting, selection, double-click, the right-click menu, typed paths, dragging a sample onto the timeline and previews all work as before. Pack loops are now decoded for their waveform when their row first scrolls into view rather than all at once.",
      "tickets": [
        450
      ]
    },
    {
      "title": "Clicking a sample no longer jumps the Samples browser back to the top",
      "version": "2026.8.320",
      "date": "2026-09-28",
      "area": "UI",
      "type": "Bug fix",
      "userFacing": true,
      "details": "Selecting or previewing a sample rebuilt the whole result list to repaint the highlight, and rebuilding a scroll view resets it to the top, so every click on a sample further down the list threw you back to the first one. The rows in view are now redrawn in place and the list stays where it was.",
      "tickets": [
        468
      ]
    },
    {
      "title": "Control API actions are a handler map, and update_listing no longer freezes the UI while art uploads",
      "version": "2026.8.320",
      "date": "2026-09-28",
      "area": "Desktop",
      "type": "Internal",
      "userFacing": false,
      "details": "ControlCommandExecutor.RunAction was a single 2,600-line switch. It is now a case-sensitive Dictionary<string, handler> (ActionHandlers) with one method per action, grouped into partial files by area (ControlCommandExecutor.Project/Sequencer/Windows/Modules/Store/LoopPacks.cs). Mechanical and behaviour-preserving: every action name, argument parse, error message and reply is unchanged, an unknown name still gets the catalogue's error, and the plain {\"ok\":true} tail (after the next frame for module_ui, module_editor and preset_browser) is CompleteActionOk. ControlActionCatalogTests now reads the map's keys and holds them equal to ControlActionCatalog's names. The one real fix: update_listing with art waited for UploadArtAsync with GetAwaiter().GetResult() on the game thread, freezing the whole UI for the length of the upload; it now answers from a continuation, the same way glue and cues_to_pads do, and a test keeps every action handler from blocking on a task.",
      "tickets": [
        453
      ]
    },
    {
      "title": "The CPU cost readouts in the Sequencer and the Stack now agree, and follow a theme change straight away",
      "version": "2026.8.320",
      "date": "2026-09-28",
      "area": "UI",
      "type": "Bug fix",
      "userFacing": true,
      "details": "The Sequencer's per-channel \"CPU NN%\" badge and the Stack's per-device \"NN% CPU · NN% of channel\" strip had been written twice and had drifted. The Stack's version (the newer one, reworked on 2026-09-27) remembers the raw figures and the colour it last drew and redraws when any of them changes; the Sequencer's remembered only the rounded percentage and whether it was over the 25% warning line. Two visible differences followed: the Sequencer rounded with MathF.Round, which sends a half to the even number (12.5% read \"CPU 12%\" where the Stack read \"13%\"), and a theme change made while playing left the Sequencer's badges in the old theme's colour until their number next changed. Both badges now go through one shared helper (CpuCostBadge) with the Stack's behaviour: halves round away from zero everywhere, and a badge recolours as soon as the theme does. Alongside it, and with no change in behaviour, the wire-scaler popover and its hit squares, which the module editor and the Stack each carried a copy of, are now one WireScalerController, with the slider/placement arithmetic in WireScalerMath; both new pure halves are unit-tested.",
      "tickets": [
        454
      ]
    },
    {
      "title": "Grouping channels during playback no longer stalls the audio",
      "version": "2026.8.320",
      "date": "2026-09-27",
      "area": "Sound engine",
      "type": "Performance",
      "userFacing": true,
      "details": "Moving a channel into or out of a group while the song plays used to compile the group parent's audio graphs on the UI thread while holding the lock the audio thread needs: a full graph when the parent's group input came or went, and a generator-only graph plus an effects graph when a parent with its own synth started playing its children's notes. On the shipped demos a warm compile of one of these costs up to 10-15 ms. The live renderer now builds them all in the background, as it already did for every other edit, and they take over when ready. Until then the old graph plays on, with the children going to the parent's bus and a parent's synth playing their notes on its full graph. Reindex after a regroup on the demos went from 0.5-3.9 ms to 0.3-0.6 ms. GroupWiringCompileTests counts compiles on the editing and render threads: 4 before, 0 now. Offline renders (exports, bounces) still compile in line.",
      "tickets": [
        460
      ]
    },
    {
      "title": "Moving a channel out of a group while playing no longer crashes the audio",
      "version": "2026.8.320",
      "date": "2026-09-27",
      "area": "Sound engine",
      "type": "Bug fix",
      "userFacing": true,
      "details": "A group channel with its own synth plays its children's notes through a generator-only graph of its own. Dragging its last child out of the group during playback threw that graph away immediately, while the bar in progress was still rendering from it, and the next slice of audio hit a null reference on the render thread. The graph and its buffers are now dropped at the next bar line, after the bar that began with them has finished. Found while working #460 and reproduced on the released code with the thedark demo. ChannelNoteRiseTests.UngroupingMidBar_FinishesTheMergedBarItBegan ungroups half-way through a merged bar; it threw before this change.",
      "tickets": [
        469
      ]
    },
    {
      "title": "No more clicks at bar lines from a group channel's own synth",
      "version": "2026.8.320",
      "date": "2026-09-27",
      "area": "Sound engine",
      "type": "Bug fix",
      "userFacing": true,
      "details": "A group channel with its own synth plays its children's notes, and it used to replay bars it had already rendered instead of rendering them again. The replay assumed a bar with the same notes always sounds the same, but a synth's oscillator keeps running across the bar line and is rarely at the same point in its cycle twice. A replayed bar therefore started at the wrong point: on a held note that put a small click on every bar line, and a layer that drifted in and out of phase with the children playing the same notes. Those bars now always render live, which is exact. The cost is some CPU on a group channel that has its own synth; no shipped demo uses one. The first diagnosis on this ticket (voices rotating) was wrong: the synth that showed it plays one voice. ChannelNoteRiseTests.AGroupSynthHoldingANote_RendersLiveByDefault holds MIDI 46 under a grouped Basic synth and requires the default render to equal the live one; with the old default it diverged at bar 3. The reasoning is recorded in docs/channel-hierarchy-design.md section 6.",
      "tickets": [
        464
      ]
    },
    {
      "title": "Sample clips cost less at every bar line",
      "version": "2026.8.320",
      "date": "2026-09-27",
      "area": "Sound engine",
      "type": "Performance",
      "userFacing": false,
      "details": "Measured first: the 13-19 ms and 0.4-0.6 MB per bar reported for sample-clip bar setup came from a standalone render that skipped what the desktop app does when a song opens - decoding every sample clip's audio up front (PredecodeSampleClipEntries) - so each sample's first bar decoded it on the render thread, plus one-time per-channel buffers, averaged over the bars. Rendered the way the app renders (samples predecoded, four bars in), sample-clip bar setup was 0.6-1.4 ms and ~5 KB per bar, and most of that was clearing two bar-long mix buffers for every sample channel on every bar, sounding or not. Those buffers are now cleared only when they can hold something: The Dark 631 -> 133 us per bar, Elevation 1369 -> 355 us per bar. Output is bit-identical (all four demos hash the same), and SampleClipRendererTests.SilentBarsBetweenSoundingOnesLeaveNothingBehind checks sounding, silent and part-bar clips through one renderer against fresh ones.",
      "tickets": [
        461
      ]
    },
    {
      "title": "Polyphonic synths do less work at every bar line",
      "version": "2026.8.320",
      "date": "2026-09-27",
      "area": "Sound engine",
      "type": "Performance",
      "userFacing": false,
      "details": "At each bar line every voice of a polyphonic synth re-summed each of its note-driven wires (pitch, gate, velocity and so on, through their scalers) over the whole bar, on the audio thread, whether or not that voice's notes had changed. A voice now keeps last bar's sums when its note buffers come round bit-for-bit identical (idle voices, and voices replaying the same notes), and the sums it does redo run a vector at a time. The Dark's per-port bar setup (BarSetupProfiler GraphPorts) went from ~1000 to ~440 us per bar in a same-process A/B, with about half of its ~500 voice-wire sums a bar skipped. Output is bit-identical: WireBarReuseTests renders The Dark and Too Many Chords with the new path off and on and requires the same bytes, and all four demos hash the same as before the change.",
      "tickets": [
        462
      ]
    },
    {
      "title": "Grouped channels no longer replay the wrong bar of a multi-bar pattern",
      "version": "2026.8.320",
      "date": "2026-09-27",
      "area": "Sound engine",
      "type": "Bug fix",
      "userFacing": true,
      "details": "A group parent with its own synth plays its children's notes, and caches each bar it renders so repeats cost almost nothing. The cache's key worked out where in a multi-bar pattern a bar falls with a missing pair of brackets, so every bar of a 2-, 4- or 8-bar pattern looked like its first bar: from the fourth bar on, the parent could replay an earlier bar's notes in place of the ones actually written. The key now uses the right bar of the pattern. One-bar patterns and patterns that are not whole bars were never affected, and the cache is in memory only, so nothing saved needs rebuilding. ChannelNoteRiseTests.MergedBarCache_ReplaysTheRightBarOfAMultiBarPattern renders a 2-bar pattern under a grouped synth with the cache on and off and requires the two to match sample for sample (on the old key they diverged at bar 3).",
      "tickets": [
        463
      ]
    },
    {
      "title": "Tests for song save and open, the control server, the web proxy, the VST3 host and the service connectors",
      "version": "2026.8.319",
      "date": "2026-09-28",
      "area": "Build",
      "type": "Internal",
      "userFacing": false,
      "details": "New coverage for the areas #455 listed as untested. SongSaveLoadPathTests: a song through the desktop File > Save / Open path (SongSerializer to UTF-8, LocalDiskService's atomic write with a .bak, listed and read back through the same service): v38 shared sample-clip definitions with per-clip fades, the WavPack wave-bank entry, a Lua component's WAV stored as WavPack and an imported wavetable in its float codec all come back byte for byte and the song renders identically; the .bak holds and opens the previous save, a torn file is refused, a BOM is tolerated, a save with no folder chosen is refused. ControlServerRequestTests: the control server over a real loopback socket with a stand-in game thread - routing onto the command queue (query strings, JSON bodies, X-Ezmuze-Client, status and content type passed back, 500 on a failed command or a bad body), and the 2026-09-27 request filter (foreign Host, Origin, Sec-Fetch-Site, GET only on read-only paths with 405 + Allow, 415 for non-JSON POST bodies, DELETE only on /hover), which /log and /mcp go through too, plus the MCP endpoint's handshake, 202 notifications and tool calls. WebProxyTests: the Studio.Host /svc proxy handler invoked directly with a fake upstream - scheme and host allowlist (suffix rules, SSRF targets, redirect hops), request-header allowlist (no Cookie/Origin/Referer), credentials dropped once a redirect leaves their scope, 303/301/302/307/308 method and body rules, the 508 loop cap, the 64 MB body cap, the decode-once rule for client escapes, and response sanitising (Set-Cookie and friends stripped, active and malformed content types made opaque, nosniff and the sandbox CSP on every answer). Vst3HostTests: the VST3 host's managed parts, linked in since nothing may reference that project - parameter queues and change lists, the 48-byte event layout, the state stream, the scan cache (round trip, version and corruption handling, staleness by the binary inside a bundle), the REAPER state reader and the descriptor built from a cached scan - and, through the fake plugin host, the missing-plugin story: a plugin song opens without the plugin, plays silence, loses nothing through a save, and plays identically once the plugin is back. ServiceConnectorTests: Openverse, Internet Archive, Freesound and Google Drive over a faked HttpClient (requests, parsing, licences, token exchange and refresh, revoked sign-ins, stale-cache fallback, offline, the web proxy rewrite), and ServiceEnvironment's URL escaping and its web-addresses-only rule for opening a browser. No network anywhere; the control-server tests use a loopback port. The test project now links ControlServer, LocalDiskService (desktop half, DESKTOPGL) and the Ezmuze.Audio.Vst sources, with stand-ins for the few app statics they touch.",
      "tickets": [
        455
      ]
    },
    {
      "title": "A channel named after a module with a slash in its name keeps the whole name",
      "version": "2026.8.319",
      "date": "2026-09-27",
      "area": "UI",
      "type": "Bug fix",
      "userFacing": true,
      "details": "Adding Keyzoo 20/20 named its channel \"20\" in the sequencer and the Stack header (\"Stack - 20\"), and \"AC/DC Bass\" read \"DC Bass\". The gutter shortens the legacy \"<pack> / <type>\" channel name to its type, and it cut at any slash. It now cuts only at \" / \" with its spaces, the separator legacy names actually use, so a slash inside a name is kept. The rule lives in Ezmuze.Core ChannelNaming.ShortName (unit-tested); it was the only place a display name was split on a slash.",
      "tickets": [
        457
      ]
    },
    {
      "title": "Elevation demo: the trap melody, pad, poops, Starlight and vocals rebalanced to the original mix",
      "version": "2026.8.319",
      "date": "2026-09-27",
      "area": "Sound engine",
      "type": "Improvement",
      "userFacing": true,
      "details": "Each Elevation channel was solo-rendered without the master chain and compared, per 8 bars, with its REAPER stem. The calibrated channels sit at a common +5.0 dB against their stems (energy-weighted); these did not, by the same amount in every section, so their faders were moved to that offset: Trap Melody 0.27 -> 0.173 (-7.7 dB), Broken Texture 0.22 -> 0.148 (-6.9 dB), Poops 0.34 -> 0.267 (-4.2 dB), Starlight 0.22 -> 0.523 (+15 dB), and the vocal bus (all seven vocal channels and the guitar) up 4.9 dB. Against the REAPER master the 8-bar error is 0.46 dB in the low band (was 0.47) and 0.43 dB full band (unchanged); no section moved by more than 0.01 dB for the worse. Reese is NOT moved: it sits 15 dB under its stem, but raising it pushes bars 17-48, already 0.4-0.7 dB over the master, another 0.26 dB over and makes the song-wide error worse (0.47 -> 0.50 low), so that one is left for a listening decision. docs/elevation-demo-port.md has the tables.",
      "tickets": [
        434
      ]
    },
    {
      "title": "A VST3 plugin that hangs while loading no longer freezes the app",
      "version": "2026.8.319",
      "date": "2026-09-28",
      "area": "Sound engine",
      "type": "Bug fix",
      "userFacing": false,
      "details": "Developer builds with VST hosting only. Every call onto the plugin thread now has a time limit (10 s to load a plugin with its patch, 30 s to scan a bundle). A plugin that does not come back is abandoned together with the thread it is stuck on, a fresh thread takes over, its channels render silence with 'stopped responding while loading' in the compile warnings, a sticky toast says which plugin it was, and it is not loaded again until restart (a scan that times out is cached as unloadable until the file changes). Before, the game thread waited forever. The limit logic is ExternalCallThread in Ezmuze.Audio, tested with a fake host whose load never returns (PluginHangTests). process() still runs on the render thread and cannot be bounded in-process: a plugin stuck in it for 5 s is now detected, logged and reported, but the stuck thread stays stuck (follow-up #465; docs/vst-support-design.md section 14.10).",
      "tickets": [
        447
      ]
    },
    {
      "title": "Quiet filter and feedback tails no longer crawl through slow denormal arithmetic",
      "version": "2026.8.319",
      "date": "2026-09-28",
      "area": "Sound engine",
      "type": "Performance",
      "userFacing": false,
      "details": "The crossover and all-pass biquads, the Buffer's feedback ring, the formant bank and the legacy fx.filter biquad stored their feedback state without a denormal flush, so a tail left to decay spent seconds in subnormal floats (doubles for the legacy filter), which x64 without flush-to-zero and WebAssembly compute many times slower. Each now stores through the same guard the newer recursive modules use (DenormalGuard.Flush, 1e-15, about 300 dB down; exact flush-to-zero for the legacy filter's double state). DenormalFlushTests drives each with an impulse and silence and failed on all five before. All four shipped demos render bit-identically before and after. The Buffer's per-sample modulo on the write index is also a compare now.",
      "tickets": [
        446
      ]
    },
    {
      "title": "Following the playhead no longer re-binds every clip on screen for each pixel it scrolls",
      "version": "2026.8.319",
      "date": "2026-09-27",
      "area": "UI",
      "type": "Performance",
      "userFacing": false,
      "details": "While playing, the sequencer scrolls under the playhead a whole pixel at a time, and every step used to re-bind every visible clip from scratch (waveform, mini piano-roll notes, outline, handles, badges) and re-place every row header, stripe and lane band - none of which a sideways scroll changes. A step that changes nothing but the scroll now only slides the clips that are already showing the same clip at the same row and width; anything else (a clip coming into view, an edit, a selection, a zoom, a waveform arriving) still re-binds in full exactly as before, and the row furniture, which only follows vertical scroll and row heights, is left alone. Measured on The Dark while following, at the default zoom: a scroll step went from about 1.3 ms of UI time (row re-placement 0.78 ms + clip binding 0.44 ms) to about 0.25 ms (0.18 ms of clip binding, and the row pass gone); with the mini piano-roll on, clip binding 0.39 -> 0.10 ms. A follow-scrolled frame is pixel-identical to the same view after a full re-bind.",
      "tickets": [
        448
      ]
    },
    {
      "title": "Less per-frame locking for the render progress strips and the Stack's cached-audio dot",
      "version": "2026.8.319",
      "date": "2026-09-27",
      "area": "UI",
      "type": "Performance",
      "userFacing": false,
      "details": "Every frame the sequencer asked the pattern render queue, under the lock it shares with the render pump, about every clip on screen to draw its green progress strip, and set a new position and size on each strip whether it had changed or not; the Stack's cached-audio dot took the same lock once per clip of its channel and painted a new fill every frame. Now one lock per frame answers \"is anything rendering at all\" (almost always no once a song has settled), a strip is only touched when its width or position changes, the dot asks about its channel's clips under a single lock, and it is only repainted when its colour changes. Measured on The Dark while playing: the progress strips went from about 16 us to 2 us per frame. The per-row voice badges were measured too (about 13 us per frame across 18 rows) and left as they are: their arithmetic only walks the clips under the playhead, and updating them less often would change what they show.",
      "tickets": [
        449
      ]
    },
    {
      "title": "Profiling with telemetry on no longer slows the audio engine",
      "version": "2026.8.318",
      "date": "2026-09-28",
      "area": "Sound engine",
      "type": "Performance",
      "userFacing": false,
      "details": "With telemetry on (the control API forces it on), every audio-engine probe added into one shared table under one lock, about a million times per 20 s of audio, and the UI thread's own probes fought it for that lock: rendering took 33-51% longer. GustUI's Telemetry (GustUI 05b3da1) now keeps each thread's totals apart under its own lock and merges them when a reader asks, so the audio thread never waits on the UI's instrumentation. Measured on the shipped demos with a busy UI thread: elevation 2.76 s -> 1.82 s, thedark 4.37 s -> 3.34 s per 20 s of audio, against 1.94 s / 3.19 s with telemetry off. Reported numbers are unchanged. No change with telemetry off.",
      "tickets": [
        444
      ]
    },
    {
      "title": "Playing a fully bounced song from disk no longer crashes or skips",
      "version": "2026.8.317",
      "date": "2026-09-28",
      "area": "Desktop",
      "type": "Bug fix",
      "userFacing": true,
      "details": "Disk playback (a song already bounced to the cache) could crash the app or skip or repeat audio when the audio callback and the render-ahead thread both reached for the next bar at once: their shared playback position had no lock, and a failed read emptied the bar the other thread was playing. That state is now under one lock, and a read that fails never touches the bar being played. Bars are read straight into reused buffers (WavStereo16Reader) instead of allocating about 1 MB per bar, the next bar is read ahead in the background, and the game thread no longer reads from disk: an underrun is served from bars already in memory (waiting at most 30 ms for the first one after Play) or falls back to live rendering. Also fixed on the way: a disk slice straddling a Play lost the new session's first slice, and a one-bar song re-read its only bar every loop.",
      "tickets": [
        443
      ]
    },
    {
      "title": "Less work and garbage on the audio thread at every bar line",
      "version": "2026.8.316",
      "date": "2026-09-28",
      "area": "Sound engine",
      "type": "Performance",
      "userFacing": false,
      "details": "Pattern cache checksums, the grouped-channel merged-bar cache, the polyphonic note compile and per-voice automation sampling no longer allocate or repeat work each bar on the render thread. Checksums hash exactly the same bytes, so the caches saved in songs stay valid (pinned by PatternCacheChecksumGoldenTests: 200 warm checksum calls went from 11.7 MB allocated to under 1 KB). Merged-bar cache evictions hand their two large arrays to the next store instead of allocating new ones, and a polyphonic graph's voices share one sampling of each automation lane. Output is bit-identical. Measured on thedark / elevation: body lookup 390-510 -> 105-120 us and 300-405 -> 100-157 us per bar; lane sampling on thedark ~280 -> 87 us per bar.",
      "tickets": [
        442
      ]
    },
    {
      "title": "Import folders: imported presets are kept out of everything you publish",
      "version": "2026.8.316",
      "date": "2026-09-27",
      "area": "Modules",
      "type": "New feature",
      "userFacing": true,
      "details": "The owner's rule: \"clearly marked folders which are exempt from uploading\". Presets brought in from outside now live under Imported in the preset browser - a converted Vital bank lands in Imported/<bank name> - listed last, in the secondary tint with an import badge. Nothing in an import folder ships as a module's Factory bank or goes into a preset pack you publish; the publish wizard says what stayed home (\"1 preset ships. 3 in import folders, not shipped.\"). Move a preset out into an ordinary folder to ship it. Installed preset packs are headed \"Pack · <name>\" with the same badge. Presets imported before this are filed into import folders once, but only when the preset itself records that it was converted; nothing is moved on a guess.",
      "tickets": [
        458
      ]
    },
    {
      "title": "Fewer audio dropouts when clips start, samples play or presets change during playback",
      "version": "2026.8.315",
      "date": "2026-09-27",
      "area": "Sound engine",
      "type": "Performance",
      "userFacing": true,
      "details": "The desktop player no longer builds parts of a channel's sound graph on the audio thread in the middle of playback. The effects graph a pattern clip needs when it first plays from its cached audio is now built in the background, and the clip plays live (sounding the same) until it is ready. The generator-only and sample-input graphs are now rebuilt alongside every background recompile after an edit instead of being thrown away, so an edit such as choosing a preset no longer costs the next bar with a clip edge between bar lines, or the next sample bar, a compile of up to ~90 ms on the audio thread. A sample channel's echo and reverb also ring on through such an edit instead of cutting off. Offline renders and exports are unchanged.",
      "tickets": [
        441
      ]
    },
    {
      "title": "Republishing a store module keeps its voice count",
      "version": "2026.8.315",
      "date": "2026-09-27",
      "area": "Modules",
      "type": "Bug fix",
      "userFacing": true,
      "details": "A module you only have as its store install carries no voice count of its own, so with nothing on a channel the publish wizard assumed one voice - and the store called the new version breaking (Keyzoo 20/20 from 4 voices, PXL4 from 8). It now takes the count from the installed package's latest version before guessing.",
      "tickets": [
        459
      ]
    },
    {
      "title": "The publish wizard says which presets are new to the Factory bank",
      "version": "2026.8.315",
      "date": "2026-09-27",
      "area": "Modules",
      "type": "Improvement",
      "userFacing": true,
      "details": "Every preset in your library for a module ships as its Factory bank, and a library collects imported banks and experiments: one module was about to ship 552 presets against the 82 of its last version. The checks now compare the bank with the previous version's - how many carry over, how many are new and from which category, how many are dropped - so nothing ships to buyers without being said."
    },
    {
      "title": "Control API: module_presets lists the presets a published module version ships",
      "version": "2026.8.315",
      "date": "2026-09-27",
      "area": "Modules",
      "type": "Internal",
      "userFacing": false,
      "details": "An installed store package is decrypted to memory only, so nothing outside the app could say which presets a published version shipped. module_presets {flc, version, full} lists them, optionally as the preset library stores each one. Used to bring the owner's Bifrost presets back to exactly the v4 factory set (552 local presets, 82 in v4) before Bifrost is republished."
    },
    {
      "title": "Module descriptions can be as long as the store allows: 4000 characters, not 400",
      "version": "2026.8.315",
      "date": "2026-09-27",
      "area": "Modules",
      "type": "Bug fix",
      "userFacing": true,
      "details": "The publish wizard refused any description over 400 characters while the store takes 4000. A listing longer than that - HALL 9000's is 1154 characters, written through the listing editor - could never have a new version published, because the wizard sends the listing with every version and failed its own check. The wizard and module properties now use the store's limit."
    },
    {
      "title": "author_face opens an installed store module itself, and matches names whatever their capitals",
      "version": "2026.8.315",
      "date": "2026-09-27",
      "area": "Modules",
      "type": "Internal",
      "userFacing": false,
      "details": "Found running author_face on the owner's machine: an installed store module lends its definition only once something has opened it this session, so twelve of the seventeen modules answered \"neither installed nor in your library\". author_face now opens the package first. And the drum kits' store packages capitalise their pad names (\"Closed Hat\") where the face says \"Closed hat\", so face names now match case-insensitively; ambiguity is still a miss."
    },
    {
      "title": "No more dropouts the first time a wavetable or the grand piano plays",
      "version": "2026.8.314",
      "date": "2026-09-27",
      "area": "Sound engine",
      "type": "Bug fix",
      "userFacing": true,
      "details": "Several instruments built heavy data inside the audio engine's render, where each step has about a millisecond and a half. The first note of the built-in grand piano decoded the whole 63 MB piano (about five seconds, freezing the app); a wavetable synth's first use of a factory table built it (40-180 ms), and one reading a sample from the wave bank extracted its table (40-290 ms); committing a new designed wavetable blocked every other designed oscillator while it was generated; and asking whether the piano was installed decoded it too, on the UI thread. Now a channel loads its tables and fonts when its graph is built, the piano starts loading in the background as soon as a song that uses it opens, and during playback anything not ready yet is loaded on a background thread while the instrument plays what it already has: the previous or built-in table for an oscillator, silence for a sampler until its font arrives. A designed wavetable is generated without holding up anything else, and the loaded-font cache now drops one font at a time instead of emptying itself when full. Bounces, exports and the web version load in line as before, so their output is unchanged.",
      "tickets": [
        440
      ]
    },
    {
      "title": "Moving a pad's sound-design controls while playing no longer causes dropouts",
      "version": "2026.8.313",
      "date": "2026-09-27",
      "area": "Sound engine",
      "type": "Bug fix",
      "userFacing": true,
      "details": "A pad (gen.pad) plays a table that takes tens of milliseconds to build, and it was being built inside the audio engine's render, where each step has about a millisecond and a half. Automating or modulating a pad's design controls (bandwidth, tilt, odd/even, formant, seed) rebuilt the table on nearly every step: 290 ms of audio took over a second to render, which is a dropout, and even a pad's first note spent 18 ms of one step building its table. Now the first table is built when the channel's graph is built (as the pad's design always intended), and during playback a new table is built on a background thread while the pad keeps playing the one it has, so a sweep changes a few tens of milliseconds behind the control instead of stalling the audio. Bounces, exports, previews and the web version still build tables in line, so their output is unchanged.",
      "tickets": [
        439
      ]
    },
    {
      "title": "Every store instrument and effect has a Stack card of the controls you actually use",
      "version": "2026.8.312",
      "date": "2026-09-27",
      "area": "Modules",
      "type": "Improvement",
      "userFacing": true,
      "details": "Korben was the only module whose Stack card showed a chosen set of controls; every other store module showed its whole graph - 197 knobs on Bifrost, 208 on PXL4. Each one now marks the controls a producer reaches for while making music (filter, the envelopes you ride, oscillator character, macros, section levels, effect sends) and leaves set-and-forget and deep-editing controls to the panel and the card's Advanced view. No fixed count: a 303 has ten, a wavetable synth twenty-five. The faces live in StoreModuleFaces (docs/module-store-design.md, Stack card faces); the control API's new author_face puts one on an author's installed store module for its next version, and songs that already use a module pick its new card up with that version.",
      "tickets": [
        456
      ]
    },
    {
      "title": "Meters, readouts and edits no longer stall while playback catches up",
      "version": "2026.8.311",
      "date": "2026-09-27",
      "area": "Sound engine",
      "type": "Performance",
      "userFacing": true,
      "details": "The audio engine holds a lock while it renders, and the meters, the live control readouts and (since #437) every edit made while playing all need the same lock on the app's main thread, which also feeds the sound card. While playback was catching up (after pressing Play or jumping to a new position) the engine rendered back to back, and because the lock is not fair it kept winning it back: a meter read measured about 100 ms at the median and up to a second on the shipped demos, which is a stutter in the UI and a gap in feeding the audio. The engine now steps aside for anything already waiting before it renders again (NaiveSongRenderer.EnterGate / HandOffGate), and the desktop render-ahead pump renders in 256-frame slices instead of 2048, so each hold is shorter. Measured on the same demos: median wait about 0.6 ms, 99th percentile under 10 ms. The meters' peak history grew to match, so they still catch every transient. Rare longer stalls at a bar start (graph compiles, first-use loads) are separate tickets (#440, #441).",
      "tickets": [
        438
      ]
    },
    {
      "title": "Editing while the song plays no longer crashes the app",
      "version": "2026.8.310",
      "date": "2026-09-27",
      "area": "Sound engine",
      "type": "Bug fix",
      "userFacing": true,
      "details": "The audio engine reads the song on its own thread while it plays, including every pattern's note list at the start of each bar. Edits were applied with no lock, so an edit that landed at the wrong moment (adding a note in the piano roll, say, or an undo) changed a list the engine was halfway through reading. The engine thread then failed with 'Collection was modified', and because nothing on that thread caught it, the whole app closed with your unsaved work. Every edit, undo and redo now waits for the engine to finish the chunk it is rendering (EditHistory.ApplyGuard, set to NaiveSongRenderer.RunExclusive), so the two can no longer overlap. As a second line of defence, a render that fails on the desktop render-ahead thread is now logged and skipped instead of taking the app down. A stress test that edits as fast as it can while the song plays (RenderEditRaceTests) failed at the first bar before this fix and passes now.",
      "tickets": [
        437
      ]
    },
    {
      "title": "Tests: two fixes for the xunit 2.9 upgrade",
      "version": "2026.8.310",
      "date": "2026-09-27",
      "area": "Build",
      "type": "Internal",
      "userFacing": false,
      "details": "xunit 2.9's TheoryData<T> enumerates as more than one IEnumerable, so ShippedDemoParallelParityTests' Demos().Select(...) no longer compiled; it now casts to IEnumerable<object[]> explicitly. GameThreadSyncContextTests.Post_FromAnotherThread_RunsOnThePumpingThread blocked on a Task.Run to post from 'another' thread, and under 2.9 the runtime inlines that task onto the waiting test thread, so the post came from the pump thread itself and the test failed every run. It now posts from a dedicated Thread it starts and joins, which is always a different thread. Full suite: 5043/5043."
    },
    {
      "title": "Secondary text is readable in the light theme",
      "version": "2026.8.310",
      "date": "2026-09-27",
      "area": "UI",
      "type": "Improvement",
      "userFacing": true,
      "details": "Captions, hints, status lines, list details and the labels on unlit toggles used one fixed grey, (150,150,165), chosen for the dark theme; on the light theme it was a washed-out grey on white. They now use a new theme colour, GustUI Theme.SecondaryText, derived from each theme's panel and body-text colours (lerp 0.62, the rule the welcome screen already used), so dim text has proper contrast in light and looks nearly the same in dark. The grey is now defined in one place instead of 47. design-guide.md has the token and a 2026-09-27 log entry."
    },
    {
      "title": "Smoother Stack panel and module editor on busy chains",
      "version": "2026.8.310",
      "date": "2026-09-27",
      "area": "UI",
      "type": "Performance",
      "userFacing": false,
      "details": "The Stack panel re-laid out its cards and rebuilt its wire overlay every frame, and the module editor rebuilt every wire (and its straight-routing inputs) every frame, even when nothing had moved. Both still recompute their inputs each frame but compare them against what is already on screen, and only allocate and rebuild when something actually changed (a card, the scroll position, a port dot's position, the wire set, selection, zoom/pan, routing mode, theme). Comparing against the output rather than keeping dirty flags means a missed invalidation cannot leave anything stale. The Stack's per-device CPU readout skips re-formatting when the reading hasn't changed (it only moves once per second); the text it shows is unchanged."
    },
    {
      "title": "Dependency cleanup: drop a stale ASP.NET package, use .NET 10's own JSON, update test tooling",
      "version": "2026.8.310",
      "date": "2026-09-27",
      "area": "Build",
      "type": "Internal",
      "userFacing": false,
      "details": "Removed Microsoft.AspNetCore.StaticFiles 2.2.0 (2018) from the web project; its only use configured StaticFileOptions in the WASM client, where nothing reads it. Removed explicit System.Text.Json 9.0.0 references from the net10 app projects in favour of the framework's copy. Test packages updated: xunit 2.9.3, xunit.runner.visualstudio 2.8.2, Microsoft.NET.Test.Sdk 17.14.1, coverlet.collector 6.0.4."
    },
    {
      "title": "Removed dead sequencer code and logged previously silent file errors",
      "version": "2026.8.310",
      "date": "2026-09-27",
      "area": "Core",
      "type": "Internal",
      "userFacing": false,
      "details": "Removed unused SequencerView members (EnsureClipAudibleAsync, LaneClipboardHasFocus, BeginSoundFontPick and its soundFontPickPending poll). Bare catches around file, drag, pack-art, theme-query and recovery-file IO in ExternalFileDrag, InstalledContentPacks, PackArt, UserPreferences and AutosaveRecovery now write a Console line, which GET /log reads. Catches that run every frame or during teardown were left silent."
    },
    {
      "title": "Posting or deleting a review on a slow connection no longer crashes the app",
      "version": "2026.8.310",
      "date": "2026-09-27",
      "area": "UI",
      "type": "Bug fix",
      "userFacing": true,
      "details": "A timeout while posting or deleting a module review escaped as an unhandled exception from an async void handler. Both now catch any failure, keep the form open with your words, and show the reason in the dialog so you can try again."
    },
    {
      "title": "Saving a project can no longer leave it half-written, and keeps the previous version as a .bak",
      "version": "2026.8.310",
      "date": "2026-09-27",
      "area": "Desktop",
      "type": "Bug fix",
      "userFacing": true,
      "details": "Saves (Ctrl+S, the file browser, and Save as) overwrote the song in place, which empties the file first, so a crash, a full disk or a sync client mid-save could leave your only copy empty -- and a project carries its own samples. Songs are now written to a temporary file beside the target, flushed to disk and renamed into place, so the old version stays whole until the new one is complete, and the version it replaces is kept as <name>.ezproj.bak (best effort: a .bak that cannot be written never fails the save). Exported audio, modules and content packs get the same safe write without a .bak. Shared helper: Ezmuze.Services/AtomicFile, unit-tested in AtomicFileTests."
    },
    {
      "title": "Saved sign-ins and preferences are written safely",
      "version": "2026.8.310",
      "date": "2026-09-27",
      "area": "Desktop",
      "type": "Bug fix",
      "userFacing": false,
      "details": "On Linux and macOS, where service tokens are stored as plain text, a secret file was written first and chmod-ed to 600 afterwards, leaving it readable by other users in between. It is now created 0600 from the first byte, as a temp file in the same folder renamed over the real one, so a crash mid-write also keeps the old secret instead of a truncated one. DesktopStore (preferences and per-key library files) now writes the same way, temp file then rename, so an interrupted write can no longer leave a half-written file."
    },
    {
      "title": "Web: files fetched through the service proxy can no longer run as the studio page",
      "version": "2026.8.310",
      "date": "2026-09-27",
      "area": "Web",
      "type": "Bug fix",
      "userFacing": false,
      "details": "Every /svc/ proxy response now carries X-Content-Type-Options: nosniff and Content-Security-Policy: sandbox; default-src 'none'. HTML, XML/SVG and script content types (and missing ones) are served as application/octet-stream, and upstream Set-Cookie, HSTS, Clear-Site-Data and Service-Worker-Allowed headers are stripped. Before this, a file anyone uploaded to archive.org could be opened on the ezmuze.studio origin as a live page with access to sign-in tokens in localStorage. Request headers are now allowlisted too, so the browser's cookies, Origin and Referer never reach third-party services; Authorization and authToken are dropped when a redirect leaves the host they were sent to (hops between ezmuze central's own hosts excepted). Request bodies are read once, capped at 64 MB (413 above that) and re-sent correctly on 307/308 redirects; 303 and POST-301/302 redirects become GETs. Full-size 32 MB cloud-save chunks, which went over Kestrel's default body limit, now fit. The app page itself still has no CSP: index.html's inline scripts need hashes first."
    },
    {
      "title": "Links in the app only open web addresses",
      "version": "2026.8.310",
      "date": "2026-09-27",
      "area": "UI",
      "type": "Bug fix",
      "userFacing": true,
      "details": "Every in-app link and every service sign-in handed its URL straight to the OS shell (or window.open on the web), including URLs that came from outside: a song file's licence credits, share and sign-in answers from servers. A crafted song could make a click launch a program, open a local or network file (leaking a Windows login hash over SMB), or run a javascript: URL in the browser build. ServicesTab.OpenUrl and ServiceEnvironment.OpenBrowser now open only absolute http/https URLs; anything else shows a toast (or is logged, for sign-in flows)."
    },
    {
      "title": "Remote control API no longer accepts requests from web pages",
      "version": "2026.8.310",
      "date": "2026-09-27",
      "area": "Desktop",
      "type": "Bug fix",
      "userFacing": false,
      "details": "The loopback control server (--control-port / Preferences > Advanced) had no auth, so any web page open in a browser on the same machine could drive the app with a form POST or a plain GET such as <img src=\".../action?name=export_module&path=...\">. Every request now passes a filter: Host must be 127.0.0.1, localhost or [::1] on the control port (defeats DNS rebinding); any Origin header, or a Sec-Fetch-Site other than none, is refused with a 403; a POST body must be Content-Type: application/json (415 otherwise), which forces a CORS preflight nothing answers; and GET only reaches endpoints whose GET only reads (/stats, /tree, /screenshot, /log, ...), while /action, /click, /key and the other commands are POST-only. MCP clients and Lua are unaffected. curl callers must use --json (or -H \"Content-Type: application/json\") instead of plain -d; docs/remote-control-api.md and its examples are updated."
    },
    {
      "title": "Pull requests now run the test suite before they reach main",
      "version": "2026.8.310",
      "date": "2026-09-27",
      "area": "Build",
      "type": "Internal",
      "userFacing": false,
      "details": "build.yml only runs on push to main, so a pull request's first test run was the beta build after the merge. New .github/workflows/pr.yml runs build.yml's test job (tools/release/test.sh plus the web worker probes) on every same-repo pull request, in its own per-PR concurrency group so it never cancels a beta build. Fork PRs are skipped on purpose: the job runs on the self-hosted VPS runner, which holds the release environment and the KNI deploy key. If a PR changes the build Dockerfile, the content-addressed image is built for it first. Heads are not built, so the Windows-only projects are still only compiled on main."
    },
    {
      "title": "A group channel's row shows its children's clips where they are, with their waveforms",
      "version": "2026.8.309",
      "date": "2026-09-27",
      "area": "Sequencer",
      "type": "Bug fix",
      "userFacing": true,
      "details": "A group parent's row (a REAPER folder after import, or any channel with children) drew its children as a run of one-bar blocks whatever they were: each block squeezed a child clip's whole waveform into one bar, and wherever two children played at once it showed no waveform at all, so a folder of stems looked like a row of empty boxes that matched nothing below it. The row now shows each stretch the children play in as one block at their real start and end (sections that only touch stay separate), with every child clip's waveform (or notes) drawn at its own position on top, so several children overlay into the group's combined shape. Where the parent has clips of its own it still takes precedence. Clicking the row still selects the child underneath. docs/channel-hierarchy-design.md §7 and design-guide.md have the rule.",
      "tickets": [
        436
      ]
    },
    {
      "title": "Right-clicking a clip gives its menu again while an area is selected somewhere else",
      "version": "2026.8.308",
      "date": "2026-09-27",
      "area": "Sequencer",
      "type": "Bug fix",
      "userFacing": true,
      "details": "With an area selected on the timeline (a right-drag), right-clicking a clip outside that area opened no menu at all: the clip menu was only offered when the area was no wider than the clicked clip, so an area anywhere else silenced every clip. Now the thing under the pointer decides. Inside the area (its bars and its channels) is the area's Cut/Copy/Paste menu; a clip outside it gives that clip's own menu, and the area stays selected; a clip partly inside gives the area menu where the pointer is inside the area and the clip menu where it is not; empty space outside the area still gives nothing. Whether the pointer is inside is now judged where it actually is, rather than rounded to the nearest bar line, which had put the last half of the bar before the area inside it and the last half of its final bar outside. Left-clicks and drags on clips were never affected. design-guide.md has the rule.",
      "tickets": [
        435
      ]
    },
    {
      "title": "Elevation's master no longer lifts its quiet passages about 4 dB more than the original does",
      "version": "2026.8.307",
      "date": "2026-09-27",
      "area": "Sound engine",
      "type": "Bug fix",
      "userFacing": true,
      "details": "The Elevation demo's master chain had a glue compressor (-24 dBFS, 2.5:1, after a 2.2x lift) that compressed at every level the song plays at: about +7.7 dB of gain in the quiet sections and +3.7 dB in the loudest drop, where the REAPER session's mastering chain is linear (+10.4 dB over the sum of its tracks everywhere, except about 2 dB off in bars 97-112). The glue is now parked at 1:1 and the limiter's makeup lowered from 1.4 to 0.72, so the chain is linear up to the limiter, which takes about 2 dB off the loud drops as REAPER's does. Sub Reese, whose fader had been lowered to make up for the glue (#430), moves back toward its stem fit: fader 0.341 -> 0.40 and release curve -0.65 -> -0.8. No other channel was compensating. Against the REAPER master, the average 8-bar error went from 1.54 to 0.47 dB in the low band and from 1.48 to 0.43 dB in the full band, no block got worse by more than 0.2 dB, and the overall level is within 0.2 dB of the master (it was 0.8 dB hot). Set in the studio with the control API and saved back. docs/elevation-demo-port.md has the per-block table.",
      "tickets": [
        432
      ]
    },
    {
      "title": "Imported wavetables are stored compressed, about a seventh of the size, playing the same audio",
      "version": "2026.8.307",
      "date": "2026-09-27",
      "area": "Services",
      "type": "Improvement",
      "userFacing": true,
      "details": "An oscillator's own wavetable, carried over from a Vital, Serum or Phase Plant patch (a dropped Vital bank or preset, or a REAPER import), is now stored in a lossless float form of the app's own instead of raw 32-bit frames, and decoded back to the identical frames once, when the synth is built, so it plays exactly as before. WavPack was not used: the decoder here only reads its float mode by rounding, so it is a small codec of its own (the float bits as ordered integers, the cheapest of seven sample and frame predictors per frame, deflate), integer-only so every platform, the web included, decodes the same bits. Every local REAPER project, converted both ways: 1,335 tables, 118.6 MB to 16.7 MB (14%; Vital 7.6%, Phase Plant 33%, Serum 36%), every one decoding to the identical bytes and every converted instrument rendering the same samples; the 131 songs carrying tables went from 411 M to 276 M characters (97 MB gzipped, from 139). Songs and presets saved with raw frames still load and play unchanged. Converting a patch for its automation lanes skips the encode. docs/reaper-import.md 4.",
      "tickets": [
        433
      ]
    },
    {
      "title": "Imported Vital samples are stored compressed too, a third of the size, playing the same audio",
      "version": "2026.8.306",
      "date": "2026-09-27",
      "area": "Services",
      "type": "Improvement",
      "userFacing": true,
      "details": "A Vital patch with a sample, converted to Bifrost (a dropped Vital bank or preset, or a REAPER project's Vital track), now stores that sample as lossless WavPack, the way Phase Plant samplers have been since #429: the Sample Player is handed back the very WAV it was made from, byte for byte, so it plays exactly as before. Local REAPER projects: the four Vital samples went from 3.0 MB to 1.2 MB (39%), every one decoding to the identical WAV, and the larger song 15.9 MB to 13.6 MB (3.7 MB gzipped, from 5.1). Songs and presets saved with a WAV still load and play it unchanged. Converting a patch for its automation lanes skips the encode, since those copies are thrown away. The Lua component editor's Validate now hands a script the file as the graph would. Nothing else embeds a WAV: Serum's noise sample is only named, and imported wavetables are float frames, not WAV. docs/reaper-import.md 4.",
      "tickets": [
        431
      ]
    },
    {
      "title": "Elevation's sub bass now fades out after the drums stop the way the original does, instead of swelling up",
      "version": "2026.8.306",
      "date": "2026-09-27",
      "area": "Sound engine",
      "type": "Bug fix",
      "userFacing": true,
      "details": "In the Elevation demo, the Sub Reese bass got louder in the break at bar 79 and at the very end, where it should fade out. The ducking compressor used to squash this channel whenever the drums were silent. That hid two mistakes in how the channel was copied from the original REAPER session: its fader was about 8 dB too high, and its release shape was wrong. The original Phase Plant patch has a 10-second release with a curved falloff: it holds for about two bars and then drops away. Ours was an 8-second release on the default curve, which starts falling straight away. Sub Reese's fader is now 0.341 (it was 0.54), and its amp envelope curve is -0.65, set in the studio and saved back to the demo file. Compared with the REAPER master below 120 Hz, the break at bar 79 is now 3.4 dB over (it was 6.0 dB over). Across bars 143-147 the ending is now 0.1-3.2 dB over. It used to swing from 4.2 dB over to 21 dB under. The low band over the whole song is closer to the master than it was (average 8-bar error 1.54 dB, down from 1.81 dB), and no other channel changed.",
      "tickets": [
        430
      ]
    },
    {
      "title": "Control API: set_param and channel_volume set a module param or a channel fader by name",
      "version": "2026.8.306",
      "date": "2026-09-27",
      "area": "Desktop",
      "type": "Internal",
      "userFacing": false,
      "details": "Two new remote-control actions. set_param finds a component by channel, device and component name, and sets one param on it; the param defaults to value, which every dial uses. channel_volume sets a channel's MixerVolume. Each one makes the same undoable edit a knob drag makes, so a calibrated value can be set exactly in a running studio and saved back into a demo file. That includes params the instrument's panel does not show, such as an envelope's curve. If a component name is on more than one device, the action returns an error that lists the devices instead of picking one. The name lookup is ParamTargets in Ezmuze.Core, which has unit tests."
    },
    {
      "title": "Imported Phase Plant voices are as loud as the plugin when they are at full level",
      "version": "2026.8.305",
      "date": "2026-09-27",
      "area": "Services",
      "type": "Bug fix",
      "userFacing": true,
      "details": "A REAPER import turning a loud Phase Plant voice into Bifrost clamped Bifrost's Output Level at its top, so a voice going out at full scale came in about 3.7 dB quiet. The level past that now goes on a Gain device straight after the Bifrost (up to +12 dB). The calibration tool that said the unity constant should be 2.43 was measuring that clamp: re-measured over ten patches, 1.5575 is right (every patch within 0.22 dB), and the tool now works the constant out from the gain Bifrost was actually given. docs/reaper-import.md 9b.",
      "tickets": [
        428
      ]
    },
    {
      "title": "Hosted plugins are made on a thread of their own, so a Phase Plant sampler never renders silent",
      "version": "2026.8.305",
      "date": "2026-09-27",
      "area": "Sound engine",
      "type": "Bug fix",
      "userFacing": false,
      "details": "Developer builds with the VST host: every plugin is now made, scanned and destroyed on one host thread that runs its Windows message queue, and a new instance made for an offline render (pattern faces, tails, capture, Convert to sample clip) is given 300 ms to finish loading. Phase Plant hands a sampler's sample over through that queue; a face rendered off the game thread played it as silence. Measured with tools/KiloheartsProbe pp-thread and in a DX build (pp-song). docs/vst-support-design.md 14.9.",
      "tickets": [
        427
      ]
    },
    {
      "title": "Imported Phase Plant samples are stored compressed, about half the size, playing the same audio",
      "version": "2026.8.305",
      "date": "2026-09-27",
      "area": "Services",
      "type": "Improvement",
      "userFacing": true,
      "details": "A REAPER import's Phase Plant sampler is stored as lossless WavPack on Bifrost's Sample Player instead of WAV: 56% of the size across the local corpus (23% to 88% each), a synthetic 12 s reese song went from 3.07 M to 0.85 M characters. The graph decodes the file once when the component compiles and hands the player the very WAV it came from, so the Sample Player's script, and its native twin that matches that exact text, are unchanged and every existing song plays as before. Any Lua component that reads WAV gets the same. Also fixes the WavPack decoder writing a large request at a non-zero offset from the buffer's start. docs/lua-components.md, docs/reaper-import.md 9c.",
      "tickets": [
        429
      ]
    },
    {
      "title": "A compressor keyed from another channel no longer squashes its own track while that channel is silent",
      "version": "2026.8.304",
      "date": "2026-09-27",
      "area": "Sound engine",
      "type": "Bug fix",
      "userFacing": true,
      "details": "A compressor set to use its channel's key source (a sidechain duck keyed from the drums, say) fell back to compressing its own input whenever the key channel had nothing playing, so the part it was meant to duck was squashed hardest exactly when the drums were out. A key channel that is not playing now counts as a silent key: no gain reduction, as a sidechain compressor keyed from silence behaves in any DAW. A compressor with no key channel named, or with its key wired inside the device, is unchanged. In the demos only Elevation moves: Sub Reese's long release after the drums stop at bars 79 and 144 is no longer pulled down about 10 dB.",
      "tickets": [
        426
      ]
    },
    {
      "title": "Notes at the very end of a control tick, a bar or a pattern now start exactly where they are written",
      "version": "2026.8.304",
      "date": "2026-09-27",
      "area": "Sound engine",
      "type": "Bug fix",
      "userFacing": true,
      "details": "Three timing slips at the edge of the engine's 64-sample control ticks, fixed in the engine. A note starting in the last sample of a tick started one sample early on a sampler (Capacitor, the wave-bank player), with the sample read from before its start; it now starts on the next tick's first sample, as a note anywhere else between two samples does. A note in a bar's short last tick (a bar is rarely a whole number of ticks) was placed as if the tick were full length, up to a third late, and one near the bar's very end could be lost; it is now placed on its own sample. And a note starting in the last tick of a pattern (its last 64 samples) never played at all, looping or not; it now does. Send cues to drum pads no longer has to nudge a hit that lands on a tick edge, so every pad's first hit now reads its wave from the top (20 off-grid cues: -66 dB against the clip, from -59). Of the demos only The Dark moves, and inaudibly: 21 Plink notes just before a bar line start 11 samples earlier, on their own frame.",
      "tickets": [
        421
      ]
    },
    {
      "title": "Filter, chorus and phaser tails ring out after a channel's audio stops instead of cutting off at the next bar line",
      "version": "2026.8.304",
      "date": "2026-09-27",
      "area": "Sound engine",
      "type": "Bug fix",
      "userFacing": true,
      "details": "Checked because a sample clip's effect tail seemed shorter than the same effects on a drum-kit channel. The sample clip was right: the kit's extra ring was its pattern restarting its first pad on the way out, which the fix for hosted plugins' tails in this same release already corrected, and the two now match. But the check found a real cut: filters (the multimode filter, the ladder and diode ladder, comb, formant, EQ, all-pass and crossover), chorus, flanger, phaser, ensemble and the limiter's lookahead told the engine they had no tail, so when a channel's audio stopped (a sample clip or a note ending) a resonant filter still ringing was cut dead at the next bar line, on every kind of channel. Effects after the instrument are now judged by what they put out and keep the channel running until they fall silent, as echoes and reverbs always did; nothing changes inside an instrument. In the demos only Elevation moves: its drum bus filter and Reverse Heaven's two filters ring out past the bar lines their audio ends on, and a drum filter tail that used to resume as a click when the drums came back at bar 96 is gone.",
      "tickets": [
        422
      ]
    },
    {
      "title": "REAPER import plays Phase Plant samplers on Bifrost's Sample Player",
      "version": "2026.8.303",
      "date": "2026-09-27",
      "area": "Services",
      "type": "New feature",
      "userFacing": true,
      "details": "A Phase Plant sampler in a REAPER project now comes across: its sample plays on the Bifrost it becomes, on Bifrost's Sample Player, at its root key, from its start point and with its loop (infinite, sustain, ping-pong or reverse, and the crossfade), where a sampler used to be named as left out and a voice of samplers only got the closest installed instrument. A sample the user put into the patch is carried inside the saved state and is read from there. A sample from Phase Plant's own library is only named by the project; a copy of that file beside the project (or picked with it on the web) is brought in, and the import never reads the plugin's own install, so a song never carries the vendor's content. Without it the voice still gets the closest installed instrument, and the report names the file. What each setting means was measured through the plugin: the root is the note that plays the sample at its own rate, the start and loop are fractions of the sample, the crossfade fades in what comes before the loop's start, and a sampler is as loud as an oscillator at the same level. The Sample Player has no loop points of its own, so the loop is written out into the file it plays, 12 seconds of it, and a note held longer starts again (said in the report unless the loop starts where the sample does, which wraps exactly). One sampler per voice; a second is named. Checked against the plugin on the 12 corpus voices that have one (median distance 0.417). docs/reaper-import.md §9c.",
      "tickets": [
        423
      ]
    },
    {
      "title": "REAPER import brings in FLAC, AIFF and MP3 audio items",
      "version": "2026.8.303",
      "date": "2026-09-27",
      "area": "Services",
      "type": "New feature",
      "userFacing": true,
      "details": "Audio items in FLAC and AIFF now import like WAV ones, and so do MP3, Ogg Vorbis and WavPack items, which REAPER names by their own source type and which were reported as not imported although the app could decode MP3 and Ogg all along (230 MP3 items in the local test projects). FLAC is decoded by the decoder written for Phase Plant's wavetables (any bit depth, stereo, a tag in front), AIFF and AIFF-C by a new reader (big- and little-endian, integer and float); both are the app's own code, so the web build imports them too, and a FLAC or AIFF dropped on a channel or found by a sample service decodes as well. Checked against a reference decoder on real 16 and 24-bit stereo FLAC at 44.1, 48 and 96 kHz and on AIFF at every width: within one step of 16-bit. M4A/AAC, MP4 and Opus items are still named as not imported: an AAC decoder would be a large dependency. docs/reaper-import.md §1, docs/services-design.md §10.",
      "tickets": [
        425
      ]
    },
    {
      "title": "Hosted plugins are freed when they are done with, and plugin tools stop growing to tens of gigabytes",
      "version": "2026.8.303",
      "date": "2026-09-27",
      "area": "Services",
      "type": "Internal",
      "userFacing": false,
      "details": "The VST3 host never actually destroyed a plugin: every pointer a plugin hands back arrives with a reference already counted and the host kept that one for ever, and the component and controller were connected to each other and never disconnected, so each held the other. A verify run over 80 Phase Plant patches grew to 37-45 GB in batches and 74 GB in one process. Disposal now releases both, a graph's instances can be given back at once (Vst3ModuleHost.Release, called by the tools' renderer after each render) instead of when the garbage collector gets to the graph, and a tool caps how many idle instances it keeps (MaxIdleInstances; the app keeps its old limit). The same run now peaks at 5.0 GB in one process. The tools also run their thread's message queue after making a plugin: Phase Plant finishes loading its samples through that queue, a console thread never ran it, and twelve of those 80 patches had been rendering silence on the plugin side because of it. docs/vst-support-design.md §14.8.",
      "tickets": [
        424
      ]
    },
    {
      "title": "REAPER import rebuilds Phase Plant patches as Bifrost, and reads older Multipass and Phase Plant saves",
      "version": "2026.8.302",
      "date": "2026-09-27",
      "area": "Services",
      "type": "New feature",
      "userFacing": true,
      "details": "Two follow-ups to reading Kilohearts plugins from REAPER projects. Older Multipass and Phase Plant saves (Multipass up to 1058, Phase Plant up to 1042: 180 and 310 instances in the local test projects) keep their lanes and snapins in a binary layout of their own, so a legacy Multipass came in as a flat Korben and a legacy Phase Plant brought no effects. That layout is now read: the plugins read the old format and save the new one, so every old save was loaded into the plugin and saved again, and the fields found by comparing the two and checked back against all of them (371 states, no mismatch). A legacy Multipass now becomes Korben with its bands, splits and each band's effects; a legacy Phase Plant brings its lanes' effects; and more snapins inside them keep their settings (gain, stereo, ladder and nonlinear filters, phaser, flanger, 3-band EQ, ensemble, Faturator, the filter's slope). And a Phase Plant's VOICE is now rebuilt as Bifrost, from either format, where it used to get the closest installed instrument: up to three analog and wavetable generators (an analog shape played exactly, a wavetable the patch carries played frame for frame, a library wavetable stood in for by name), noise, the filter each generator passes through, a second filter, distortion as Drive, the amp envelope, two envelopes and four LFOs with their drawn shapes, velocity and key tracking, macros 1-4, mono and glide, unison, the voice's transpose, and the modulation routings, each through the knob law measured on the plugin. How a Phase Plant voice connects (groups, outputs that pass their signal on) was measured through the plugin rather than assumed. The report says what came across and names what did not. A voice of samplers only still gets the closest installed instrument. Automation of Phase Plant's macros and mod wheel now lands on the converted Bifrost. Checked against the plugin on 64 corpus patches with the matcher's measures (median distance 0.745; level within a few dB). docs/reaper-import.md §9.1, §9a, §9b.",
      "tickets": [
        420
      ]
    },
    {
      "title": "Send a sample clip's cues to drum pads",
      "version": "2026.8.301",
      "date": "2026-09-27",
      "area": "Sequencer",
      "type": "New feature",
      "userFacing": true,
      "details": "A new command, Send cues to drum pads, in the Clip editor's Edit menu, on a cue's menu and on a sample clip's right-click menu, turns the clip into a playable kit: the audio between each pair of cues becomes a pad of a new Capacitor drum machine on a channel right below the clip's, and the clip becomes a pattern that hits those pads where the clip played them, so it sounds the same (measured: sample for sample on a loop at the song's tempo, and within -55 dB on real material) while every hit can now be moved, swapped or played harder or softer. The cuts are the ones Slice at cues makes. A part of the sample that comes round again (the loop's next pass) uses the same pad, and a pad's wave is named after its cue when the cue has a name. A kit has sixteen pads, so a clip with more distinct slices keeps the rest playing from trimmed copies of the clip. The kit's channel takes the old channel's effects, mixer settings and the automation on them, and the pads hold the clip's sound as it played (stretched, pitched, faded), stored in the song. The clip is replaced; one Ctrl+Z brings it back. The control API has cues_to_pads.",
      "tickets": [
        419
      ]
    },
    {
      "title": "A sample clip's play-once intro is drawn on the timeline",
      "version": "2026.8.301",
      "date": "2026-09-27",
      "area": "UI",
      "type": "Improvement",
      "userFacing": true,
      "details": "A sample clip that plays an intro once before its loop now shows it on its face in the sequencer: the intro's waveform first, under the Clip editor's light 'heard once' veil, then the loop repeating from where the intro ends, with the clip's outline pinched at the join. A clip trimmed or slipped into its intro shows the rest of it; one slipped past it shows the loop from where it is. Before, the face tiled the loop from the clip's start, so it did not match what played.",
      "tickets": [
        418
      ]
    },
    {
      "title": "A bar shared by two pattern clips no longer re-attacks or costs a first render, and each clip keeps its own stretch",
      "version": "2026.8.300",
      "date": "2026-09-27",
      "area": "Sound engine",
      "type": "Bug fix",
      "userFacing": true,
      "details": "Follow-ups to pattern clips playing where they are drawn. A bar two pattern clips share has to play live, and it used to do so on the channel's full instrument, cold, unless the bar before happened to warm it up: a held note re-attacked at the bar line, and on a channel whose other bars come from the pattern cache the first such bar paid the instrument's first render on the audio thread. A shared bar now plays on the same warmed generator-only path as a clip's sub-bar end, into the same effects the cached bars feed, warmed by the bar before whenever a clip runs into it; the crossfade out of the cache stops where the next clip takes over, and the clip handed on to the next bar is the one still playing at the bar's end, not the one that started last. Serving part of a shared bar from the cache was considered and rejected (a second mid-bar seam for no audible gain). In a bar holding clips at different speeds, a sampler now fits each clip's notes to that clip's own stretch instead of the last clip's for the whole bar. A group parent now starts a child clip moved off the grid on its exact frame rather than at the next 64-sample tick. Synth, The Dark, Too many chords and Elevation render byte-identical to 2026.8.297.",
      "tickets": [
        416
      ]
    },
    {
      "title": "Hosted plugin instruments keep their patch, ring out past the bar line, and convert to sample clips correctly",
      "version": "2026.8.300",
      "date": "2026-09-27",
      "area": "Sound engine",
      "type": "Bug fix",
      "userFacing": false,
      "details": "Convert to sample clip was checked with a real VST3 instrument (Vital) and with a fake plugin host in the tests, and four bugs came out, all of which affected playback as well as Convert. The graph pushed every plugin parameter the song does not set back to the plugin's init value on the first block, overwriting the restored patch (a 13 s release played as 0.09 s); only parameters the song actually sets (a wire, a lane, a stored knob) are sent now. A plugin always counted as silent, so its release and reverb stopped at the first bar line after its last note, and a converted clip kept no more than that; a plugin now counts as sounding until its output has stayed under -80 dBFS for a quarter of a second. A note a clip starts inside (a front trim) never reached a plugin; it is now picked up at the clip's start as it is for the built-in instruments. And a clip ending between two control ticks exactly where its pattern wraps restarted its first note on the way out: a blip on a built-in instrument, a note left hanging on a plugin. The conversion's own plugin instance is loaded on its pool thread from the channel's saved state and is never the one playback is using; checked with playback running throughout. Vst3Probe --convert runs the real-plugin check.",
      "tickets": [
        417
      ]
    },
    {
      "title": "REAPER import reads Kilohearts settings: kHs effects, Multipass, Snap Heap and Phase Plant's effect lanes come in as you set them",
      "version": "2026.8.300",
      "date": "2026-09-27",
      "area": "Services",
      "type": "New feature",
      "userFacing": true,
      "details": "A Kilohearts plugin's saved state holds its settings, so the REAPER importer now reads it instead of guessing. A kHs Filter comes in at its own cutoff, type, Q and slope (shelves and bells become the EQ); Reverb, Delay (synced delays at the song tempo), Compressor, Limiter, Distortion, Faturator, Chorus, Gain, Stereo, 3-Band EQ and the ladder and nonlinear filters carry their real values too. Multipass becomes Korben with the same bands, splits, band gains and each band's effects, plus its pre and post lanes; Snap Heap becomes its lanes' effects in order; Phase Plant's effect lanes go on its channel, and the report names what its voice was (generators, transpose) so whoever picks an instrument knows what they are replacing. Kilohearts states in the binary layout are read for the common single effects; a Multipass or Phase Plant saved that way is still reported as not read. Where the lanes sit was measured by rendering through the plugins (tools/KiloheartsProbe). See docs/reaper-import.md section 9.",
      "tickets": [
        412
      ]
    },
    {
      "title": "REAPER import turns plugin automation into automation lanes",
      "version": "2026.8.300",
      "date": "2026-09-27",
      "area": "Services",
      "type": "New feature",
      "userFacing": true,
      "details": "Plugin automation envelopes used to be dropped. Now each one becomes an automation lane on the channel, on the control of whatever replaced the plugin, in that control's own units: a kHs Filter cutoff sweep drives the Filter's cutoff through the plugin's own curve, REAPER's wet knob drives the replacement's mix, and on a Serum or Vital converted to Bifrost the lane goes wherever the converter puts that setting (it is asked, by converting the patch again across the setting's range). An envelope with nothing to drive, such as a Phase Plant macro or a dropped plugin's parameter, is named in the report. Envelopes are now matched to the plugin written just before them, and the master chain's are read too. See docs/reaper-import.md section 10.",
      "tickets": [
        413
      ]
    },
    {
      "title": "REAPER import picks the closest installed instrument for plugins it cannot convert",
      "version": "2026.8.299",
      "date": "2026-09-27",
      "area": "Services",
      "type": "New feature",
      "userFacing": true,
      "details": "A REAPER track that played an instrument plugin other than Serum, Vital or Speedrum used to arrive with no instrument at all. Now it gets the closest instrument installed on this machine, with the closest of its presets applied, and the import report says what was picked, which part it was taken for and why, and the next two closest, so you can change it. The choice is made from what the project can tell without the plugin: the patch's name (and the BS/PD/LD codes sound designers put in front of them), the folder and tags Phase Plant saves with a patch, a table of well-known plugins (drum samplers, pianos, 303s, Juno and OB-style synths, FM synths), the track's name, and how the notes are played (low single notes are a bass, long chords a pad, General MIDI kick, snare and hats are drums). It never picks a module you do not have; when none of the free store instruments made for that part is installed, the report names them. A drum part goes to the Synth kit with its General MIDI notes moved onto the kit's pads. A preset that would be silent here (Bank Manager's sampled piano without its pack) is passed over and the report says so. A track with no notes keeps no instrument, as before. Five Phase Plant patches that were matched by ear in an earlier port go to the modules chosen then. The plugin is not played to compare the sound: the released app does not load plugins.",
      "tickets": [
        414
      ]
    },
    {
      "title": "Sample clips can play an intro once, then loop",
      "version": "2026.8.299",
      "date": "2026-09-27",
      "area": "Sound engine",
      "type": "New feature",
      "userFacing": true,
      "details": "The Clip editor has a start marker ahead of the loop brace: the clip plays from it to the loop once, then loops the loop, as Ableton's start marker does. Set it from the wave's right-click menu (Play from here once, then loop), from a cue before the loop, or from Loop and cues at the cursor, and drag its handle in the marker lane; dragging it onto the loop removes it. The intro plays at the loop's speed and is shared with linked copies. In the engine a clip with an intro plays as two ordinary regions, the intro run into the loop's first pass and then the loop, each with its own cached lap, so it stays sample-accurate; a clip without one renders exactly as before, and the demos are unchanged. Sample clips' settings are now part of a channel's content hash too (they were not hashed at all), so a cached bounce is redone when one changes. The intro is not drawn on the clip's face in the sequencer yet.",
      "tickets": [
        406
      ]
    },
    {
      "title": "Slice a sample clip into clips at its cue points",
      "version": "2026.8.299",
      "date": "2026-09-27",
      "area": "UI",
      "type": "New feature",
      "userFacing": true,
      "details": "Slice the clip at its cues (the Clip editor's Edit menu, a cue's menu, and Slice at cues on the clip's right-click menu) turns one sample clip into a row of clips, cut wherever what it plays crosses a cue and wherever its loop comes round, following its trim, offset, stretch, loop passes and intro. Cuts land on the whole tick at or before each cue, so no slice loses the sound its cue marks, and the row sounds like the clip. Each slice is a clip of its own, linked to nothing, except that the same slice repeated on later passes of a loop is linked, like copies. One undo step. Cues are not offered to the drum chop or Capacitor yet.",
      "tickets": [
        407
      ]
    },
    {
      "title": "Name cue points in the Clip editor",
      "version": "2026.8.299",
      "date": "2026-09-27",
      "area": "UI",
      "type": "New feature",
      "userFacing": true,
      "details": "Double-click a cue's flag, or choose Name this cue on its menu, to give it a name; the name is drawn beside the flag, cut short at the next one. Clear its name takes it off. Names are shared with linked copies, as cues are, and the control API can set them (clip_editor op cue_name).",
      "tickets": [
        408
      ]
    },
    {
      "title": "Remove unused waves from a song after editing samples",
      "version": "2026.8.299",
      "date": "2026-09-27",
      "area": "UI",
      "type": "New feature",
      "userFacing": true,
      "details": "Editing a sample's audio embeds an edited copy and keeps the original, which went on taking space in the song after nothing played it. Remove unused waves from the song (the Clip editor's Edit menu) removes the embedded waves that no clip or shared definition plays, as one undo step, and says how much it saved. It is careful: a wave a wave-bank instrument could pick by slot number is kept (it works out which slots the song's samplers can reach), a licence credit on a removed original moves to the edited copy that is still heard, and saving never prunes on its own, so the undo history always has what it needs.",
      "tickets": [
        409
      ]
    },
    {
      "title": "The Clip editor's view follows the preview playhead",
      "version": "2026.8.299",
      "date": "2026-09-27",
      "area": "UI",
      "type": "Improvement",
      "userFacing": true,
      "details": "Zoomed in on a sample, the view now pages along with the preview playhead when it runs off the edge, or jumps back to a loop's start that is out of view, keeping the zoom. If you have already scrolled away from the playhead it is left alone.",
      "tickets": [
        410
      ]
    },
    {
      "title": "Import REAPER projects in the browser: choose the project's folder and its audio comes too",
      "version": "2026.8.299",
      "date": "2026-09-27",
      "area": "Web",
      "type": "New feature",
      "userFacing": true,
      "details": "File > Import > REAPER project used to say \"desktop app only\" on the web build, because a project keeps its audio in separate files and a browser can only read the files you choose. It now asks for the project WITH its audio: choose the project's folder (or the .rpp and its audio files together), or drop the folder on the window. Only the files the project plays are read. Each audio path the project wrote is matched against what you chose, first relative to the .rpp and then by the longest matching end of the path, so a project saved on another machine still finds its files, and anything not found is listed in the import report with a line saying how to bring it in. The import runs in a background worker of its own, the same importer the desktop app uses, so the app stays responsive while the audio is decoded and compressed; only opening the finished song happens on the page.",
      "tickets": [
        415
      ]
    },
    {
      "title": "Glue and Convert to sample clip are in the Edit menu, and the Edit menu greys only what you can't do right now",
      "version": "2026.8.298",
      "date": "2026-09-27",
      "area": "UI",
      "type": "Improvement",
      "userFacing": true,
      "details": "The sequencer's Edit menu now has Glue (Ctrl+G) and Convert to sample clip, straight after Duplicate after itself, as in a clip's right-click menu. They act on the selected clips and follow the same rules: Glue reads \"Glue (select two or more clips)\" and is greyed until some channel has two or more selected clips, and Convert says how many clips it will render. The Edit menu used to decide what was greyed once, when the sequencer opened, so Copy, Cut, Duplicate and the rest stayed grey after you selected an area, and Undo and Redo never changed. Every row now asks when the menu opens: Undo and Redo follow the history, the area operations follow the selection, and Paste follows the clipboard. File > Loop pack... does the same for the song that is open. Behind it, a GustUI menu item can now give a live enabled state and label (EnabledWhen, TextWhen), asked when its menu opens and when the menu bar looks for a shortcut to run.",
      "tickets": [
        411
      ]
    },
    {
      "title": "Pattern clips moved or sized off the bar line play where they are drawn, and two clips can share a bar",
      "version": "2026.8.297",
      "date": "2026-09-27",
      "area": "Sound engine",
      "type": "Bug fix",
      "userFacing": true,
      "details": "A pattern clip Shift-dragged to start part way into a bar used to start on the bar line anyway, one sized to end part way through a bar played on to the bar line, and of two clips sharing a bar only one was heard: the engine placed pattern clips a whole bar at a time. Now a clip's notes start at its start, to the frame (a note the start lands inside is picked up part way, as a front trim already did), and at its end the instrument releases, the same as at the end of any clip, so its release tail rings on. Several clips in one bar all play, one after another. Clips on the bar grid sound exactly as before. The Elevation demo's bass and texture parts end half a bar before each drop, and that break is now heard. Only the bar holding such an edge plays live; the clip's other bars still come from the pattern cache, and the instrument is warmed up the bar before so a held note carries straight on. The piano roll's playhead and the channel's voice meter follow the same placement, and a hosted plugin instrument now gets a note-off at the end of a clip for every note still held there (it never did, at any clip end).",
      "tickets": [
        403
      ]
    },
    {
      "title": "Echo and reverb tails ring on where a channel moves between sample clips and a pattern clip",
      "version": "2026.8.297",
      "date": "2026-09-27",
      "area": "Sound engine",
      "type": "Bug fix",
      "userFacing": true,
      "details": "A channel plays sample clips (and cached pattern audio) through one copy of its effects and a live pattern clip through another, and the two did not share their state. So where a channel went from sample clips to a live pattern clip, the echo of the samples stopped dead at the bar line, and where it went back, the pattern clip's echo stopped and an old tail from the last time the other copy ran came back instead. Both now use one set of effect state, handed over at the bar where the channel switches, so an echo or reverb rings on exactly as it would between two pattern clips. Measured: the switched bar is now sample-identical to the same song with no switch, where before its echo was missing entirely. The instrument's own voices carry across the switch too when a live pattern clip and sample clips meet, so a release is not cut. A hosted plugin effect is the one exception: its tail still restarts at a switch.",
      "tickets": [
        404
      ]
    },
    {
      "title": "Per-channel CPU numbers from the control API no longer vanish when a song is opened",
      "version": "2026.8.296",
      "date": "2026-09-27",
      "area": "Desktop",
      "type": "Bug fix",
      "userFacing": false,
      "details": "Starting the control server forces the per-channel CPU profiler on so /stats reports cpuPercent per channel, but every song load re-applied the user's 'show pipeline cost' preference with a plain assignment and switched it off again, including the boot song, so the numbers read null for the rest of the session. The control server's switch is now a session override (AudioCostProfiler.SetSessionOverride) that the preference paths (song attach, Preferences, POST /settings showPipelineCost) OR with the preference through AudioCostProfiler.ApplyPreference. It lasts until the server stops, which hands the profiler back to the preference. Documented in docs/remote-control-api.md.",
      "tickets": [
        405
      ]
    },
    {
      "title": "Glue several clips into one: right-click > Glue, or Ctrl+G",
      "version": "2026.8.295",
      "date": "2026-09-27",
      "area": "Sequencer",
      "type": "New feature",
      "userFacing": true,
      "details": "Select clips on a channel and choose Glue from a clip's right-click menu (or press Ctrl+G) to make one clip of them that plays exactly what they did. Pattern clips become one new pattern holding every note they played, where they played it: trims, repeats, stretches, gaps and overlaps are all worked out, and the old patterns stay in the channel's pattern bank. Sample clips are mixed down into one new sample clip, with their gain, pan, fades, pitch and stretch applied as they sounded and the channel's effects left to run on it live, so nothing is processed twice. Pattern and sample clips on the same channel can't be one pattern, so you are asked whether to glue them all as a sample clip; yes renders the pattern clips through their instrument first. Each channel's selected clips glue separately, and it is all one undo step.",
      "tickets": [
        398
      ]
    },
    {
      "title": "Convert a pattern clip to a sample clip from its right-click menu",
      "version": "2026.8.295",
      "date": "2026-09-27",
      "area": "Sequencer",
      "type": "New feature",
      "userFacing": true,
      "details": "Convert to sample clip is always in a pattern clip's right-click menu. It renders the clip through its channel's instrument, offline, and puts a sample clip of that audio in its place, so you can edit it as audio. The channel's effects are not rendered in: they keep running on the sample clip live, as they did on the notes. The instrument's release is kept, and the clip grows to hold it, so nothing is cut off. Only the clips you chose convert: other clips of the same pattern stay pattern clips. One undo step puts it back.",
      "tickets": [
        398
      ]
    },
    {
      "title": "In pattern mode, a clip's release tail no longer draws over the next clip's notes",
      "version": "2026.8.294",
      "date": "2026-09-27",
      "area": "UI",
      "type": "Bug fix",
      "userFacing": true,
      "details": "With the sequencer showing pattern clips as mini piano rolls (the note-preview toggle in the toolbar), each clip's release tail was still drawn as a small waveform just past its end, on top of whatever came next, so it covered the first notes of the following clip. A tail is a picture of the sound ringing out, not of notes, so pattern mode now leaves it out, as it already did for the tails between a clip's own repeats. Turn the toggle off and the tails are back, exactly as before. Wave-picker channels, which keep their waveforms in pattern mode, keep their tails too.",
      "tickets": [
        402
      ]
    },
    {
      "title": "Restore on the sequencer while it fills the space now shrinks it, and a later drag no longer does",
      "version": "2026.8.294",
      "date": "2026-09-27",
      "area": "UI",
      "type": "Bug fix",
      "userFacing": true,
      "details": "While the sequencer filled the space beside the docked panels, its maximise button did nothing you could see: pressing it flagged the window as maximised, pressing it again started shrinking it, and the layout that keeps it filling the space undid both every frame. The shrink stayed pending and finished the next time you dragged the title bar, so the sequencer suddenly shrank under the pointer. A window that fills the free space now counts as maximised within the docks: its button shows restore, and pressing it floats the window at 70% of the space (never below its minimum). Maximise on it after that is the ordinary one. Nothing started while a window fills the space is left to finish later.",
      "tickets": [
        401
      ]
    },
    {
      "title": "Scroll a zoomed-in sample in the Clip editor with the overview strip under it",
      "version": "2026.8.294",
      "date": "2026-09-27",
      "area": "UI",
      "type": "New feature",
      "userFacing": true,
      "details": "Zoomed in on a sample in the Clip editor, there was nothing on screen to scroll with: Shift+wheel and a middle drag worked, but nothing said so, and nothing showed where in the sample you were. A slim overview strip now runs under the waveform: the whole sample, with the loop, the selection, the cues, the cursor and the preview playhead marked, and the part the view shows framed as a window. Drag the window to scroll; drag either end of it to zoom; press anywhere else on the strip to jump there; double-click it to see the whole sample. The wheel over the strip scrolls sideways and Ctrl+wheel zooms. Over the waveform the wheel still zooms around the pointer and Shift+wheel still scrolls.",
      "tickets": [
        397
      ]
    },
    {
      "title": "The pattern explorer shows notes while the sequencer shows notes",
      "version": "2026.8.294",
      "date": "2026-09-27",
      "area": "UI",
      "type": "Bug fix",
      "userFacing": true,
      "details": "With the sequencer's note-preview toggle on (the toolbar button that swaps every pattern clip's waveform for a mini piano roll), the pattern explorer's Patterns tab still drew each pattern as a waveform. Its tiles now follow the toggle and draw the pattern's notes, the same mini piano roll the clips use, bends included, in one pitch range shared by all of the channel's tiles so they compare. Clicking a tile to preview it walks the playhead across the notes. As on the timeline, a wave-picker channel and a pack pattern that has not been fetched yet keep their waveforms.",
      "tickets": [
        399
      ]
    },
    {
      "title": "Restoring a maximised window never makes it smaller than its minimum, and a docked window keeps its minimum too",
      "version": "2026.8.293",
      "date": "2026-09-27",
      "area": "UI",
      "type": "Bug fix",
      "userFacing": true,
      "details": "With the pattern explorer docked at the bottom, maximising the sequencer and pressing restore left it floating at 736 x 182, under its 260 px minimum height: a window with no floating size of its own to go back to restores to 70% of its maximised size, and nothing checked that against its minimum. Dragged to the top edge, it then docked at 182, leaving a 78 px gap above the explorer. A restore now keeps to the window's minimum and to the free space beside the docks, and lands inside that space rather than hanging over a docked panel. That is the same rule a window coming off a dock already followed, and the two now share it. Docking never makes a window thinner than its minimum either, wherever the space allows, so the sequencer docks at the top at 260. Dragging a dock's divider stops at the panel's minimum as well (the pattern explorer at 170) instead of at 48 px.",
      "tickets": [
        400
      ]
    },
    {
      "title": "A docked window's inner edge has one drag, and it lights up where you can grab it",
      "version": "2026.8.292",
      "date": "2026-09-27",
      "area": "UI",
      "type": "Bug fix",
      "userFacing": true,
      "details": "The edge of a docked window facing the rest of the screen had two drag targets on it: the window's own 6 px resize strip, and the 10 px dock divider on top of it. They did different things (one resized the window, the other moved the boundary), and the one that lit up on hover was the strip, while the one you actually grabbed was the divider. The divider now owns the edge on its own: the window's resize strips switch off while it is docked, and the divider lights up across its full 10 px when you hover or drag it. The divider's highlight had never drawn at all before.",
      "tickets": [
        393
      ]
    },
    {
      "title": "Every button and window title bar holds each of its parts once",
      "version": "2026.8.292",
      "date": "2026-09-27",
      "area": "UI",
      "type": "Bug fix",
      "userFacing": false,
      "details": "An element could hold the same child twice, and many did: every button held its label twice (added when it was built and again under its final name), so each label was drawn twice, and removing a child took out only one copy. GustUI's child list now moves a child it already holds instead of adding it again, so nothing can be held twice. A window title bar's pin and maximise buttons also showed in /tree as two unnamed buttons, because AddChildElement ignored the name it was given; they are now pinButton and sizeButton. 10 duplicated children in a new song's tree with the explorer open are now 0, and nothing looks different.",
      "tickets": [
        394
      ]
    },
    {
      "title": "The sequencer no longer overlaps a bottom-docked explorer after you move it",
      "version": "2026.8.292",
      "date": "2026-09-27",
      "area": "UI",
      "type": "Bug fix",
      "userFacing": true,
      "details": "In a new song with the explorer docked along the bottom, dragging the sequencer let the explorer grow by about 3 px into it. Dragged anywhere, the sequencer stopped filling the free space and the docks stopped leaving room for it, so the explorer took its full half of the window; dragged to the top edge, it docked there, and a top dock and a bottom dock were never kept apart at all. A window that filled the free space now keeps its room while it floats (until it is closed), and a top and a bottom dock (or a left and a right) share the window the way side and bottom docks already did: the one that was there first keeps its size, the later one fits in what is left, and the later one's minimum holds the first back. The dock preview shows the same.",
      "tickets": [
        395
      ]
    },
    {
      "title": "An imported Vital or Serum channel shows Bifrost's panel even when Bifrost is not installed",
      "version": "2026.8.291",
      "date": "2026-09-27",
      "area": "UI",
      "type": "Bug fix",
      "userFacing": true,
      "details": "A REAPER import turns every Vital (and Serum) track into a Bifrost whose whole definition travels inside the song, so it plays with Bifrost not installed. Its panel did not travel with it: panels arrive with a module's store package, so on a machine without Bifrost the channel's panel said \"No custom UI yet\" and only the node editor worked. The panel window now shows Bifrost's own panel in that case. The same goes for every other module that moved from the app to the store with its panel still built into the app (Bank Manager, Bueller, Cloudform, Freyja, HALL 9000, Kenobi, Laufeyson, Modyssey, Plucktopus, PXL4, Rat 030, Rhodine): a song carrying one of them shows its face without the package. It is shown only, never saved into your panel library or bundled into a module you publish; your own edit of the panel still wins, then an installed package's, then this. For a store module whose panel the app does not carry, the empty panel now says the panel comes with its store package and offers a button into the store's own Get or Buy page, and the panel appears as soon as the install lands.",
      "tickets": [
        392
      ]
    },
    {
      "title": "F1 shows the controls in the Clip editor too",
      "version": "2026.8.291",
      "date": "2026-09-27",
      "area": "UI",
      "type": "Improvement",
      "userFacing": false,
      "details": "The Clip editor gained its own keyboard shortcuts with the sample editor (#387), and every screen that binds keys must also bind F1 to open the help (ShortcutDocumentationTests). It now does."
    },
    {
      "title": "The Clip editor edits the audio: select, cut, copy, paste, reverse, gain, fades, a loop and cue points",
      "version": "2026.8.291",
      "date": "2026-09-27",
      "area": "UI",
      "type": "New feature",
      "userFacing": true,
      "details": "The Clip editor now shows the clip's sample as a waveform you can edit. Drag to select (edges snap to zero crossings so cuts do not click), click to place the cursor, and use Cut, Copy, Paste, Delete, Trim to selection, Reverse, Gain (in dB), Normalise, Fade in, Fade out and Silence from the toolbar, the menus, a right-click or the usual keys (Ctrl+X/C/V, Delete, Ctrl+A). The loop brace above the waveform is the part of the sample the clip plays and repeats: drag its ends or the whole loop, or select and press Loop. Cue points are placed with M, a double-click above the waveform or the flag button, dragged to move, and right-clicked to start the clip at one or select to the next. Edits never change the original sample: the first edit makes an edited copy inside the song and the clip, and every linked copy of it, plays that; other clips, songs and packs using the original are untouched, and one undo puts everything back. The loop, cues and edited audio are saved with the song. Wheel zooms, Shift+wheel scrolls, and the play button previews the selection or the loop.",
      "tickets": [
        387
      ]
    },
    {
      "title": "The Clip editor is redesigned: knobs, switches and menus in cards instead of rows of step buttons",
      "version": "2026.8.291",
      "date": "2026-09-27",
      "area": "UI",
      "type": "Improvement",
      "userFacing": true,
      "details": "The clip's properties are now four cards under the waveform, laid out like the Stack's device cards: Sample (fitted length, tempo, reverse, and Make unique when the clip shares its settings), Pitch and stretch (pitch and fine knobs, keep pitch, algorithm and formants), Level (gain and pan knobs) and Fades, this clip only (fade in and out knobs and their shapes). Knob drags are one undo step and a right-click resets a knob. The editor docks a little taller, gives any extra height to the waveform, and scrolls its cards when the window is small.",
      "tickets": [
        389
      ]
    },
    {
      "title": "The welcome screen no longer leaves an empty gap above Demos",
      "version": "2026.8.291",
      "date": "2026-09-27",
      "area": "UI",
      "type": "Bug fix",
      "userFacing": true,
      "details": "When there were no packs to start from (signed out, or no licensed packs), the Start from a pack section hid its heading by drawing it in a transparent colour, so the heading and the gap under the section still took about 40 px and left an empty band above Demos. The heading and gap are now part of the section itself, so an empty section takes no room. With packs, the section is laid out exactly as before.",
      "tickets": [
        388
      ]
    },
    {
      "title": "A sequencer docked at the side keeps its minimum height beside a bottom dock",
      "version": "2026.8.291",
      "date": "2026-09-27",
      "area": "UI",
      "type": "Bug fix",
      "userFacing": true,
      "details": "With the pattern explorer docked along the bottom, docking the sequencer to the left at 1600x900 gave it 257 px of height, under its 260 px minimum: while the sequencer filled the free space its minimum held the explorer back, but once it was docked nothing did, and the explorer grew to its 50% cap. A dock that docks after a perpendicular one now holds that earlier dock back by its minimum (and the status bar), the same way a window filling the free space does. Here the explorer stays at 279 px and the sequencer docks at exactly 260.",
      "tickets": [
        390
      ]
    },
    {
      "title": "The dock preview shows where the window will actually dock",
      "version": "2026.8.291",
      "date": "2026-09-27",
      "area": "UI",
      "type": "Bug fix",
      "userFacing": true,
      "details": "Holding a window at the bottom edge drew its dock preview flush with the bottom of the window, over the status bar and over any panel already docked along the bottom. The preview now uses the same rule the dock itself does: above the status bar, above any bottom docks, inboard of any panels already on that side, between the side docks, and at the size the window will really get, so a wide window held at a side edge previews at half the width rather than its full width. For the control API, a docked window's resize-handle element now reports the window's current size in /tree rather than the size it had when it docked.",
      "tickets": [
        391
      ]
    },
    {
      "title": "Serum tracks in a REAPER project now import as Bifrost playing a converted patch",
      "version": "2026.8.291",
      "date": "2026-09-27",
      "area": "Services",
      "type": "New feature",
      "userFacing": true,
      "details": "A REAPER project's Serum channels used to import with no instrument, because Serum's settings could only be read by Serum itself. The app now reads them straight from the project's saved patch, with no Serum install needed, and gives the channel a Bifrost playing a conversion of it: oscillators, the patch's own wavetables (played frame for frame when the patch carries them, which most do), filter, the three envelopes with their hold stages, LFO rates (tempo divisions or free Hz), the mod matrix, unison, macros and the effect levels Bifrost has. A wavetable the project only names (one of Serum's own files) gets a generated stand-in, and the import report says which. Everything that cannot be carried is listed in the report, grouped by the part of Serum it belongs to. VST3 and VST2 Serum instances are both read. Checked against Serum itself on 38 real instances: reading the saved patch gives the same Bifrost as reading through the plugin.",
      "tickets": [
        268
      ]
    },
    {
      "title": "Serum converter shares its code with the app, and no longer reads wavetables as LFO shapes",
      "version": "2026.8.291",
      "date": "2026-09-27",
      "area": "Sound engine",
      "type": "Internal",
      "userFacing": false,
      "details": "The pure half of tools/SerumConvert (SerumPatch, SerumState, SerumMap, the source table, WavetableChoice, BifrostWriter) moved to src/Ezmuze.Audio/Serum so REAPER import and the tool use one converter. New: SerumStateReader (the patch from the saved state alone), SerumDisplay (Serum's display text from measured curves), SerumSections and SerumImport. Corrections to the tool's converter: the state tail's second zlib stream is the oscillators' wavetables (byte counts at 0x4968/0x496C), not LFO curves, so the old LFO-shape read was removed; modulation endpoints use the measured display curves instead of proportional scaling (an envelope-to-cutoff sweep had read eight times too shallow); free LFO rates, envelope holds and Output Level (relative to each default, not 0.7 copied across) are carried; mod amounts and switched-off sections no longer show as dropped. New tool commands corpus, audit, sweep and import-report. docs/serum-to-bifrost-converter.md section 8 and docs/reaper-import.md section 4a."
    },
    {
      "title": "A maximised window dragged when a dialog opens comes back maximised",
      "version": "2026.8.290",
      "date": "2026-09-27",
      "area": "UI",
      "type": "Bug fix",
      "userFacing": true,
      "details": "If a dialog the app waits on (Save As and the like) opened while you were dragging a maximised window off by its title bar, the window went back to where the drag started but at its smaller restored size, with bare backdrop around it. It is maximised again now, filling the space it had, and restoring it later still goes back to the size it had before. The cause was in the window's title bar, which held its drag strip twice, so one press was handled twice: the first took the window out of maximised and the second then recorded it as not maximised. The same fix stops the title bar's close button hearing each click twice. A window dragged by one of its tabs is now put back the same way instead of docking or merging wherever the drag had got to.",
      "tickets": [
        383
      ]
    },
    {
      "title": "Docking a window beside one docked along the bottom no longer leaves an empty corner",
      "version": "2026.8.290",
      "date": "2026-09-27",
      "area": "UI",
      "type": "Bug fix",
      "userFacing": true,
      "details": "With the pattern explorer docked along the bottom, docking the sequencer to the left shortened the sequencer to sit above the explorer and ALSO narrowed the explorer to start beside the sequencer, so the corner below one and beside the other was bare backdrop. Now the panel that was docked first keeps the corner and the later one fits beside it, which is what the dock preview has always shown while you hold: here the explorer keeps its full width and the sequencer docks above it. Docking the sequencer first and then opening the explorer puts the explorer beside it instead, with the sequencer full height. A panel dragged off a dock and put back by a dialog returns to the same place. The sequencer also lays its rows out again when its toolbar wraps to a second line in a narrow window, so it no longer runs past its bottom edge onto the panel below.",
      "tickets": [
        384
      ]
    },
    {
      "title": "The Clip editor fits its fades in at a normal window size, and scrolls when it cannot",
      "version": "2026.8.290",
      "date": "2026-09-27",
      "area": "UI",
      "type": "Bug fix",
      "userFacing": true,
      "details": "Docked at the bottom of a 1600x900 window, the Clip editor cut off Fade in, Fade out and their shapes below its bottom edge, and the wheel did nothing. Its three columns were a fixed 400 px each, so the third wrapped under the first although the rows need only about 320. Columns now share the width, three side by side from about 1000 px wide, so every row fits in the docked editor at that size. When the editor is narrower or shorter than its rows it scrolls, with the wheel or its scrollbar, and pressing a step button keeps your place rather than jumping back to the top.",
      "tickets": [
        385
      ]
    },
    {
      "title": "The welcome screen invites you to sign in, and dims the demos that need an account",
      "version": "2026.8.289",
      "date": "2026-09-27",
      "area": "UI",
      "type": "New feature",
      "userFacing": true,
      "details": "A new section at the top of the welcome screen: \"Welcome to ezmuze studio. While not required, ezmuze studio works better when you use an ezmuze central account. Click here to sign in, or click here to sign up.\" Sign in opens the usual ezmuze central sign-in over the welcome screen; sign up opens the ezmuze central sign-up page in your browser. The × dismisses it for good. It is also hidden while you are signed in, and goes the moment a sign-in succeeds. Demos made of licensed loop packs (Sample song and ezmuze 2.0 today) are drawn at half opacity while you are signed out, with a tooltip saying they need an ezmuze central account to play their sounds; they still open, and opening one asks you to sign in as before. Which demos need an account is worked out from what each demo references, not from a list of names, so a new demo is covered automatically. Tooltips can now run to more than one line.",
      "tickets": [
        386
      ]
    },
    {
      "title": "GL builds bind the visualiser's spectrum texture by its real name, and a test catches a sampler name that would miss",
      "version": "2026.8.288",
      "date": "2026-09-26",
      "area": "Build",
      "type": "Internal",
      "userFacing": false,
      "details": "On the GL heads a Slang effect's samplers are bound by looking up their uniform by name, and a miss is silent: the sampler keeps GL's default unit, 0. VisEffects declared its one sampler as \"SpectrumTex\", which is no uniform in the generated GLSL (spirv-cross names it SPIRV_Cross_CombinedSpectrumTexSpectrumSampler), so it had only ever worked by falling back to unit 0. It now declares the generated name, and its DesktopGL and web content is rebuilt. New SlangSamplerNameTests check, against the compiled DesktopGL content, that every texture a Slang source samples reaches the GLSL as a sampler uniform and every declared sampler names one, so a two-texture effect cannot silently sample the wrong texture. ModalBackdrop.slangfx's comment about the unit-0 fallback is corrected to match (bug board #376 discussion). No change to what anything looks like.",
      "tickets": [
        376
      ]
    },
    {
      "title": "The full-screen visualiser opens on top again, and one click or Escape closes it",
      "version": "2026.8.288",
      "date": "2026-09-26",
      "area": "UI",
      "type": "Bug fix",
      "userFacing": true,
      "details": "The full-screen button on the now-playing toast seemed to do nothing: since the visualisers were merged into one (#152) the full-screen view had been opening underneath the sequencer, where you could not see it, while still taking Escape and the arrow keys. It now covers the whole window again, the bars and toasts included. A click anywhere or the first Escape closes it, and Left and Right still change the effect. It closes itself if the app needs to ask you something, so a question is never hidden behind it, and it will not open while one is waiting.",
      "tickets": [
        380
      ]
    },
    {
      "title": "A drag you were in the middle of is put back when a dialog asks you something",
      "version": "2026.8.288",
      "date": "2026-09-26",
      "area": "UI",
      "type": "Bug fix",
      "userFacing": true,
      "details": "If a dialog the app waits on (Save As, Unsaved changes and the like) opened while you were dragging, the drag carried on invisibly underneath it: a clip dragged when Save As opened landed wherever you let go of the mouse, under the dialog. Now opening such a dialog ends every drag at once. A clip move, resize, slip or fade is dropped without changing anything, a window being dragged goes back where it was (back onto its dock, or back to maximised), and a ruler scrub puts a stopped playhead back. The rest of that mouse press does nothing, the dialog included.",
      "tickets": [
        374
      ]
    },
    {
      "title": "The docked pattern explorer opens at a usable height, and no longer shrinks each time the window does",
      "version": "2026.8.288",
      "date": "2026-09-26",
      "area": "UI",
      "type": "Bug fix",
      "userFacing": true,
      "details": "At an ordinary window size (1600x900 at 150% scaling) the pattern explorer docked only 118 px tall, showing its tabs but none of its tiles, and every time the window was made smaller it got smaller too and stayed that way. A docked panel now remembers the height it wants separately from the height it has room for, so it grows back when the window does. The sequencer also gives up more room to docks (its minimum height is now 260 instead of 420), so the explorer opens at about 278 px there with its tiles showing.",
      "tickets": [
        379
      ]
    },
    {
      "title": "Lua scripts: ezmuze.log() returns the log, number arguments work, and a /tree name that matches nothing says so",
      "version": "2026.8.288",
      "date": "2026-09-26",
      "area": "Services",
      "type": "Internal",
      "userFacing": false,
      "details": "Three silent gaps in the remote-control API's Lua bridge. ezmuze.log() returned nil: a report() alias with the same name overwrote it, and /log was only answered by the HTTP listener, not the command queue scripts go through (the in-app MCP server's get_log failed the same way). The queue now answers /log from the same buffer. Arguments that were numbers or booleans were ignored, because only strings were read: ezmuze.get('/clips', {limit=5000}) quietly returned the default 50. Any JSON type is now read, the same for POST bodies over HTTP. And GET /tree?name= (or text=) that matched nothing returned the whole tree; it now answers 404 naming what was asked for.",
      "tickets": [
        371
      ]
    },
    {
      "title": "Enter in the Save song dialog saves",
      "version": "2026.8.288",
      "date": "2026-09-26",
      "area": "UI",
      "type": "Bug fix",
      "userFacing": true,
      "details": "Pressing Enter after typing a name in the Save song dialog did nothing. The dialog stands aside from Enter while a text field has the keyboard, so that a name being typed reaches the field, and the file name box had nothing of its own to do on Enter. It now presses Save, as the button says.",
      "tickets": [
        381
      ]
    },
    {
      "title": "File > Song settings brings the open Song settings window to the front instead of opening a second one",
      "version": "2026.8.288",
      "date": "2026-09-26",
      "area": "UI",
      "type": "Bug fix",
      "userFacing": true,
      "details": "With Song settings already open but hidden behind the maximised sequencer, choosing File > Song settings opened a second copy on top of it. It now brings the open one to the front, as Preferences already did. If the song has changed since that window was opened, it is replaced by a fresh one for the current song.",
      "tickets": [
        382
      ]
    },
    {
      "title": "Clips in the sequencer now draw pitch bends, so a bent pattern looks like it does in the piano roll",
      "version": "2026.8.287",
      "date": "2026-09-26",
      "area": "UI",
      "type": "Bug fix",
      "userFacing": true,
      "details": "With the mini piano-roll preview on, a pattern clip drew each note at its starting pitch and ignored its bend, so the Too many chords demo (nine notes that all start on C4 and bend out to the chords) showed as one flat bar while the piano roll showed the chords fanning out and gliding. Clips now draw every note along its bend, using the same curve samples and the same band shape as the piano roll, so the two cannot disagree. The clip's vertical range now covers what the notes actually sound, bends included, with a little headroom above and below when bends reach past the notes (the piano roll's three rows, scaled down for a small bend). Songs without bends look exactly as before. The bent curves are built once and redrawn from a cache, so scrolling and panning the sequencer are no slower, and the piano roll now draws straight stretches of a bend as single pieces instead of hundreds.",
      "tickets": [
        377
      ]
    },
    {
      "title": "Dialogs that ask you something now stay on top, over a blurred backdrop, until you answer",
      "version": "2026.8.286",
      "date": "2026-09-26",
      "area": "UI",
      "type": "Improvement",
      "userFacing": true,
      "details": "A dialog the app waits on (Missing packs, Unsaved changes, New song, a rename or delete prompt, an error, the Open/Save file browser, sign-in, the beta gate and similar) used to be an ordinary window: one click on the maximised sequencer buried it, and the song load it was holding up looked hung. Now such a dialog sits above every window, including windows pinned to the front, over a darkened, live-blurred view of the app (animation and playback carry on behind it). It is the only thing you can click, scroll or type into until you answer it: menu shortcuts and global keys wait too. Clicking anywhere outside it flashes its title bar. A dialog opened from another stacks on top of it. Tooltips, the dialog's own dropdowns and toasts still appear above. Tool windows (Preferences, module panels, the preset browser, the store, the Stack, the explorer, the Clip editor) behave as before. The Unsaved changes hint now names the dialog's own keys (Enter saves, Escape stays), since Ctrl+S cannot reach the sequencer behind it.",
      "tickets": [
        368
      ]
    },
    {
      "title": "Choosing a window pinned to the back from View now unpins it and brings it forward",
      "version": "2026.8.286",
      "date": "2026-09-26",
      "area": "UI",
      "type": "Bug fix",
      "userFacing": true,
      "details": "A window pinned to the back sits behind a maximised sequencer, so choosing it from the View window list made it the active window (with the keyboard) while leaving it invisible, and its pin button was out of reach on its own hidden title bar. Choosing it from View now sets its pin back to Normal and brings it to the top, like any other window. Windows pinned to the front keep their pin.",
      "tickets": [
        369
      ]
    },
    {
      "title": "Save As takes a full path typed into File name",
      "version": "2026.8.285",
      "date": "2026-09-26",
      "area": "UI",
      "type": "Bug fix",
      "userFacing": true,
      "details": "Typing a whole path such as D:/songs/tune.ezproj into the Save dialog's File name box saves it there, from whatever folder the dialog is showing. It used to be glued onto the current folder (C:/D:/songs), and the save failed with an error after the dialog had already closed. A relative path such as Drafts/tune is relative to the folder shown, a path to a folder opens that folder instead of saving, and a folder that does not exist is said so under the list while the dialog stays open, rather than being created or failing. The 'already exists' warning names just the file, so the instruction to press Save again is no longer cut off. Browser storage and ezmuze central keep using the name as typed.",
      "tickets": [
        370
      ]
    },
    {
      "title": "A window dragged off a dock and maximised now fills the free space instead of overlapping the docked panel",
      "version": "2026.8.284",
      "date": "2026-09-26",
      "area": "UI",
      "type": "Bug fix",
      "userFacing": true,
      "details": "With the pattern explorer docked on the right, docking the sequencer on the left and dragging it back off left it floating at the full size of the free space, hanging over the status bar. Maximising it then left it stuck partway, about 60% of the way from where it was, over the docked explorer. There were two causes. First, the maximise animation stopped as soon as the window reached its target size, without checking its position, so a window that was already the right size moved one step and stopped. It now finishes only when both size and position arrive, then lands exactly on the target. While you drag a maximised window off, the pointer controls its position as before. Second, a window that was filling the free space when it docked no longer remembers that size. It comes off a dock slightly smaller than the dock was, like any panel with no floating size of its own, and no window comes off a dock bigger than the free space.",
      "tickets": [
        367
      ]
    },
    {
      "title": "Docked windows no longer show a maximise button that does nothing",
      "version": "2026.8.283",
      "date": "2026-09-26",
      "area": "UI",
      "type": "Bug fix",
      "userFacing": true,
      "details": "A docked window (the pattern explorer, the Stack, a tabbed pair of them, or the sequencer dragged to a screen edge) still drew its maximise button, on its title bar or on its active tab, but clicking it did nothing: a docked window's size belongs to the dock. The button is now hidden while the window is docked, the same way the pin already was. On a title bar the close button and the title close up the gap; on a tab the slot stays, so tabs keep their width. Drag the window off the dock to maximise it, and the button comes back as soon as it is floating.",
      "tickets": [
        366
      ]
    },
    {
      "title": "Pasting part of a sample clip plays the part you copied",
      "version": "2026.8.282",
      "date": "2026-09-26",
      "area": "Sequencer",
      "type": "Bug fix",
      "userFacing": true,
      "details": "Copying a time selection that cut into the front of a sample clip and pasting it gave a clip of the right length in the right place that played from the sample's start, not from where the selection began: only pattern clips had their window moved by what the selection cut off. Sample clips now keep their place in the sample, and a stretched clip's cut goes through its speed, the way trimming its left edge or splitting it does (this also corrects pasting part of a stretched pattern clip). A fade on an end the selection cut is dropped, as each half of a split drops it; a fade on an end it kept stays.",
      "tickets": [
        364
      ]
    },
    {
      "title": "Cancelling New song after closing the welcome screen brings the welcome screen back",
      "version": "2026.8.282",
      "date": "2026-09-26",
      "area": "UI",
      "type": "Bug fix",
      "userFacing": true,
      "details": "With no song open, closing the welcome screen opens New song. Cancelling that (Cancel, Escape or the dialog's X) used to leave an empty backdrop with no song, no menu and no way out but closing the window. It now goes back to the welcome screen. File > New with a song open is unchanged: cancelling leaves the song as it was. The New song dialog's title-bar X now counts as a cancel everywhere.",
      "tickets": [
        352
      ]
    },
    {
      "title": "Capacitor, Chip and Synth kit open in the module editor with nothing on top of anything else",
      "version": "2026.8.282",
      "date": "2026-09-26",
      "area": "Modules",
      "type": "Bug fix",
      "userFacing": true,
      "details": "Their graphs shipped with hand-placed positions that had drifted into one another as the modules grew: Capacitor's drum dials sat on top of each other in every row, and Chip's oscillators and Synth kit's crash and tom voices were piled up with their titles hidden. They now ship with the module editor's Clean Up (Balanced) layout, as Korben does since #329. Only positions change; presets and front panels are untouched. New devices get the clean layout; a device already in a song keeps the layout it was saved with. The three Capacitors in The Dark were cleaned up in the studio and the demo saved back. The overlap guard (ModuleFootprintTests) no longer exempts any module, and a new test holds The Dark's Capacitors clean.",
      "tickets": [
        360
      ]
    },
    {
      "title": "Pattern thumbnails stop growing in a tall explorer; more rows fit instead",
      "version": "2026.8.282",
      "date": "2026-09-26",
      "area": "UI",
      "type": "Improvement",
      "userFacing": true,
      "details": "In a tall pattern explorer (for example its own maximised window) every pattern, wave and loop tile stretched to the full height, so each thumbnail became a narrow column and a sustained note drew as one solid block. Tiles now stop at 240 px tall, about their size in the docked panel, and the extra height holds more rows, filled column by column so the strip still scrolls sideways. The docked panel at its usual height looks as it did.",
      "tickets": [
        295
      ]
    },
    {
      "title": "MidBarGraphSwapTests no longer fail when a bar-ahead test runs beside them",
      "version": "2026.8.282",
      "date": "2026-09-26",
      "area": "Build",
      "type": "Internal",
      "userFacing": false,
      "details": "The mid-bar graph swap tests need each bar rendered span by span, but BarAhead.PreferenceEnabled is process-wide, and the bar-ahead test classes turn it on. Their [Collection(\"BarAhead\")] only ordered them among themselves, so a bar-ahead test running in parallel made bar 0 render whole, the swap waited for the bar line, and CI failed on MidBarSwaps == 0 (the d71be022 run). MidBarGraphSwapTests now joins that collection and resets the flag in its constructor and Dispose. Reproduced locally by running the classes together (3 of 6 failed); five runs after the fix passed. SampleClipRendererTests, which failed once on 2026-09-22, was checked with the flag forced on and passes, so it has no such dependency and stays parallel.",
      "tickets": [
        365
      ]
    },
    {
      "title": "Module editor: removed a stale doc comment on the preset browser",
      "version": "2026.8.282",
      "date": "2026-09-26",
      "area": "Modules",
      "type": "Internal",
      "userFacing": false,
      "details": "ModuleEditorView.OpenPresetPopup had two stacked summary blocks; the first described the toolbar dropdown the shared preset browser (#133) replaced. Removed it.",
      "tickets": [
        320
      ]
    },
    {
      "title": "Copies of a sample clip are linked: edit one and they all change",
      "version": "2026.8.281",
      "date": "2026-09-26",
      "area": "Sequencer",
      "type": "New feature",
      "userFacing": true,
      "details": "A sample clip copied with Ctrl+drag, Ctrl+D, paste or split is now another instance of the same clip, the way a pattern clip's copies share their pattern: changing its pitch, gain, pan, region, fitted length, algorithm or reverse in the Clip editor changes every copy. Fades stay each clip's own, and the editor marks them \"this clip only\"; position, length, trim and stretch are each clip's own too. The Clip editor's title says how many clips share what it is editing, \"Make unique\" in a sample clip's right-click menu gives it settings of its own, and selecting a sample clip keeps its copies at full strength while everything else dims. Songs are saved in format 38: sample clips in older songs each start out on their own, and songs converted from wave-bank patterns link the clips that came from the same pattern. The shipped demos were re-saved in the new format.",
      "tickets": [
        361
      ]
    },
    {
      "title": "A Stack or pattern explorer switched by a channel click comes up over the sequencer",
      "version": "2026.8.280",
      "date": "2026-09-26",
      "area": "UI",
      "type": "Bug fix",
      "userFacing": true,
      "details": "With the Stack or the pattern explorer floating over the sequencer, clicking a channel header switched it to that channel but left it hidden: the click landed on the sequencer, which came to the top over it. Now a panel a click retargets (the Stack, the pattern explorer, or the Clip editor a double-click points at a sample clip) comes up on top straight after, while the sequencer, which you clicked, keeps the keyboard, so Delete, S and copy/paste still act on the timeline. GustUI gained a way to raise a window without making it the active one (ModalWindowElement.RaiseWithoutActivating; drawn order and active order are now two separate sequences). No bug-board ticket: reported directly by the user."
    },
    {
      "title": "Control API: coordinates with a decimal point are rounded, and /tree reports the tooltip",
      "version": "2026.8.280",
      "date": "2026-09-26",
      "area": "Desktop",
      "type": "Bug fix",
      "userFacing": false,
      "details": "Every pointer verb (/click, /move, /mousedown, /mousemove, /scroll, /hover) and Lua's ezmuze.click answered a 500 'invalid format' for a coordinate written with a decimal point, even 60.0, because they were read with JsonElement.GetInt32 - and /tree reports fractional positions, so clicking what the tree just said failed. They are now rounded, as is every other number the executor reads (scroll delta, window geometry, action arguments), through one tested reader (ControlArgs). The hover tooltip has no size of its own, so /tree reported it as 0x0 with no text while it was on screen; it now reports where it was last drawn, its text, and visible:false while hidden, and /screenshot?name=tooltip crops to it (GustUI TooltipElement.DrawnBounds).",
      "tickets": [
        308,
        355
      ]
    },
    {
      "title": "Control API: POST /window focus works when another app has the foreground",
      "version": "2026.8.280",
      "date": "2026-09-26",
      "area": "Desktop",
      "type": "Bug fix",
      "userFacing": false,
      "details": "Focus was a plain SetForegroundWindow, which Windows refuses unless the calling process received the last input event - so it failed whenever the terminal or IDE driving the API was in front, which is always, and for a freshly launched instance too. The docs claimed being in-process exempted it; it does not. It now sends one empty input event (a zero-distance mouse move) first, falls back to AttachThreadInput + BringWindowToTop, and checks the OS's foreground window to decide success. /stats windowActive is now the OS's answer (it read true while another app held the foreground), with KNI's own flag reported as gameActive. Benchmark mode no longer re-grabs focus every frame, which with working focus would have taken the window from whoever was typing; it already disables the inactive-window sleep. /window also accepts op as an alias of action and names the valid actions when given a wrong one.",
      "tickets": [
        331,
        359
      ]
    },
    {
      "title": "Control API: /click by text prefers an exact match",
      "version": "2026.8.280",
      "date": "2026-09-26",
      "area": "Desktop",
      "type": "Improvement",
      "userFacing": false,
      "details": "A text lookup took the first depth-first element whose text CONTAINED the search, so {\"text\":\"Module\"} could hit the 'Module editor - Korben' window title rather than the Module menu. It now prefers an exact match (trimmed, ignoring case) on a visible element, then an exact match on a hidden one, then a visible substring match, then any; /tree?text= and /screenshot?text= resolve the same way.",
      "tickets": [
        317
      ]
    },
    {
      "title": "Control API: /key accepts key as well as keys",
      "version": "2026.8.280",
      "date": "2026-09-26",
      "area": "Desktop",
      "type": "Improvement",
      "userFacing": false,
      "details": "{\"key\":\"Escape\"} was refused with 'key requires non-empty text'. key is now an alias of keys, as a single name or an array; an empty request's error names both the keys and text forms, and an unknown key name is quoted back.",
      "tickets": [
        333
      ]
    },
    {
      "title": "Remote-control docs: a screenshot during a /move dwell shows the dwell",
      "version": "2026.8.280",
      "date": "2026-09-26",
      "area": "Desktop",
      "type": "Internal",
      "userFacing": false,
      "details": "docs/remote-control-api.md's Lua section said a screenshot taken during a /move dwell arrives after it. Since #213 reads are answered mid-gesture: measured, a screenshot sent 1.5 s into a 3 s dwell returned at 1.65 s. The behaviour is the useful one, so the doc was corrected rather than the code.",
      "tickets": [
        334
      ]
    },
    {
      "title": "Control API: one action list, and the MCP run_action tool is generated from it",
      "version": "2026.8.280",
      "date": "2026-09-26",
      "area": "Desktop",
      "type": "Improvement",
      "userFacing": false,
      "details": "The MCP run_action schema (no clip, window or pin fields, and a description naming a third of the actions) and the Node adapter's fixed action enum (which rejected clip_editor outright) had both drifted from what POST /action accepts. ControlActionCatalog is now the one list: every action with its description and arguments. The in-app MCP server generates run_action's name enum, description and argument schema from it, an unknown action is answered with every valid name, and ControlActionCatalogTests reads RunAction's own case labels and fails when the two disagree. The superseded Node adapter in tools/ezmuze-mcp is deleted; .mcp.json already pointed at the app's own /mcp.",
      "tickets": [
        345
      ]
    },
    {
      "title": "Control API: module_editor and module_ui find the built-in devices",
      "version": "2026.8.280",
      "date": "2026-09-26",
      "area": "Desktop",
      "type": "Bug fix",
      "userFacing": false,
      "details": "Opening a module by name searched only the factory catalog and the user's library, so the code-built devices the Stack's add menu offers (Basic synth, Wave generator, Wave bank, the legacy effects) could not be reached, and Bifrost - a store module since 2026-09-10 - failed with 'not found' on any machine that had not installed it. The built-in devices are now one table (FactoryDevices) shared by the add menu and the control API, and an uninstalled store module says so. The docs' example uses Korben.",
      "tickets": [
        299
      ]
    },
    {
      "title": "Control API: opening a module by name adds a real, undoable channel",
      "version": "2026.8.280",
      "date": "2026-09-26",
      "area": "Desktop",
      "type": "Bug fix",
      "userFacing": false,
      "details": "module_editor, module_ui and preset_browser on a module by name pushed an invisible 'preview: <module>' channel straight into the song: no undo, not drawn in the sequencer, the song not marked unsaved, and saved into the file next time. They now add the channel exactly as Add channel > With Module does - one undoable edit, named after the module, drawn, marking the song unsaved (no starter pattern) - and re-use it on the next call for the same module. module_editor also goes straight to the editor rather than building the panel first.",
      "tickets": [
        353
      ]
    },
    {
      "title": "Control API: actions that open a screen reply once it is drawn",
      "version": "2026.8.280",
      "date": "2026-09-26",
      "area": "Desktop",
      "type": "Improvement",
      "userFacing": false,
      "details": "module_editor, module_ui, preset_browser and clip_editor's open and edit now answer only after one more whole Update and the Draw after it, so a click sent the moment the reply arrives lands on the screen as it is laid out. An action runs inside Update, after GustUI's tree update, so a window it opens was not laid out until the next frame. clip_editor's reply now carries a current 'marked'. Verified: a click on the module editor's Add button sent straight after the action opened its menu 5 times out of 5.",
      "tickets": [
        322
      ]
    },
    {
      "title": "Control API: /transport seek moves the sequencer's playhead",
      "version": "2026.8.280",
      "date": "2026-09-26",
      "area": "Desktop",
      "type": "Bug fix",
      "userFacing": false,
      "details": "A seek went to the audio engine alone (on a stopped song, a play then a stop in the same frame, which the sequencer never saw), so the drawn playhead did not move and a script that sought to bar 5 and pressed S split wherever the playhead had been. Seek now goes through the sequencer's own SeekToBars, and /transport and /stats report the sequencer's playhead while stopped, so moving it with the ruler shows up there too.",
      "tickets": [
        300
      ]
    },
    {
      "title": "Clicking a clip selects just that clip; Shift or Ctrl adds more",
      "version": "2026.8.279",
      "date": "2026-09-26",
      "area": "Sequencer",
      "type": "Improvement",
      "userFacing": true,
      "details": "A plain click on a clip now selects it and deselects every other clip; it used to toggle the clip and leave the rest selected, so selections only ever grew. Shift+click or Ctrl+click adds a clip to the selection, and Shift+click or Ctrl+click on a clip that is already selected clears the whole clip selection. A double-click still leaves its clip selected, and no longer breaks up a multi-selection: a plain click on a clip that is already selected alongside others collapses the selection to it only once the double-click time has passed.",
      "tickets": [
        362
      ]
    },
    {
      "title": "Selecting a clip dims the clips that are not copies of it",
      "version": "2026.8.279",
      "date": "2026-09-26",
      "area": "Sequencer",
      "type": "New feature",
      "userFacing": true,
      "details": "While clips are selected, every clip that is not an instance of one of them is drawn at 90% opacity, easing over about 150 ms, so you can see at a glance where else the same thing plays. Instances are clips of the same pattern, or of the same pack loop whatever bar of it they play; a sample clip counts only as itself for now. With several different clips selected, anything that is an instance of any of them stays at full strength. With nothing selected, nothing is dimmed.",
      "tickets": [
        363
      ]
    },
    {
      "title": "Escape and Enter go to the window you clicked last, even under a pinned window",
      "version": "2026.8.278",
      "date": "2026-09-26",
      "area": "UI",
      "type": "Improvement",
      "userFacing": true,
      "details": "Escape and Enter reach a dialog only when it is the active window: the one you clicked or opened last, whose title bar is lit. They used to follow the window drawn on top, so with a module panel pinned to the front, opening Preferences from it and pressing Escape closed the panel rather than Preferences. Now the lit title bar always says where keys go: Preferences takes Escape, and once you click the pinned panel, Escape is the panel's. The beta sign-up screen keeps its dialog active while it is up."
    },
    {
      "title": "Preferences is in every File menu, not just the sequencer's",
      "version": "2026.8.278",
      "date": "2026-09-26",
      "area": "UI",
      "type": "Improvement",
      "userFacing": true,
      "details": "A module panel, the module editor and the panel designer now end their File menu with Preferences..., as the sequencer does. With a maximised module panel over the sequencer, Preferences used to be reachable only by bringing the sequencer back to the front first.",
      "tickets": [
        349
      ]
    },
    {
      "title": "A popped-out tab opens just below and right of the window it left",
      "version": "2026.8.278",
      "date": "2026-09-26",
      "area": "UI",
      "type": "Bug fix",
      "userFacing": true,
      "details": "Popping a tab out of a window used to centre the new window on the pointer, so it landed wherever along the tab strip the pop-out button happened to be, often well to one side. It now opens one title bar (30 px) down and right of the window it came from, on top of it with both title bars showing. A tab popped out of a maximised window opens maximised; one popped out of a docked window opens floating, off the corner of the docked area.",
      "tickets": [
        346
      ]
    },
    {
      "title": "Menus widen to fit long entries, and the window list names the sequencer by its song",
      "version": "2026.8.278",
      "date": "2026-09-26",
      "area": "UI",
      "type": "Bug fix",
      "userFacing": true,
      "details": "Every menu opened from a window's menu bar was a fixed 300 px wide, so the View menu's window list cut long window titles short. A menu is now as wide as its longest entry, from 300 px up to 640 px and no wider than the room left on screen. The sequencer is listed as \"Sequencer - <song>\" rather than its full title with the author and tempo.",
      "tickets": [
        351
      ]
    },
    {
      "title": "Windows are no longer see-through",
      "version": "2026.8.278",
      "date": "2026-09-26",
      "area": "UI",
      "type": "Bug fix",
      "userFacing": true,
      "details": "The sequencer, the Stack, module panels and the panel designer painted their window background slightly transparent, a leftover from when only the background video sat behind them. Now that windows overlap each other, a view with a transparent body let the windows behind show through: the pattern explorer merged into a module panel's window showed the panel behind it, and maximised, the whole sequencer. All four are now fully opaque.",
      "tickets": [
        298
      ]
    },
    {
      "title": "Dragging a window no longer merges it into one you cannot see",
      "version": "2026.8.278",
      "date": "2026-09-26",
      "area": "UI",
      "type": "Bug fix",
      "userFacing": true,
      "details": "A dragged window merged as a tab into any window whose title bar was under the pointer, even one hidden behind the maximised sequencer, where it then vanished. It now merges only into the topmost window under the pointer, when the pointer is on that window's title bar, and the window it joins comes to the top and takes the keyboard.",
      "tickets": [
        357
      ]
    },
    {
      "title": "Removed the unused FloatAboveModalTier window setting",
      "version": "2026.8.278",
      "date": "2026-09-26",
      "area": "Core",
      "type": "Internal",
      "userFacing": false,
      "details": "ModalWindowElement.FloatAboveModalTier stopped doing anything when every window came to share one stack. The property is removed from GustUI along with its 16 assignments in the app, and the comments and design-guide rules that relied on it are reworded or marked superseded. DepthCeiling stays, for the beta gate's sign-in dialog.",
      "tickets": [
        348
      ]
    },
    {
      "title": "The module editor opens with the whole graph fitted and centred",
      "version": "2026.8.277",
      "date": "2026-09-26",
      "area": "UI",
      "type": "Bug fix",
      "userFacing": true,
      "details": "Opening a module in the module editor showed its graph drawn tiny in the top-left corner of the window, with the scrollbars off to one side, because the view was fitted to the window's size on its very first frame, before the window had grown to fill the screen. The editor now refits every time the window changes size until you move the view yourself (a pan, the wheel, a scrollbar, a zoom, Home or Ctrl+0); after that a resize leaves your view alone. Fit (Home, and the Fit button) now also centres the graph in the room it leaves, so a small module sits in the middle of the canvas instead of in the top-left corner.",
      "tickets": [
        328
      ]
    },
    {
      "title": "Modulation routings added from a panel no longer land on top of other components",
      "version": "2026.8.277",
      "date": "2026-09-26",
      "area": "Modules",
      "type": "Bug fix",
      "userFacing": true,
      "details": "Dragging a modulation source onto a knob adds a routing component to the module's graph. It was placed a fixed step to the right of the knob, whatever was already there, so in the module editor it covered other components and their ports. It now goes beside the knob when there is room, and otherwise straight down below whatever is in the way. A modulation source added from a panel is placed below the bottom of the lowest component, rather than below its top. Placement uses each component's real height and a generous width (ModuleFootprint), since the audio layer has no font to measure names with.",
      "tickets": [
        326
      ]
    },
    {
      "title": "Korben's graph ships tidy: the mute and solo switches no longer cover the Mute / solo component",
      "version": "2026.8.277",
      "date": "2026-09-26",
      "area": "Modules",
      "type": "Bug fix",
      "userFacing": true,
      "details": "In Korben's shipped graph the ten Band mute and solo switches sat on top of the Mute / solo component and hid most of its Mute and Solo rows and their ports, and the all-pass stages overlapped their neighbours. Korben is now built with the same layout Clean Up (Balanced) gives it, so a newly added Korben opens tidy. The Korben in The Dark demo was cleaned up and saved the same way. Korben devices already in your own songs keep their layout; Clean Up (Balanced) tidies them. A new test checks that no factory module ships components on top of each other; it found three more (Capacitor, Chip, Synth kit), filed as #360. The control API's module_editor action can now open the editor on a device already in the song (\"channel\"), which is how the demo was edited.",
      "tickets": [
        329
      ]
    },
    {
      "title": "Select a wire in the module editor by clicking anywhere along it",
      "version": "2026.8.277",
      "date": "2026-09-26",
      "area": "UI",
      "type": "Improvement",
      "userFacing": true,
      "details": "A wire could only be selected by clicking its small scaler square near its input. Now a click anywhere along the wire, curved or straight, selects it and draws it highlighted, and Delete removes it. A double-click on the wire opens its scaler, as the square still does, and a right-click offers Delete wire. Escape lets go of a selected wire before it closes the editor. A click on a component is still the component's, even where a wire runs underneath it.",
      "tickets": [
        314
      ]
    },
    {
      "title": "Straight wires steer around other components and keep their spacing",
      "version": "2026.8.277",
      "date": "2026-09-26",
      "area": "UI",
      "type": "Improvement",
      "userFacing": true,
      "details": "With Straight wires on, a wire's vertical runs and return runs only avoided the two components it connected, so they could run straight across unrelated components or right along their edges. They now move to the nearest clear column or row, keeping half a lane clear of other components, as a group so a stereo pair keeps its gap, and never more than six lanes out of their way. The runs straight out of an output and into an input stay at their ports' height. A pair of wires looping back through a narrow gap between two components used to squeeze together into one thick line; a gap too narrow for the whole group at full spacing now sends it under both components instead. The Inputs and Outputs banks' rows are now 22 px apart, the same as a component's port rows (they were 34), so wires into and out of the banks keep one even gap. Routes are only recalculated when something moves.",
      "tickets": [
        324,
        323,
        325
      ]
    },
    {
      "title": "Double-clicking a selected clip keeps it selected",
      "version": "2026.8.276",
      "date": "2026-09-26",
      "area": "Sequencer",
      "type": "Bug fix",
      "userFacing": true,
      "details": "Double-clicking a clip to open it (a pattern clip in the piano roll, a sample clip in the Clip editor) now always leaves it selected. The first click of the pair toggled the selection like any click, so a clip that was already selected came back deselected, breaking up a multi-clip selection. Clip double-clicks also use the app's standard double-click interval now, the same one the channel headers use.",
      "tickets": [
        343
      ]
    },
    {
      "title": "The Clip editor's mark no longer sits under the clip's name, and goes when the editor closes",
      "version": "2026.8.276",
      "date": "2026-09-26",
      "area": "Sequencer",
      "type": "Bug fix",
      "userFacing": true,
      "details": "The clip the Clip editor is on carries an accent bar along its foot. Its name label used to be drawn across that bar; on the edited clip the label now steps up to sit just above it. The bar also stayed on the clip after the Clip editor was closed, because hiding it shrank only its dark keyline and not the bar inside; both are hidden now.",
      "tickets": [
        342,
        356
      ]
    },
    {
      "title": "Double-clicking an empty channel's header opens its Stack",
      "version": "2026.8.276",
      "date": "2026-09-26",
      "area": "Sequencer",
      "type": "Improvement",
      "userFacing": true,
      "details": "A double-click on a channel header opens the channel's instrument. A channel with no devices has none, so the double-click used to do nothing at all, and even cancelled the Stack its first click would have opened. It now opens that channel's Stack straight away, whatever the 'Open Stack on channel click' preference says, and the status bar points at the [+] that adds an instrument.",
      "tickets": [
        337
      ]
    },
    {
      "title": "Escape closes an open menu without closing the window behind it",
      "version": "2026.8.276",
      "date": "2026-09-26",
      "area": "UI",
      "type": "Bug fix",
      "userFacing": true,
      "details": "Pressing Escape to dismiss a menu used to close the window it came from as well: a module panel's View menu took the panel with it, and the module editor's Module menu closed the whole editor. An open menu now takes the keys it uses (Escape, Enter and the arrow keys) before anything else sees them, so one Escape closes just the menu and the next closes the window. For the same reason, Enter on a menu item no longer also opens the piano roll on a selected clip, and a dropdown inside a dialog can be dismissed with Escape without cancelling the dialog.",
      "tickets": [
        350,
        330
      ]
    },
    {
      "title": "Escape only reaches the window on top",
      "version": "2026.8.276",
      "date": "2026-09-26",
      "area": "UI",
      "type": "Bug fix",
      "userFacing": true,
      "details": "Escape and Enter used to go to the frontmost DIALOG even when another window covered it, so with the module editor open over the New song dialog, Escape closed the hidden dialog and nothing visible changed. Now only the window on top gets them: a window in front of a dialog, including one pinned to the front, shields it, and the key goes to that window instead. Clicking the dialog back to the top gives it the keys again. The control API's module_editor action also refuses (409, naming the dialog) while a dialog is open, rather than opening the editor over it.",
      "tickets": [
        335
      ]
    },
    {
      "title": "Pin a window to the front or the back from its title bar",
      "version": "2026.8.275",
      "date": "2026-09-25",
      "area": "UI",
      "type": "New feature",
      "userFacing": true,
      "details": "Every window now has a pin button beside its maximise button (on the active tab of a tabbed window). Click it to choose Normal, Pin to front (always on top) or Pin to back (always behind). A window pinned to front stays above every normal window, the sequencer included, however recently that was clicked; one pinned to back stays below them. Among windows with the same pin, the one you clicked last is on top. A pinned window's pin turns blue with a small up or down arrow, and its tooltip says which. The pin belongs to the whole window, is hidden while the window is docked, and lasts until you close the app."
    },
    {
      "title": "View lists every open window, so you can bring any of them back to the top",
      "version": "2026.8.275",
      "date": "2026-09-25",
      "area": "UI",
      "type": "New feature",
      "userFacing": true,
      "details": "The View menu of every window with a menu bar now ends with a list of the open windows by title: module panels, Preferences, the file browser, the Stack, the pattern explorer, the Clip editor and so on. Choosing one brings it to the top and makes it the active window; choosing a tab of a tabbed window selects that tab. The active window is ticked, and pinned windows say so. A window whose menu bar had no View menu, such as a module panel, now has one holding the list."
    },
    {
      "title": "Every window comes to the top when you click or open it",
      "version": "2026.8.275",
      "date": "2026-09-25",
      "area": "UI",
      "type": "Bug fix",
      "userFacing": true,
      "details": "All windows now share one stack: floating panels, tabbed and docked windows, dialogs, Preferences, the file browser, the preset browser, the pack store and maximised windows. Whichever you clicked, opened or activated last (from a menu or a shortcut too) is on top, so clicking a panel behind the file browser brings the panel forward, and opening the file browser again brings it back. Choosing Preferences while it is already open now brings that window to the top instead of opening a second one. The sequencer is treated the same, so clicking it brings it forward over floating windows; View lists every open window to bring them back, and a window can be pinned to stay in front. Tooltips, menus, dropdowns, drag previews and notifications still appear above every window. A window opened by a click, such as a module panel from a double-click on a channel header, arrives on top of the window you clicked. This replaces the earlier rule that kept dialogs above panels and maximised windows below them.",
      "tickets": [
        301
      ]
    },
    {
      "title": "Window tabs are sized to their titles, and the active tab stands out",
      "version": "2026.8.274",
      "date": "2026-09-25",
      "area": "UI",
      "type": "Improvement",
      "userFacing": true,
      "details": "Tabs in a window's tab strip used to share its whole width, so the Stack and the pattern explorer tabbed together at the bottom of the screen were two 1278px tabs that read as two separate windows, and the active tab differed from the rest only by a faint gradient. Each tab is now as wide as its title and its buttons, up to 240px, and long titles shrink with \"...\" when space runs short (never below 80px). The rest of the strip is still the title bar: drag it to move, dock or merge the window. The active tab has a stronger accent fill and a 3px underline across its whole width; the other tabs are dimmed, with muted titles.",
      "tickets": [
        341
      ]
    },
    {
      "title": "The window you click comes to the front, and the sequencer stays behind floating panels",
      "version": "2026.8.274",
      "date": "2026-09-25",
      "area": "UI",
      "type": "Bug fix",
      "userFacing": true,
      "details": "Clicking a floating window that sat behind another gave it the active title-bar shading but left it behind, because windows at the same depth were drawn in the order they were opened. They are now drawn in the order they were last clicked, and only the window the click lands on comes forward. Windows now stack in three bands: the sequencer and anything else that fills the available space (a maximised piano roll, a maximised panel) always stays behind floating panels however often you click it; panels such as the Stack, the explorer and module panels come forward in the order you click them; and dialogs such as the file browser stay above every panel, even when you click the panel behind one.",
      "tickets": [
        301
      ]
    },
    {
      "title": "Dragging a tabbed window onto another window merges it, instead of hiding it",
      "version": "2026.8.274",
      "date": "2026-09-25",
      "area": "UI",
      "type": "Bug fix",
      "userFacing": true,
      "details": "Dragging a window with two or more tabs by one of its tabs (for example the Stack and the pattern explorer docked together at the bottom) onto a panel's title bar showed the merge preview, but letting go did not merge: the window came loose and landed hidden behind the panel. The release went to the tab, which did nothing with it. Releasing a drag started on a tab, or on the empty part of the tab strip, now merges the window as the preview promised, and docks it when it was held at a screen edge, exactly like a drag on an ordinary title bar.",
      "tickets": [
        296
      ]
    },
    {
      "title": "Tooltips go away when you click or drag",
      "version": "2026.8.274",
      "date": "2026-09-25",
      "area": "UI",
      "type": "Bug fix",
      "userFacing": true,
      "details": "After dragging the pattern explorer by its title bar, its tooltip could stay on screen through clicks and key presses until the mouse moved. Starting a drag replaced the window's hover-exit handler for good, which threw away the tooltip's own. Dragging no longer does that, any mouse press and the start of any drag now hide the tooltip, and the explorer's tooltip belongs to its content rather than its window, so it keeps working after the explorer is merged into another window as a tab.",
      "tickets": [
        297
      ]
    },
    {
      "title": "Sample clips have their own Clip editor window: double-click a clip to edit it",
      "version": "2026.8.273",
      "date": "2026-09-25",
      "area": "Sequencer",
      "type": "New feature",
      "userFacing": true,
      "details": "A sample clip's properties (region, fitted length, pitch, keep pitch, algorithm, formants, gain, pan, fades and reverse) used to take over the pattern explorer's Patterns tab whenever sample clips were selected. They now have a window of their own, the Clip editor. Double-click a sample clip to open it on that clip; double-click another to move it there. Selecting, clicking or dragging clips never changes what it edits, and while it is open the clip it is editing carries an accent bar along its foot, separate from the white selection outline. Like the Stack and the pattern explorer it docks at the bottom (it joins them as a tab), can be dragged out, docked right or popped out, and has a toolbar button and View > Toggle clip editor. The pattern explorer now always shows the selected channel's patterns, so it can no longer point at a clip on a different channel from the Stack after a clip drag (#312).",
      "tickets": [
        336,
        312
      ]
    },
    {
      "title": "Double-clicking a channel header opens only its instrument, not the Stack as well",
      "version": "2026.8.272",
      "date": "2026-09-25",
      "area": "Sequencer",
      "type": "Bug fix",
      "userFacing": true,
      "details": "Double-clicking a channel header opened the channel's instrument panel, but its first click had already opened the Stack along the bottom. That also shrank every row, so the second click could land on a different channel and open that channel's panel instead. A double-click now opens the instrument panel only. A single click still opens the Stack (when Preferences say so), after a short wait that tells a click from the first half of a double-click. The same goes for the pattern explorer when it is set to open on a channel click. The wait applies only to opening a closed Stack: selecting the channel and moving an open Stack to it still happen at once.",
      "tickets": [
        302
      ]
    },
    {
      "title": "Space in a module panel plays from the playhead, like Space in the sequencer",
      "version": "2026.8.272",
      "date": "2026-09-25",
      "area": "Sequencer",
      "type": "Bug fix",
      "userFacing": true,
      "details": "Pressing Space while a floating module panel had the keyboard started the song from the channel's first clip (or bar 0), wherever the sequencer's playhead was. Space in the sequencer plays from the playhead. Since panels got their own keys, the panel's Space was the only one that fired, so its old rule became what Space did. A panel's Space now does exactly what the sequencer's does: it plays from the playhead and pauses where it stands.",
      "tickets": [
        304
      ]
    },
    {
      "title": "A Stack device card no longer draws \"+N more\" under the add button",
      "version": "2026.8.272",
      "date": "2026-09-25",
      "area": "UI",
      "type": "Bug fix",
      "userFacing": true,
      "details": "In a short Stack, a device with more controls than its card could show printed \"+N more\" past the card's right edge, where the round + add button sits on top of it. The card set a column aside for that count but was sized without it. The card now includes the column, so the count sits inside it. When a card has room for only one column, the count takes the last control's place instead.",
      "tickets": [
        307
      ]
    },
    {
      "title": "The channel header no longer shows an out-of-date \"click to open its Stack\" tooltip",
      "version": "2026.8.272",
      "date": "2026-09-25",
      "area": "Sequencer",
      "type": "Bug fix",
      "userFacing": true,
      "details": "Hovering an empty part of a channel header showed \"<name> — click to open its Stack (drag to reorder)\", and the master's said \"Click to open the master's Stack\". Neither described a click any more: a click selects the channel, and opens the Stack only when Preferences say so. The header also had three different tooltips depending on where the pointer rested. The row-wide one is gone. The channel name still shows its full name on hover, which matters for a name the gutter cuts short, and the voices strip along the bottom keeps its own explanation.",
      "tickets": [
        310
      ]
    },
    {
      "title": "Right-clicking a channel header now moves an open Stack to that channel",
      "version": "2026.8.272",
      "date": "2026-09-25",
      "area": "Sequencer",
      "type": "Bug fix",
      "userFacing": true,
      "details": "Right-clicking a channel header (to open its menu) selected the channel and highlighted it, but an open Stack went on showing the channel selected before it, so the highlight and the Stack disagreed until you left-clicked. The right-click now selects through the same path as every other way of picking a channel, so an open Stack and pattern explorer follow it. It still never opens a closed Stack: only a left-click on the header does that.",
      "tickets": [
        309
      ]
    },
    {
      "title": "The 4-tap echo keeps its panel and presets after you add modulation to it",
      "version": "2026.8.271",
      "date": "2026-09-25",
      "area": "Modules",
      "type": "Bug fix",
      "userFacing": true,
      "details": "Dragging a modulation source onto a 4-tap echo knob, then closing and reopening its panel, showed \"No custom UI yet\" in place of its knobs, and its presets dropped out of the preset browser. Nothing was deleted. The echo was the one factory module whose devices did not carry their module's id, so its panel and presets were filed under the shape of its graph, and each routing added to that graph changed the shape. Echoes now carry the id like every other factory module, so a routing no longer changes where the panel and presets are found. Echoes in songs saved before this get the id when the song opens (no format change). A panel or presets you saved for the echo under the old key move to the new one on startup, and an edited panel is kept, not replaced by the factory one. The sound is unchanged.",
      "tickets": [
        327
      ]
    },
    {
      "title": "Straight wires keep an even gap, and wires that loop back no longer pile up on each other",
      "version": "2026.8.270",
      "date": "2026-09-25",
      "area": "Modules",
      "type": "Bug fix",
      "userFacing": true,
      "details": "With Straight wires on in the module editor, the gap between two parallel wires changed along their length: it matched the port rows where the wires ran across, but the vertical runs sat a fixed 6 px apart, and a stereo pair could cross itself at a corner. Wires that loop back to a component on the left fared worse: they had no lanes at all, so both wires of a pair ran down the same line, back along the same line and into the input on top of each other. Parallel wires are now spaced at the port row pitch everywhere: across, down, back and in. They also stay nested, with each wire keeping its side of the bundle through every turn, so a pair never crosses itself. Wires that loop back get lanes for all three of their runs, and wires from one output still share a single trunk that branches. When the gap between two components is too narrow for a pair at full spacing, the return runs close up to fit rather than running through a component. The routing lives in GustUI's OrthogonalWireRoute.AssignRoutes, which has unit tests for each case.",
      "tickets": [
        321
      ]
    },
    {
      "title": "Clean Up in the module editor puts controls just before what they feed, lines the wires up, and offers three methods",
      "version": "2026.8.269",
      "date": "2026-09-25",
      "area": "Modules",
      "type": "Improvement",
      "userFacing": true,
      "details": "Clean Up used to place every component as early as it could go, so a component with no inputs of its own, such as Korben's mute and solo switches, landed in the first column even when the only thing it fed was near the far end, with a wire running the width of the graph. The Clean Up button now opens a menu of three methods, and the one you used last is ticked and remembered. Balanced, the default, lays the signal path out forwards from the inputs and puts every control one column before the first thing it feeds, so Korben's switches now sit beside the band gains, right next to Mute / solo. Forwards is the old behaviour. Backwards places everything as late as it can go. All three now order each column by the ports they feed, so the switches come out in the same order as Mute / solo's inputs, and then move each component to the height where its wires run level, which makes straight wires really straight. A loop of wires no longer collapses into the first column; it lays out as the chain it nearly is. Module > Clean up layout offers the same three methods. Internally the column assignment is now public and tested directly, and its stale comment is fixed (#319).",
      "tickets": [
        318
      ]
    },
    {
      "title": "The module editor can draw its wires straight, with rounded corners",
      "version": "2026.8.268",
      "date": "2026-09-25",
      "area": "Modules",
      "type": "New feature",
      "userFacing": true,
      "details": "A new toggle in the module editor's toolbar (also Module > Straight wires) swaps the curved wires for straight horizontal and vertical runs joined by small rounded corners, like a workflow diagram. Wires from the same output share one trunk and branch off it, and wires that would share a column run side by side instead of on top of each other. A wire running backwards, to an input left of its output, loops round between its two components, or under them when they overlap. The wire colours, dashed content wires, selection and scaler squares work exactly as before, and a wire you are dragging follows the same style. The setting is remembered and applies to every module; curves remain the default.",
      "tickets": [
        311
      ]
    },
    {
      "title": "Clicking a channel's lane or any clip on it now switches the open Stack and pattern explorer to that channel",
      "version": "2026.8.267",
      "date": "2026-09-25",
      "area": "Sequencer",
      "type": "Improvement",
      "userFacing": true,
      "details": "Only a click on a channel's header used to select it, so with the Stack or the pattern explorer open, clicking a clip or an empty stretch of another channel's lane left both panels showing the old channel. Now a channel is selected by a click on its header, a still click on empty space in its lane, any press on one of its clips (a click, a move, a resize, a fade or a slip), or a click on one of its automation lanes. Each of these points whichever of the Stack and the explorer are open at that channel. A clip on a merged group row selects the child channel it belongs to. Dragging on empty space to pan the timeline selects nothing, and a clip dragged to another channel leaves the selection where it started. Only the header opens a closed panel, and only as Preferences > 'Open Stack on channel click' and 'Open pattern explorer on channel click' say. Clicking an automation lane used to follow those preferences too; now it only retargets panels that are already open.",
      "tickets": [
        306
      ]
    },
    {
      "title": "The Stack opens again after you close it with its X",
      "version": "2026.8.266",
      "date": "2026-09-25",
      "area": "Sequencer",
      "type": "Bug fix",
      "userFacing": true,
      "details": "Closing the Stack with its close button cleared which channel it was showing, but the sequencer kept hold of the closed window. After that, clicking a channel or choosing View > Toggle Stack pointed the dead window at the new channel instead of opening a new Stack, so it never came back until the app was restarted. The close now forgets the window too, so the next channel click or toggle opens a fresh Stack.",
      "tickets": [
        303
      ]
    },
    {
      "title": "Clicking a window's title bar no longer snaps it back into the window underneath",
      "version": "2026.8.265",
      "date": "2026-09-25",
      "area": "UI",
      "type": "Bug fix",
      "userFacing": true,
      "details": "A popped-out window opens exactly on top of the one it came from, so its title bar sits over that window's title bar. A single click on it, to focus it or to start moving it, merged it straight back in as a tab. Merging now needs a real drag: the pointer has to move a few pixels after pressing before a merge can happen, which is the same rule undocking already followed. Dragging a window onto another's title bar still merges them exactly as before. Fixed in GustUI (ModalWindowElement), so it applies to every window.",
      "tickets": [
        292
      ]
    },
    {
      "title": "Lua scripting on the Mac and Linux heads",
      "version": "2026.8.264",
      "date": "2026-09-25",
      "area": "Desktop",
      "type": "Internal",
      "userFacing": false,
      "details": "POST /script (and the MCP run_script tool) answered \"scripting is desktop-only for now\" on the Mac and Linux builds, because only the two Windows heads defined EZLUA and referenced MoonSharp. Both now do. MoonSharp is pure C#, so nothing platform-specific was holding it back. The web head has no remote-control API, so it has no script endpoint either."
    },
    {
      "title": "Mac: Restart to update now installs the update instead of offering it again",
      "version": "2026.8.264",
      "date": "2026-09-25",
      "area": "Desktop",
      "type": "Bug fix",
      "userFacing": true,
      "details": "On a Mac, an app that macOS was still treating as a fresh download ran from a read-only copy that macOS made of it (App Translocation). Restart to update tried to replace that copy, failed, reopened the old version, and the welcome screen offered the same update again. The updater now finds the app where it actually is and replaces that, and the updated app is no longer run from a read-only copy. If the app's real location can't be found, the notice keeps its Download link instead of offering an update that can't install."
    },
    {
      "title": "Space plays and Escape closes one thing while a device panel is open; keys follow the window you clicked",
      "version": "2026.8.263",
      "date": "2026-09-24",
      "area": "UI",
      "type": "Bug fix",
      "userFacing": true,
      "details": "A floating device panel (double-click a channel header) shared the sequencer's keyboard shortcuts, so both answered every key. Space started playback and stopped it on the same press, so it seemed to do nothing. Escape closed the panel and also cleared the sequencer's selection. Ctrl+Z undid two edits instead of one. With the panel designer open, Delete removed the selected controls and the selected clips in the sequencer. Each window now has its own keys, and the window you clicked last has the keyboard: the panel's keys while you work in the panel, the sequencer's (Delete, S, copy and paste) once you click back into it. A dialog such as Save preset keeps the keyboard until it closes, wherever you click.",
      "tickets": [
        293
      ]
    },
    {
      "title": "Smaller updates: only the parts of ezmuze studio that changed are downloaded",
      "version": "2026.8.261",
      "date": "2026-09-24",
      "area": "Desktop",
      "type": "Improvement",
      "userFacing": true,
      "details": "Updating in place now downloads just the difference from the version you have, instead of the whole app, when you are updating from a recent version. On Windows this works from the first update; on Mac and Linux the first update after installing downloads the whole app once, and later ones use the smaller download. Behind it, each platform now has one update feed per channel on the CDN, listing recent versions and the difference between each and the next. The update-in-place built on 24 September before this change reads the old layout, so a copy of that one build needs its next update downloaded by hand."
    },
    {
      "title": "Update in place: ezmuze studio downloads its update and restarts into it",
      "version": "2026.8.261",
      "date": "2026-09-24",
      "area": "Desktop",
      "type": "New feature",
      "userFacing": true,
      "details": "When a newer version is available, the notice on the welcome screen and Help > Check for updates now offer Update instead of a download link on Windows, Linux and Mac. It downloads in the background, showing its progress, and then offers Restart to update, which asks about unsaved work exactly as quitting does, installs the new version and opens it again. Closing the app instead is fine too: a downloaded update installs the next time ezmuze studio starts. A copy that wasn't installed normally (run from a folder or straight off the disk image) still gets the Download link. On Linux, that Download link also now finds the AppImage rather than a file that no longer existed."
    },
    {
      "title": "The Mac download is now a disk image: drag the app to Applications",
      "version": "2026.8.260",
      "date": "2026-09-24",
      "area": "Desktop",
      "type": "Improvement",
      "userFacing": true,
      "details": "The Mac version used to download as a .tar.gz, which unpacked the app into your Downloads folder and left you to move it. It is now a .dmg: open it and a window shows ezmuze studio beside a shortcut to your Applications folder, with an arrow between them, so installing is one drag. The disk image is signed and notarized by Apple like the app inside it, so it opens without a security warning. The in-app \"new version available\" notice now points at the disk image too."
    },
    {
      "title": "The ezmuze icon everywhere: browser tab, Dock, taskbar and title bar",
      "version": "2026.8.260",
      "date": "2026-09-24",
      "area": "UI",
      "type": "Bug fix",
      "userFacing": true,
      "details": "Several places showed the graphics engine's KNI logo instead of ours. The web version's browser-tab icon was KNI's; the Windows (OpenGL) and Linux versions showed it in the title bar and taskbar while running; and on the Mac it replaced the app's icon in the Dock. All of them now use the ezmuze mark. The Mac icon is also sharp at every size now, up to the Dock's largest (it was built from a 256-pixel image before), and every icon is generated from one vector original, the mark on the web's loading screen, which was itself drawn with its wave upside down and is fixed too."
    },
    {
      "title": "\"Open maximised\" now works on Mac",
      "version": "2026.8.260",
      "date": "2026-09-24",
      "area": "Desktop",
      "type": "Bug fix",
      "userFacing": true,
      "details": "Preferences > General > Open maximised is on by default, but on macOS the window-management layer was a placeholder that did nothing, so the app always opened at its default size. The Mac build now drives its window through SDL: it opens filling the screen (menu bar and Dock left clear) when the preference is on. The remote-control API's window commands (maximise, minimise, restore, move, resize, and GET /window) work on macOS too, in the same pixel units as on Windows."
    },
    {
      "title": "Sharp text and graphics on Retina Macs",
      "version": "2026.8.260",
      "date": "2026-09-24",
      "area": "Desktop",
      "type": "Bug fix",
      "userFacing": true,
      "details": "On a Retina display the Mac version drew everything at half resolution and macOS stretched it to fill the window, so text looked pixelated and edges were soft. The window is now created high-DPI and the app renders at the display's full resolution, scaling its interface by the Mac's own backing scale (2x on Retina, 1x on a standard monitor, followed live when the window moves between them). Preferences > Display scale on Automatic follows it; a custom scale set while the old build looked blurry will now look smaller than intended, so set it back to Automatic or re-pick it. The window opens at 1280 x 800 points as intended, where before it came out at half that on Retina."
    },
    {
      "title": "Escape on a module panel merged into another window closes just the panel",
      "version": "2026.8.260",
      "date": "2026-09-24",
      "area": "UI",
      "type": "Bug fix",
      "userFacing": true,
      "details": "A module panel merged into another window as a tab, or popped out of one, closed that whole window on Escape, with every tab in it. Merged into the sequencer's window, that took the sequencer down and left an empty screen with the song still loaded behind it. Escape, the panel's File > Close and anything else that closes the panel now close only the panel's tab, and the window only when the panel was the last thing in it. The panel designer, the piano roll, the drum lanes, the module editor and the pickers had the same flaw in the window they built themselves: once another view had been merged into it, their Escape closed that view too. They all close just their own tab now. The X on the title bar of a window a view built for itself still closes that window.",
      "tickets": [
        291
      ]
    },
    {
      "title": "Editors keep track of the window they are in after a pop-out or a merge",
      "version": "2026.8.260",
      "date": "2026-09-24",
      "area": "UI",
      "type": "Bug fix",
      "userFacing": true,
      "details": "The piano roll, drum lanes, module editor, wave picker and content picker kept a copy of the window they were opened in and never updated it, so after a pop-out or a merge they were talking to a window they had left. They now ask for the window they are in when they need it. What that fixes: the wave picker, opened as a tab of the sequencer, renamed the sequencer's window after itself whenever the song's edit history changed; it now renames its own tab, wherever it is. The Stack renamed the window it was merged into, the sequencer included, every time you picked another channel; it now renames its own tab. The pattern explorer, closed by the X of a tab in another window, could not be opened again for the rest of the session; it now reopens. And a piano roll or other editor popped out of the sequencer and merged back lost its keyboard shortcuts' scope on the way: closing it afterwards left the sequencer answering no keys at all. That scope now travels with it (GustUI).",
      "tickets": [
        290
      ]
    },
    {
      "title": "The menu bar always shows the menus of the tab on screen",
      "version": "2026.8.260",
      "date": "2026-09-24",
      "area": "UI",
      "type": "Bug fix",
      "userFacing": true,
      "details": "A tab with no menu of its own, such as the pattern explorer or the Stack, kept whatever menu the window had shown before it. Close a piano roll whose tab sat beside the pattern explorer and the piano roll's Pattern menu stayed on the bar, still clickable and with its shortcuts live, acting on an editor that had gone. Switching to the pattern explorer's tab showed the sequencer's File, Edit, View and Help. The menu bar now follows the active tab exactly, and a tab with no menu and no toolbar has no bar at all, so its content starts right under the tab strip. Fixed in GustUI, so every tabbed window works this way.",
      "tickets": [
        289
      ]
    },
    {
      "title": "Windows are named after the view that owns them, so the control API finds the right one",
      "version": "2026.8.259",
      "date": "2026-09-24",
      "area": "UI",
      "type": "Bug fix",
      "userFacing": false,
      "details": "A window's element name was fixed when it was built. Popping the sequencer out of its window left that window called sequencer-modal while it held a piano roll, and the sequencer arrived in a window called tab-popped-<guid>; a view popped out of the sequencer got a tab-popped name too. Anything looking a window up by name (the control API's /tree, name-targeted clicks and screenshots, Lua and MCP scripts) found the wrong window or none. A window's name now follows the view that owns it: its own view while that is in it, otherwise the view in its first tab. A popped-out view's window takes the name that view's own window would have had (pianoroll-modal, sequencer-modal, drumlanes-modal...), and the window it left is renamed after whoever it belongs to now. Done in GustUI (ModalWindowElement), so every tabbable window follows it; views that join a window as a tab pass their name along. Nothing in the app, the tools, the Lua helpers or the docs looked windows up by name, so nothing needed changing to keep working.",
      "tickets": [
        288
      ]
    },
    {
      "title": "Closing an editor's tab in a window it opened now closes the editor properly",
      "version": "2026.8.259",
      "date": "2026-09-24",
      "area": "UI",
      "type": "Bug fix",
      "userFacing": true,
      "details": "An editor in a window of its own (a piano roll, drum lanes or the module editor popped out of the sequencer, or a module panel) could have other windows merged into it as tabs. Closing the editor's own tab then took it off screen and told it nothing: it stayed alive with its listeners attached and its shortcuts registered, and a module panel's keys still showed under F1. It now closes exactly as it does from its window's X or Escape, and the window stays open with the tabs that are left. Closing it no longer takes the tabs merged into its window with it. A window whose own editor has gone, with several other tabs left in it, now closes those tabs with its X; before, the X did nothing. The panel designer, when it opened in a window of its own, gets the same fix, and once it has been merged into another window, closing it closes just its tab rather than that whole window. The control API has a new GET /shortcuts, which lists every screen whose shortcuts are still registered, so a view that failed to close can be seen from outside.",
      "tickets": [
        287
      ]
    },
    {
      "title": "Closing the sequencer's tab now closes the song properly, and asks first if it has unsaved changes",
      "version": "2026.8.258",
      "date": "2026-09-24",
      "area": "UI",
      "type": "Bug fix",
      "userFacing": true,
      "details": "With a module panel or another editor tabbed into the sequencer's window, the X on the sequencer's own tab took the sequencer off screen and nothing else. It never asked about unsaved changes. The sequencer carried on behind the window: its shortcuts still showed under F1, and the window's close button was still wired to it, so closing the panel left in that window closed the song. The sequencer's tab X now does exactly what closing the sequencer window does. If there are unsaved changes you're asked first, and Cancel leaves every tab where it was. Otherwise the song closes, the editors that belong to it close with it, and you're back on the welcome screen. The same holds wherever the sequencer's tab is: merged into a module panel's window, or popped out into a window of its own. Popping the sequencer out used to strand it: closing the window it left then stopped every shortcut working, and closing the song closed the wrong window. A window left holding only somebody else's tab now closes that tab with its X.",
      "tickets": [
        286
      ]
    },
    {
      "title": "Closing a window with a module panel tabbed into it now closes the panel too, instead of leaving its Save preset box behind",
      "version": "2026.8.257",
      "date": "2026-09-24",
      "area": "Modules",
      "type": "Bug fix",
      "userFacing": true,
      "details": "A module panel dragged into another window's tabs (the sequencer's, say) was never told when that whole window closed. Its content went with the window, but the panel itself stayed alive behind it with its shortcuts registered, and a Save preset box it had open stayed on screen over the welcome page. A window now tells every panel or editor tabbed into it that it is closing, the same way closing that one tab does, so each one tears down properly: hooks released, prompts closed. The panel also tears down at once when its tab or window goes, rather than a frame later. A frame later the sequencer it had been in was already gone, and the panel's close opened a fresh one over the welcome page. Closing just the panel's own tab already worked, with or without the prompt open, and still does.",
      "tickets": [
        284
      ]
    },
    {
      "title": "The Mac version opens and runs instead of closing straight away",
      "version": "2026.8.256",
      "date": "2026-09-24",
      "area": "Desktop",
      "type": "Bug fix",
      "userFacing": true,
      "details": "The macOS build installed and launched, then closed on its first frame with \"Shader Compilation Failed\". Every shader the desktop app draws with is modern OpenGL (GLSL 3.30), and the graphics layer underneath asked the system for OpenGL 2.1. Windows and Linux graphics drivers treat that as a minimum and hand back something newer, so the request never mattered there; macOS gives exactly what is asked for, and OpenGL 2.1 cannot compile those shaders. The Mac build now asks for the modern (Core Profile) OpenGL that every current Mac supports, and the graphics layer was taught the three things Core Profile does differently: how it lists its extensions, that it needs a vertex array object to draw, and that shapes drawn straight from memory (the visualiser uses these) have to go through a GPU buffer first. The whole app, the sequencer, and all eight visualiser effects, including the two raymarched ones, now run on Apple Silicon at the display's refresh rate.",
      "tickets": [
        285
      ]
    },
    {
      "title": "Installed desktop builds now use ezmuze studio's own mouse pointers",
      "version": "2026.8.256",
      "date": "2026-09-24",
      "area": "Desktop",
      "type": "Bug fix",
      "userFacing": true,
      "details": "The custom pointer set (resize arrows, crosshair, pointing hand and the rest) never reached an installed desktop build: the pointer image was missing from the files a build ships with, so every published Windows, Linux and Mac build quietly fell back to the system pointer. It only ever worked when running from a developer's checkout. It now ships, and a build without it fails rather than going out. Separately, the OpenGL builds loaded it at a moment when the graphics card could not be used yet, so even with the image present they fell back; it now loads once the app is ready to draw."
    },
    {
      "title": "Control API works on macOS and Linux; KNI builds for .NET 10",
      "version": "2026.8.256",
      "date": "2026-09-24",
      "area": "Build",
      "type": "Internal",
      "userFacing": false,
      "details": "The remote-control server added an IPv6 loopback prefix ([::1]) next to 127.0.0.1 to spare Windows clients a slow localhost fallback. Off Windows, .NET's managed HttpListener accepts that prefix and then fails the whole Start with \"Invalid port in prefix\", so the server never listened on macOS or Linux. The IPv6 prefix is now Windows-only. The macOS head now project-references the KNI fork's DesktopGL platform instead of the MonoGame.Framework.DesktopGL.9000 package (the fork carries the Core Profile work and vendors libSDL2/libopenal, which now reach the publish by themselves), and the fork's runtime projects gained net10.0 / net10.0-windows targets so every desktop head compiles against .NET 10 builds of KNI. build-head.sh no longer copies Mac dylibs out of the NuGet cache, and now fails the build if Contents/MacOS holds anything but the apphost and dylibs, the rule notarization depends on."
    },
    {
      "title": "Escape in a module panel's Save preset box now closes just the box, and the box can no longer be left behind",
      "version": "2026.8.255",
      "date": "2026-09-24",
      "area": "Modules",
      "type": "Bug fix",
      "userFacing": true,
      "details": "Presets > Save current as... on a module's panel opened a plain panel rather than a dialog, so it never had the keyboard. Escape went past it to the module panel, the panel closed, and the Save preset box was left floating over the sequencer with nothing behind it. It is now a proper dialog, the same one the module editor's prompts became: Escape cancels it and leaves the panel open, Enter saves, the panel's shortcuts (Space, Ctrl+Z) wait while it is up, the name arrives selected, and it closes with the panel however the panel is closed. The panel's Save shape as box, for a drawn LFO shape, had the same fault and got the same fix. A module panel merged into another window's tabs now also tears down properly when that tab's X closes it, instead of leaving its shortcuts behind.",
      "tickets": [
        284
      ]
    },
    {
      "title": "Escape in the module editor's Save as box now closes just the box, and the box can no longer be left behind",
      "version": "2026.8.254",
      "date": "2026-09-24",
      "area": "Modules",
      "type": "Bug fix",
      "userFacing": true,
      "details": "File > Save module as (Ctrl+Shift+S) opened a plain panel rather than a dialog, so it never had the keyboard. Escape went past it to the module editor, the editor closed, and the Save as panel was left floating over the sequencer with nothing behind it. It is now a proper dialog like the app's other prompts: Escape cancels it and leaves the editor open, Enter saves, the editor's shortcuts (Space, Delete, Ctrl+S) wait while it is up, and it closes with the editor however the editor is closed, including the editor tab's X. The name arrives selected, ready to type over. The editor's other hand-built prompts had the same fault and got the same fix: Save preset as, Rename port, Rename component, and a control's Readout settings. The wire scaler popover now closes with the editor too. Two underlying faults went with it. Closing a sequencer tab with its X never told the view in it, so a module editor, piano roll, drum lanes or picker closed that way left its shortcuts registered and its listeners attached. And a dialog that cleared keyboard focus as Escape closed it crashed the app on that keypress.",
      "tickets": [
        283
      ]
    },
    {
      "title": "Choosing a preset while the song plays no longer cuts out the audio or jumps the preset list back to the top",
      "version": "2026.8.253",
      "date": "2026-09-24",
      "area": "Modules",
      "type": "Bug fix",
      "userFacing": true,
      "details": "Picking a preset in the preset browser during playback froze the app for one to two seconds, the audio went silent for as long, and the list scrolled back to the top, so auditioning presets meant finding your place again after every click. There were two separate faults. The freeze was everything the click did on the thread that also feeds the sound card. It waited up to 1.6 s for the background bounce to finish a bar so it could reindex it. It recompiled the synth's graph (100-300 ms) while holding the lock the audio renders with. It resynced every clip on the timeline (about 200 ms), though a preset moves no clip. And it started re-rendering every pattern waveform on the channel at once. Now the bounce worker reindexes on its own thread, the new graph compiles in the background and takes over mid-bar with a 6 ms crossfade, a preset skips the timeline resync, and during playback the channel's waveforms follow the same rule as a knob move: the channel plays live and redraws once, 10 s after the last change. Pattern-waveform renders are also limited to 4 threads while the song plays, and run at full width when stopped. The scroll jump came from the browser rebuilding its whole 470-row list after every click. Now only the lit row changes, and rebuilds that do still happen (ticking, renaming, deleting) keep your scroll position. Measured on a 217-clip song, playing a Bifrost channel, three clicks in a 470-preset folder, before -> after: worst frame 1538 / 1585 / 802 ms -> 182 / 182 / 280 ms; output starved 1822 / 1619 / 837 ms -> 72 / 141 / 0 ms; waveform re-renders queued per click 34 -> 0 (then one catch-up of 34, which costs no audio); list scroll 288 px -> 0 every time, now 288 px -> 288 px. The remaining 70-140 ms on some clicks is garbage collection after the compile's 75 MB of allocation. The first preset picked on a channel playing from its cached pattern audio is heard from the next bar; later ones from the next few milliseconds. /stats has a new audio.outputStarvedMs, the only counter that sees this kind of dropout: the render-ahead underrun count read zero throughout.",
      "tickets": [
        282
      ]
    },
    {
      "title": "Ctrl+S saves the song instead of splitting clips",
      "version": "2026.8.252",
      "date": "2026-09-24",
      "area": "UI",
      "type": "Bug fix",
      "userFacing": true,
      "details": "In the sequencer, Ctrl+S split the clips under the playhead and never saved. Two things were wrong. A keyboard shortcut fired whenever its own modifiers were held and ignored any extra ones, so the plain S that splits also fired on Ctrl+S. And the shortcuts shown in the menus (File › Save's Ctrl+S, Save as's Ctrl+Shift+S, View › Full screen's F11) were only labels: nothing ever ran them. Now a shortcut fires only when exactly its modifiers are held, the right-hand Ctrl, Shift and Alt keys count the same as the left ones, and a shortcut shown in a menu bar runs that menu item unless the screen already binds the same keys. The F1 help lists the menu shortcuts too. Side effects: Ctrl+Shift+X in the sequencer now only cuts and closes the gap (it used to run the plain cut as well), and Ctrl+Shift+V the same for paste. Ctrl+Shift+Z still redoes everywhere, and now also in the module panel. Ctrl+Shift+arrows in the piano roll still move notes by a bar or an octave, and Ctrl+Shift+S in the module builder still opens Save as. Enter and Escape in a dialog now need no modifier held. Keys that only ever worked by accident with a modifier held, like Shift+Space, no longer do. Typing a shortcut with Ctrl or Alt held in the module builder or a panel no longer plays a note as well.",
      "tickets": [
        280
      ]
    },
    {
      "title": "Ctrl+drag a clip to copy it, and drag a selection of clips together",
      "version": "2026.8.251",
      "date": "2026-09-24",
      "area": "Sequencer",
      "type": "New feature",
      "userFacing": true,
      "details": "Hold Ctrl while dragging a clip on the timeline and it is copied rather than moved: the original stays where it was and a new clip lands where you let go. Ctrl can be pressed or released at any point in the drag, as in Ableton or REAPER, and the pointer and the clip under it show which one you will get before you let go. The copy plays the same pattern as the original, just like Ctrl+D and paste, so editing one changes both; Make unique splits them. Dragging a clip that is part of a selection now carries the whole selection with it, across rows as well as along them, and Ctrl+drag copies the whole selection. Either way it is one undo step. If any clip in the group would land on another clip or off the timeline, nothing moves. The copies come out selected, so you can Ctrl+drag again straight away.",
      "tickets": [
        281
      ]
    },
    {
      "title": "The file dialog has Rename, Duplicate, Move to and Delete buttons, and folders can be selected",
      "version": "2026.8.251",
      "date": "2026-09-24",
      "area": "UI",
      "type": "Improvement",
      "userFacing": true,
      "details": "The Open, Save and Import dialog's toolbar now has four more buttons after New folder: Rename, Duplicate, Move to and Delete. They do what the right-click menu does, to whatever is selected, so organising your songs no longer depends on knowing to right-click. Move to drops down the list of folders you can move into. The buttons are greyed out when nothing is selected, and when the place you are browsing can't do that action, such as a read-only source; Duplicate is always greyed out for a folder. Folders now work like they do in File Explorer: a single click selects a folder and a double click opens it, so a folder can be renamed, moved or deleted from the toolbar too. With a folder selected, the dialog's main button reads Open and goes into the folder, and Enter does the same. Only a file is ever opened, imported or saved over, and selecting a folder no longer puts its name in Save's file name box.",
      "tickets": [
        279
      ]
    },
    {
      "title": "Imported songs render their waveforms and pattern audio in seconds, not minutes",
      "version": "2026.8.250",
      "date": "2026-09-24",
      "area": "Performance",
      "type": "Performance",
      "userFacing": true,
      "details": "Opening a big imported song used to sit on “Rendering waveforms — N of 262” for as long as twenty minutes. Every pattern is played through its instrument once to draw its waveform and to give playback the audio it plays from, and on the REAPER project this was measured on (131 patterns, most of them converted Vital patches) that took 19 minutes from dropping the file to the last waveform. It now takes about 16 seconds on the same machine: 135 seconds with one render thread, 39 with four, 25 with eight, 19 with sixteen. Reopening the saved song shows every waveform as soon as it loads and has the pattern audio ready about 12 seconds later. What changed: patterns render on every core but two instead of on one thread, costliest first; a pattern's release tail carries on from its own render instead of rendering the whole pattern a second time and throwing it away; the profiler hooks meant for live playback no longer fire inside these renders (with the remote-control API on they were most of the cost, waiting on a lock shared with the game and audio threads); the sample player inside every Bifrost voice, a Lua script that on most patches has nothing to play, now runs as the equivalent C# instead of making gigabytes of garbage a second; the audio engine is optimised in development builds too; and an imported song's samples are decoded across the cores before the sequencer draws them, instead of one by one while the window was frozen. Nothing you hear has changed: every pattern, tail and waveform of that song, and every demo song, renders bit for bit as it did before.",
      "tickets": [
        278
      ]
    },
    {
      "title": "Typed-path tests build their example paths for the OS they run on",
      "version": "2026.8.249",
      "date": "2026-09-24",
      "area": "Build",
      "type": "Internal",
      "userFacing": false,
      "details": "TypedPathTests (#277) used Windows paths such as C:\\Songs. The release pipeline runs its tests on Linux, where C:\\Songs is correctly not a rooted path, so four tests failed and blocked the build for the file picker change. The tests now build rooted paths for the OS they run on. The product code was already right for each platform."
    },
    {
      "title": "The file picker fills its window, and has Back, Forward, Up and a path you can type",
      "version": "2026.8.249",
      "date": "2026-09-24",
      "area": "UI",
      "type": "Improvement",
      "userFacing": true,
      "details": "The open, save and import dialog now fits: the file list and the sources down the side reach all the way down to the footer and follow the window as it is resized, where the list used to stop well short and leave an empty band, and the list’s scrollbar now sits inside its border. A toolbar above the path has Back, Forward and Up buttons that work the way File Explorer’s do. Back and Forward remember every folder you visited in the dialog, including switching between sources, and going somewhere new clears Forward. Up is greyed out at the top of a source. The keys work too: Alt+Left, Alt+Right, Alt+Up and Backspace. New folder has moved into the same toolbar. While the dialog is open, the song behind it no longer hears your shortcuts, so Space doesn’t start playback underneath it. On This computer, click the path (or press Ctrl+L) to type or paste one, then press Enter to go there. Quotes from Explorer’s Copy as path, %VARIABLES% and ~ are understood, and a path to a file opens its folder with the file selected. A path that doesn’t exist says so and leaves the box open. Escape or clicking away puts the path back. Other sources, and the web version, keep a plain path, because there is nothing there anyone would paste. Folders now have a folder icon instead of one that looked like a page.",
      "tickets": [
        277
      ]
    },
    {
      "title": "REAPER import: CLAP plugins are no longer skipped, so CLAP Vital comes in as Bifrost",
      "version": "2026.8.248",
      "date": "2026-09-24",
      "area": "Services",
      "type": "Bug fix",
      "userFacing": true,
      "details": "The REAPER importer only read VST plugins. A track playing a CLAP plugin came in with no instrument and nothing in the report saying why, and the plugin count left it out. CLAP and JS plugins are now read and handled exactly like VST ones: a CLAP Vital becomes Bifrost playing its own patch, a CLAP or JS effect gets the same best-guess built-in replacement, and anything that can't be carried over is named in the report. Plugins in formats the importer doesn't read (AU, LV2 and FX containers) and take FX on individual items are now listed in the report too, instead of disappearing.",
      "tickets": [
        276
      ]
    },
    {
      "title": "REAPER import: Speedrum drum kits come in as Capacitor, samples and all",
      "version": "2026.8.247",
      "date": "2026-09-24",
      "area": "Services",
      "type": "New feature",
      "userFacing": true,
      "details": "A REAPER track playing Speedrum or Speedrum Lite used to import with no instrument. It now gets a Capacitor drum machine: each pad's sample is read from the path Speedrum saved (or found beside the project) and loaded into the song, and each pad keeps its volume and pitch. A pad that played only part of its sample gets a trimmed copy. Speedrum and Capacitor put their pads on the same notes, 36 to 51, so the drum patterns play the same pads. What Capacitor has no control for is listed per pad in the import report: pan, the attack-hold-decay envelope, filters, choke groups, and pads that ignored velocity, which keep their recorded velocities and so play quieter. A Capacitor pad reaches the song's first 32 samples, so the drum samples are loaded ahead of the project's other audio. Pooled MIDI items (REAPER's linked copies) now import too; they used to be skipped, which left gaps in a project's patterns. Desktop only, like the rest of REAPER import.",
      "tickets": [
        275
      ]
    },
    {
      "title": "Recent songs on the welcome screen are a list, like the file browser",
      "version": "2026.8.246",
      "date": "2026-09-24",
      "area": "UI",
      "type": "Improvement",
      "userFacing": true,
      "details": "The welcome screen showed your recent songs as big cards, the same as the demos and pack templates. Every card had the same ezmuze mark on a coloured tile, so you still had to read the names to tell them apart. Recent songs are now a list with the same rows the file browser uses: the song's icon, its name, where it lives (the folder, or the service it came from), and when you last opened it. One click opens a song, as before, and a row lights up under the pointer. Nothing else about the list has changed: you still get up to eight songs, and the list has no menu and no delete. The demos and pack templates keep their cards.",
      "tickets": [
        274
      ]
    },
    {
      "title": "Changing a module you published makes its next version, not a remix",
      "version": "2026.8.245",
      "date": "2026-09-24",
      "area": "Modules",
      "type": "Bug fix",
      "userFacing": true,
      "details": "Editing the graph of a module you published used to turn it into a remix the moment you changed anything. It got a new identity and a name ending in (remix), so publishing it created a second store listing that credited your own module, instead of a new version of it. Now your first edit makes a working copy of the same module, with the same name, and the publish window offers it as the next version. If you really do want a separate module that credits the original, the module editor's File menu has Make a remix instead, which asks you to confirm first. A module you already saved as a remix of one of your own modules opens the publish window on a new Publish as choice, set to New version of the original. Publishing it that way turns it back into the original module on this machine (in your song, your library, its presets and its panel), so it keeps publishing as versions. A separate remix listing is still one click away if that is what you meant. Remixes of other people's modules work exactly as before.",
      "tickets": [
        273
      ]
    },
    {
      "title": "A store preview can be a passage of your own song",
      "version": "2026.8.244",
      "date": "2026-09-24",
      "area": "Modules",
      "type": "New feature",
      "userFacing": true,
      "details": "When you publish a module, the publish window now has a Preview from choice. If the channel selected in the sequencer has the module in its chain (as its instrument or as an effect) and you have made a time selection, it defaults to Your selection: the preview is that passage of your song with the channel soloed, as it sounds in your song, including edits you have not saved, the rest of the channel's effects and the master. Rendering keeps going up to four seconds past the end of the selection so the last notes and any echo or reverb can ring out, and nothing after the selection starts playing. A selection longer than a minute uses its first minute. It is levelled to the same loudness as every other store preview. Store default, the fixed phrase over the module's presets, is still one pick away, and it is what you get when there is no selection or the selected channel does not hold the module. A module nested inside another device, or on the master channel, does not count. The status line in the publish window also no longer runs off its right edge.",
      "tickets": [
        272
      ]
    },
    {
      "title": "Each Services tab now says what its services are for",
      "version": "2026.8.244",
      "date": "2026-09-24",
      "area": "UI",
      "type": "Improvement",
      "userFacing": true,
      "details": "The line under the tabs in Preferences > Services used to be one fixed sentence about every kind of service. It now changes with the tab. Accounts explains that a service there does several jobs and how switching off differs from logging out. Songs, Sounds, Packs, Sharing and Import each say what their services are for and where the ones you switch on appear in the app."
    },
    {
      "title": "Preferences > Services is split into tabs, and its rows have a proper fold-out arrow",
      "version": "2026.8.243",
      "date": "2026-09-24",
      "area": "UI",
      "type": "Improvement",
      "userFacing": true,
      "details": "The Services page listed every service in one long column. It now has a row of tabs along the top: Accounts, Songs, Sounds, Packs, Sharing and Import. Accounts holds the services that do more than one thing, which today is ezmuze central, plus ezmuze 3 songs when ezmuze 3 is installed on this computer. Every other service is under the one thing it does. A tab with nothing in it is not shown, so Packs stays hidden while ezmuze central is the only pack store. The tabs stay in view while the list scrolls under them. The page opens on the tab you last used, and resizing the window no longer folds up the rows you had open. Each row now has a drawn arrow that points right when the row is closed and turns to point down as it opens, because it was not obvious that clicking a row unfolds it. An opened row's contents are no longer pushed against its left edge: they sit inside the row with a margin on every side. 'Provides' now also lists song import.",
      "tickets": [
        270
      ]
    },
    {
      "title": "You can now hide the visualiser in the “now playing” toast",
      "version": "2026.8.242",
      "date": "2026-09-24",
      "area": "UI",
      "type": "New feature",
      "userFacing": true,
      "details": "Preferences > Sequencer has a new switch, Show visualizer in “now playing” toast, directly under the one for the toast itself. Turn it off and the toast still appears while a song plays with its title, author, progress bar and full-screen button, but without the visualiser square, so it is shorter. The visualiser also stops rendering for it rather than just being hidden. It can be flipped while a song is playing and the toast updates in place. The switch is greyed out while the toast itself is off, and remembers its setting for when you turn the toast back on. On by default, so nothing changes unless you turn it off.",
      "tickets": [
        271
      ]
    },
    {
      "title": "Preferences has a category list down the left, and the window can be resized",
      "version": "2026.8.241",
      "date": "2026-09-23",
      "area": "UI",
      "type": "Improvement",
      "userFacing": true,
      "details": "The tab strip across the top of Preferences had run out of room: six tabs shared the width, 'New songs' wrapped onto two lines, and Display held sixteen settings, several of them not about display at all. Preferences now lists its categories down the left, each with an icon: General, Appearance, Waveforms, Sequencer, New songs, Audio, Services, Installed and Advanced. The settings are grouped by what they are about. General has the launch and window settings, Appearance the display scale, pointer size, theme and playback background, Waveforms the three waveform settings, and Sequencer the channel and click settings. Waveform render mode and Show CPU cost % moved to Advanced, which now appears in the web version too. The window is wider, can be resized (the settings reflow to fill it), and remembers its size between launches. It opens on the category you last used. Once you have clicked the list, the Up and Down keys move through it. The pointer-size slider still previews as you drag it, but Cancel or closing the window now puts the pointer back to its saved size.",
      "tickets": [
        269
      ]
    },
    {
      "title": "The macOS build no longer fails on a stray documentation file from the REAPER importer",
      "version": "2026.8.240",
      "date": "2026-09-23",
      "area": "Build",
      "type": "Internal",
      "userFacing": false,
      "details": "Ezmuze.Reaper generated an XML documentation file. Once the app referenced it for REAPER import (#268), Ezmuze.Reaper.xml was copied into the macOS bundle's Contents/MacOS. rcodesign cannot seal a file there that is not Mach-O, so the release pipeline's guard failed the osx-arm64 build. Nothing reads that file, so it is no longer generated."
    },
    {
      "title": "A flaky pad-synth cache test no longer fails release builds",
      "version": "2026.8.240",
      "date": "2026-09-23",
      "area": "Build",
      "type": "Internal",
      "userFacing": false,
      "details": "PadModuleTests.ManyVoicesShareOneBake counts bakes in PadTableCache, which is process-wide. Another test rendering a pad at the same time could bump or clear that count, and it did, failing the 2026-09-23 release build for the import-services commits. PadModuleTests now runs in a collection with parallelization disabled, so xUnit runs it on its own after the parallel tests."
    },
    {
      "title": "REAPER import: Vital tracks come in as Bifrost playing a conversion of their patch",
      "version": "2026.8.240",
      "date": "2026-09-23",
      "area": "Services",
      "type": "New feature",
      "userFacing": true,
      "details": "When a REAPER project has a track that played Vital, the import now reads the Vital patch saved inside the project and gives the channel a Bifrost playing a conversion of it, using the same converter as dropping a .vitalbank on the window. Vital does not need to be installed. The import report names the patch and its author, and lists in one line what Bifrost leaves out or approximates (Vital's filter models, effects and some modulation). Serum patches cannot be converted yet, so Serum tracks still come in with no instrument."
    },
    {
      "title": "Import REAPER projects: tracks, MIDI and audio come in, and plugin effects become built-in ones",
      "version": "2026.8.240",
      "date": "2026-09-23",
      "area": "Services",
      "type": "New feature",
      "userFacing": true,
      "details": "File > Import > REAPER project... (or dropping an .rpp on the window) turns a REAPER project into a song. Tracks become channels, folders become parent channels, MIDI items become patterns on the timeline, and audio items become sample clips with their offsets, playrates, pitch, fades and looping. MIDI items with identical notes on a channel share one pattern, so editing a repeated part edits every copy. The project's audio is embedded in the song, compressed the way ezmuze 3's packs are, and it is found even if the project was moved, by looking beside it and in its Media folder. Instrument plugins cannot come across, so their channels keep their patterns with no instrument yet, and the report names the plugin each one used (Serum included: converting Serum patches comes later). Effect plugins become the closest built-in effect in the same place in the chain: EQs become the EQ, reverbs the Reverb, delays the Delay, compressors the Compressor, and so on, with safe starting settings. REAPER's own ReaEQ brings its real bands. Plugins with nothing close, such as pitch correctors and gates, are left out. Every replacement and every loss is listed in the import report. Desktop app only: a REAPER project keeps its audio in separate files, which a browser page cannot reach, so the web version shows the importer disabled and says why."
    },
    {
      "title": "File > Import brings in ezmuze 3 songs, and dropping an .eez on the window now opens it",
      "version": "2026.8.240",
      "date": "2026-09-23",
      "area": "Services",
      "type": "New feature",
      "userFacing": true,
      "details": "There is a new kind of service, Import, for songs made in other programs. File > Import lists every importer that is switched on. The first one is \"ezmuze 3 song...\": it opens the song picker filtered to .eez files, and on a computer where ezmuze 3 was installed it also lists the songs ezmuze 3 kept for itself, under their real names rather than the GUIDs they are stored as. Dropping an .eez on the window now imports it; before, it was refused as not being a sample. The song goes through the same licensing check and conversion as File > Open, which still reads .eez as it did. An imported song is a new song, so Save asks where to put it. When an import has notes on what it carried over or guessed, a \"Song imported\" message offers the full report, and a long report now scrolls instead of being cut off. Importers appear in Preferences > Services and are on by default."
    },
    {
      "title": "Glide now only slides between overlapping (legato) notes, like Vital",
      "version": "2026.8.239",
      "date": "2026-09-23",
      "area": "Sound engine",
      "type": "Improvement",
      "userFacing": true,
      "details": "An instrument's Glide used to slide into every new note from the one before, even after a gap. It now follows Vital's portamento (with its 'force' option off, which is Vital's default): a note glides only when another note is still held as it starts - an overlapping, legato line - and then from the note played last, whichever voice played it. A note after a gap, or one that starts exactly as the previous one ends, starts at its own pitch. In a chord the first note starts in place and the others glide from it, since the notes of a chord are pressed one after another. This applies to every instrument with a Glide control, mono or polyphonic. To glide between two notes, make them overlap. Songs that relied on gliding between separate notes will now step between them instead; the Elevation demo's reese basses are the audible case."
    },
    {
      "title": "Give a Switch as many inputs as it needs: right-click it and add a stereo pair",
      "version": "2026.8.238",
      "date": "2026-09-23",
      "area": "Modules",
      "type": "New feature",
      "userFacing": true,
      "details": "The module editor's Switch used to come with a fixed sixteen stereo pairs whether you needed two or twenty. It now starts with two, and right-clicking it offers \"Add stereo pair input\", up to 64 pairs. A pair you added can be deleted from its socket's right-click menu (\"Delete stereo pair 3\"). The two pairs every Switch starts with cannot be deleted. Deleting a pair renumbers the ones above it so there is never a gap, and anything wired into them moves with them. A dial scaled to sweep every pair keeps sweeping every pair as you add more, and a Switch left on a pair keeps that pair when a lower one is deleted. Switches in songs saved before this open with the pairs they actually use (the highest one anything is wired to, and at least two), so nothing that was connected is lost. Each add or delete is one Undo step. This works through a general mechanism: any component can declare that it grows ports, name what one more of them is called, and do the adding itself. The Switch is the first to use it.",
      "tickets": [
        226
      ]
    },
    {
      "title": "Components can have their own number of ports, starting with the Switch",
      "version": "2026.8.238",
      "date": "2026-09-23",
      "area": "Modules",
      "type": "Internal",
      "userFacing": false,
      "details": "Engine and model groundwork for #226. A module's port list was one shared object per TYPE, so a switch could not have five inputs while another had forty; util.switch shipped with a fixed sixteen pairs for that reason. A primitive can now declare that it grows ports (PortGrowth: a named unit such as \"stereo pair\", how many it declares, a ceiling, and add/remove callbacks), and ModuleRegistry.Find(instance) returns that instance's own port list. The count lives in the component's Params under a reserved \"ports.\" key, so this is NOT a song-format change: older builds ignore the key, the bounce and pattern caches already hash it, and presets deliberately neither capture nor apply it. ChangePortUnitsEdit applies an add or a delete as one undo step and renumbers the ports above a deleted unit everywhere a port id is stored (wires at the component's level, exposed-param ids a level up, Params keys, panel marks, automation lanes); whatever was on the deleted unit goes with it. util.switch is the first adopter: 2 declared pairs, up to 64. select's range follows the count (0..count-1): adding a pair stretches a SCALED wire into select that reached the old top pair so it still reaches every pair, and deleting a pair moves a stored or automated select value above it down with the pair it named. Unscaled wires carry a raw index and are left alone. A switch saved before this has no count, and on load it gets the highest pair anything in the song references (a wire, an exposed-param id, an automation lane, a Params key, a panel mark), minimum 2, so loading never drops a reference. Where that pass never runs (library modules, store packages) a keyless switch keeps the old sixteen. The four ModuleRegistry call sites that held an instance but looked it up by type id now see grown ports (FindWithGrowth), without also picking up a Lua component's per-script control names."
    },
    {
      "title": "Right-click a component in the module editor for Rename, Duplicate, Disconnect and Delete",
      "version": "2026.8.238",
      "date": "2026-09-23",
      "area": "Modules",
      "type": "New feature",
      "userFacing": true,
      "details": "Right-clicking a component in the module editor used to do nothing, so Delete was reachable only through the bin in its title bar and nothing else could be done to a component from where it sat. Right-clicking a node now opens a menu with Rename…, Duplicate, Disconnect all wires, Edit Lua… (on Lua components) and, last and set apart, Delete. Duplicate copies a component with every setting but none of its wires, placed just below and to the right of the original. Rename sets the label the node and the panel show; clearing it goes back to the component's own name. Port dots and knobs keep their own right-click menus, and every action is one Undo step. This is the first part of #226; per-component ports that can be added and removed follow."
    },
    {
      "title": "Polyphonic synths let a released note ring out instead of cutting it off, and glide from the note you played last",
      "version": "2026.8.237",
      "date": "2026-09-23",
      "area": "Sound engine",
      "type": "Improvement",
      "userFacing": true,
      "details": "Every polyphonic instrument (Bifrost, Freyja, Kenobi, Chip, Capacitor and the rest) gave a new note the lowest-numbered free voice, and a voice still in its release tail counted as free. So a note replayed during the previous one's release re-triggered that same voice: the tail was cut off, and with an envelope delay it clicked. A new note now takes the voice that has been quiet longest - one never used, else the one released longest ago - which is what Vital does (a dead voice first, a releasing one only when none is left). Tails now ring on instead of being cut, which is most audible on drums: a kit's hits used to choke each other's tails and now overlap. Back-to-back notes still stay on one voice, and a held note is stolen at exactly the moments it was before, since the number of free voices has not changed. Because a note can now land on a different voice from the one before, glide also changed to match Vital: a note glides from the channel's last played note, whichever voice played it, not from the pitch its own voice last held. The demos sound slightly different where notes overlap a release.",
      "tickets": [
        263
      ]
    },
    {
      "title": "Imported Vital sounds with an envelope delay no longer click when a note is replayed",
      "version": "2026.8.237",
      "date": "2026-09-23",
      "area": "Sound engine",
      "type": "Bug fix",
      "userFacing": true,
      "details": "When an envelope with a delay was retriggered while it was still sounding - most often a note replayed during the previous one's release - its level dropped to zero in a single sample, which clicked and cut the release tail off. It now does what Vital's envelope does: it fades in a straight line from wherever it was down to zero across the delay time, and the attack then starts from zero. Vital itself jumps back up to the old level when the attack starts; that jump is the same click moved later, so it is deliberately not copied. Envelopes without a delay, and a delay that starts from silence, sound exactly as before."
    },
    {
      "title": "Drop a MIDI file on the app to turn it into patterns, one for the whole file or one per channel",
      "version": "2026.8.237",
      "date": "2026-09-23",
      "area": "Sequencer",
      "type": "New feature",
      "userFacing": true,
      "details": "The first slice of #240. Drop a .mid or .midi file anywhere on the sequencer and its notes become patterns in the pattern explorer, in the open folder of the selected channel (or a new Basic Synth channel named after the file when the master or nothing is selected), ready to drag onto the timeline. When the file has more than one part (tracks in a type-1 file, MIDI channels in a type-0 file) you are asked whether you want one pattern for all channels or one pattern for each channel; a file with a single part imports without asking. Pitch, timing and velocity come across; every pattern from one file keeps its notes where they were in the file and is the same length, so patterns split from one file line up when placed at the same bar. The whole import is one undo step. Not imported: the file's tempo and time signature (the song's own tempo plays the notes), controllers, pitch bend, aftertouch and program changes; the status bar says how many of those were left out. Not part of this slice: the Import service, legacy .eez and REAPER song import, matching VST instruments to modules, and Serum/Vital preset conversion. For testing, the desktop control API gains a drag_file action that runs a whole file drag through the real drop target.",
      "tickets": [
        240
      ]
    },
    {
      "title": "SoundFonts, Vital banks and module bundles can be dragged onto the Windows app again",
      "version": "2026.8.237",
      "date": "2026-09-23",
      "area": "Desktop",
      "type": "Bug fix",
      "userFacing": true,
      "details": "On Windows the app replaces the system's drop handling with its own, so a sample can be dragged onto the timeline with a preview of where it will land. That handler only accepted audio files and turned everything else away with the no-entry cursor, so dropping a .sf2 SoundFont, a .vitalbank or .vital bank, or a .ezmodsrc module bundle onto the window did nothing, although the importers for all of them were there waiting. Confirmed through the real drop handler before the fix: all four were refused while a .wav was accepted. They are accepted now and go straight to their importers, the same way they always did on the web and on Linux and macOS; audio drops behave exactly as before."
    },
    {
      "title": "A desktop build without its data folder now says so and exits, instead of crashing",
      "version": "2026.8.237",
      "date": "2026-09-23",
      "area": "Desktop",
      "type": "Bug fix",
      "userFacing": false,
      "details": "A desktop build run from a folder with no data folder beside the exe (and no checkout above it) threw DirectoryNotFoundException at startup, which went to the crash reporter and filed a bug-board ticket. Every copy became a new ticket, because the message included the folder path. It now shows one line in a dialog and on stderr saying where it looked, then exits with code 2. It writes no crash log and files no report. Only misplaced dev or agent builds can hit this; shipped builds always carry their data folder, so it is not user-facing.",
      "tickets": [
        237
      ]
    },
    {
      "title": "Time-stretching clips on the GPU is much faster on Windows",
      "version": "2026.8.237",
      "date": "2026-09-23",
      "area": "Sound engine",
      "type": "Performance",
      "userFacing": true,
      "details": "The DirectX GPU path that time-stretches clips for the stretch cache and for export made a new Direct3D device and recompiled-and-loaded its four compute kernels whenever a job's shape changed, and the shape (clip length, stretch ratio) changes with nearly every clip, so almost every job paid about 200 ms of setup for well under a millisecond of work. The device, kernels and analysis window are now made once and kept; a new shape only reallocates the buffers that depend on it. On a mixed run of 20 jobs across six shapes the whole run dropped from about 3.3 seconds to about 0.18 seconds (roughly 18 times faster), a job that changes shape went from about 217 ms to about 2 ms, and the output is byte-for-byte identical to before. Any GPU failure still throws the whole device away, so a reset or removed graphics device is recovered on the next job exactly as before.",
      "tickets": [
        262
      ]
    },
    {
      "title": "Store previews now play at the same loudness, so moving between modules no longer means reaching for the volume",
      "version": "2026.8.236",
      "date": "2026-09-23",
      "area": "Modules",
      "type": "Improvement",
      "userFacing": true,
      "details": "A module's store preview used to be levelled by its loudest sample, which puts every peak in the same place and leaves the loudness to the sound: a sparse pluck came out near -20 LUFS beside a dense bass near -7, a 13 dB jump from one tile to the next. Previews are now levelled to -14 LUFS integrated (ITU-R BS.1770-4, the level streaming services use) under a -1 dBTP true-peak ceiling measured on 4x oversampled audio, with 0.5 dB more left for the Ogg encode so the file itself stays under -1 dBFS. A sound too peaky to reach -14 under that ceiling plays quieter rather than being limited, so what you hear is the module and not a limiter. This applies to every preview rendered from now on, in the publish wizard and when a preview is replaced, and every preview in the store was re-rendered with it on 2026-09-23."
    },
    {
      "title": "Store previews re-rendered with the new preview renderer, and a store module's preview now plays its whole preset bank",
      "version": "2026.8.236",
      "date": "2026-09-23",
      "area": "Services",
      "type": "Improvement",
      "userFacing": false,
      "details": "The control API's render_preview (the path that replaces the preview of a module that is already published, without a new version) rendered an installed store module with no presets at all: every preset an installed package brings is package-owned, and the bank gather keeps only the author's own, so the preview played one sound for fifteen seconds. It now renders over the package's own bank through a ModulePublishJob.RenderPreview overload that takes it. A preset pack rendered through that path ignored the pack entirely and played the target module's bank; it is now the pack's first preset on the target module, as the publish wizard renders it, with the target's definitions passed in so its category and embedded files are found. render_preview also gained out (write the ogg to disk), upload:false (render only) and file (upload a given ogg), and reports raw peak, gain and how many frames the render clipped, so a whole store can be rendered, checked and then uploaded exactly as checked. Used to refresh every store module's preview on 2026-09-23."
    },
    {
      "title": "WebCLAP bridge: feasibility study, design and spike",
      "version": "2026.8.236",
      "date": "2026-09-23",
      "area": "Modules",
      "type": "Internal",
      "userFacing": false,
      "details": "Internal. docs/webclap-bridge.md studies hosting WebCLAP plugins (CLAP compiled to wasm32) as modules on desktop and in the browser build, and recommends go with caveats: one C# CLAP host with pluggable ABI backends (Wasmtime on desktop, [JSImport] into the audio worker in the browser, optionally native .clap), plugged into the existing IExternalModuleHost seam, with plugin binaries travelling inside module definitions so the store ships them unchanged. The spike (tools/WebClapSpike, not wired into the app) rendered every WebCLAP example from C# on Wasmtime with notes, parameter events and state round trips, at 1-2% of a core per effect at the 64-frame tick and zero managed allocation per block. It also rendered them in a plain browser Worker without COOP/COEP in Edge, Firefox and WebKit (shared wasm memory works without cross-origin isolation, but Firefox was 7-10x slower), measured the .NET AOT [JSImport] hop at about 0.5-1 us per block, and drove a native Vital.clap through the same flow. The doc covers the parameter, automation, modulation, state, preset, GUI (webview), sandboxing, licensing and store mapping, the rejected route through the VST3 host, a phased plan (~6-8 weeks to v1) and the decisions left for the user."
    },
    {
      "title": "Synth panels with wavetable, envelope and LFO displays draw faster while they sit still",
      "version": "2026.8.236",
      "date": "2026-09-23",
      "area": "UI",
      "type": "Performance",
      "userFacing": true,
      "details": "Every curve on a panel - Bifrost's three wavetable displays of eleven frames each, the envelope graphs, the LFO shapes - used to be rebuilt from scratch every frame: one rotated quad per line segment per stroke, one gradient quad per column of fill, each with its own trigonometry. The curve element (GustUI's GlowCurveElement) now builds that geometry once and redraws the stored copy until something it was built from actually changes - the points, the size or the colours. A modulated wavetable position still redraws its moving frame, and only that frame; the stack behind it stays put. The picture is pixel-for-pixel the same (checked by screenshot). With Bifrost's panel open and idle on a 4K screen, a frame went from 9.2 to 6.7 ms in a Debug build and from 1.76 to 1.54 ms in Release; turning a knob went from about 11 to 8.4 ms (Debug). The live spectrum analyser still redraws every update, as it should."
    },
    {
      "title": "Publishing a new version of your own store module no longer needs workarounds, even when only its panel or presets changed",
      "version": "2026.8.236",
      "date": "2026-09-23",
      "area": "Modules",
      "type": "Bug fix",
      "userFacing": true,
      "details": "Four workarounds added on 2026-09-22 so Bifrost could be republished are gone, each replaced by the mechanism it was standing in for. (1) The publish wizard skipped the 'installed store module' export refusal for anything in this machine's local list of published modules. That refusal is what stops a store module - somebody else's, or the factory's - being re-uploaded as your own, and it now applies to everyone: what lets an author through is the author grant the store signs into the installed package (ModuleOrigin.OpenedByAuthor, set by InstalledModules.Open from the verified header, never serialized), so the author of a module that only exists on their machine as a store install - every module that moved from the app to the store - can publish its next version, and a buyer's copy cannot. The written bundle drops the store provenance on a copy (ModuleBundle.Unpublished), so it is byte-for-byte what a library copy of the same graph writes, and saving the author's own store module to the library stores it as their source. (2) The wizard special-cased Bifrost by name, clearing its origin and swapping in the panel built in code. Publishing now knows no module by name. A store module whose panel is authored in C# (Ezmuze.Audio.StoreModuleSources: Bifrost, Kenobi, HALL 9000 and the rest) is brought across by an explicit authoring step - the control API's author_panel - which saves the code-built panel as the author's own edit of it, exactly as the panel editor's Save would, so it shows over the package's and the next publish bundles it. (3) Gather appended '[Bifrost panel refresh]' to the description to get a panel-only version past the store's duplicate check; the check was what was wrong (ezmuze-central now treats a new panel or new presets on the same graph as a new version of the owner's own listing), so the marker is gone. Against a store that has not yet deployed that fix, a panel-only version is refused with the store's own message rather than faked through. (4) Reseeding panels (reseed_layouts) re-saved Bifrost's panel over whatever was stored, and PackageSeeds.PickLayout let a stored panel with a higher revision beat an installed package's. Reseeding is factory-only again, and only a person's edit beats a package's panel - a revision number is not an edit."
    },
    {
      "title": "A module you import or save no longer disappears at the next launch because it used to be built in",
      "version": "2026.8.235",
      "date": "2026-09-23",
      "area": "Modules",
      "type": "Bug fix",
      "userFacing": true,
      "details": "When a built-in module is retired, the app removes its old library entry at startup so it does not linger under My modules. It decided what to remove by id alone: any id it had ever shipped that no longer ships. Bifrost, Laufeyson, Modyssey, Kenobi, Bank Manager, Cloudform, Plucktopus, Bueller and the other modules that moved to the store kept their ids, so a copy you put back in your library under one of them was deleted as retired at the next launch, and every launch after that. That covered importing an .ezmodsrc (Bifrost.ezmodsrc, for example), saving a song's copy from the module editor, and a module of your own that nests one. Store installs were never affected, because they are not kept in that library. The app now records which library entries the built-in seeding actually wrote, and retires only those. Anything you imported or saved stays, and a genuinely retired built-in copy is still removed. On the first launch with this fix, an entry under a formerly built-in id is kept, because every launch since that module was retired would already have removed the built-in copy."
    },
    {
      "title": "Bifrost's panel no longer slows to a crawl while you turn a knob, and opens lighter",
      "version": "2026.8.235",
      "date": "2026-09-23",
      "area": "UI",
      "type": "Performance",
      "userFacing": true,
      "details": "Turning any knob on a panel whose mod rail is part of the layout (Bifrost, Korben) rebuilt the whole panel, about 900 elements plus three wavetable displays and the rail, on every frame of the drag. That took 35-40 ms per frame in a debug build, which is why the frame rate fell sharply the moment a knob moved. The panel now works out whether the edit changed anything the rail shows (ModRailState: sources, their settings and wiring, and every routing's source, destination and depth). If it did not, the panel only syncs the controls already on screen, which costs about 2.5 ms, and every edit other than a plain value change still rebuilds as before. The modulation depth pass also read the device's whole mod matrix once per control, 125 times per edit on Bifrost, and now reads it once per pass. With the panel open and idle, garbage fell from about 900 KB per frame to about 3 KB. GustUI's knob, circle, ring and rounded-panel primitives now build their geometry in reused per-thread buffers instead of four new arrays per call. Labels no longer allocate a closure on every draw. The wavetable displays stopped allocating a closure per wire on every live poll and no longer rebuild the device's slot map 40 times a second. GlowCurveElement's fill now walks its points once per curve instead of once per pixel column. A chrome (non-embedded) mod rail also leaked its LFO editors into the panel's per-frame poll every time it redrew, so those panels got slower with each edit that touched the rail. The old editors are now removed when the rail redraws. The panel looks exactly as it did."
    },
    {
      "title": ".NET 8 is gone: everything builds and runs on .NET 10",
      "version": "2026.8.234",
      "date": "2026-09-23",
      "area": "Build",
      "type": "Internal",
      "userFacing": false,
      "details": "Internal. .NET 8 reaches end of support on 2026-11-10, so the last net8.0 targets are retired. Every library (Audio, Audio.Gpu.D3D11, Audio.Vst, Core, Legacy, Reaper, Services and the service plugins), every tool and the web host now target net10.0 / net10.0-windows, and the tests run on .NET 10 only instead of both runtimes. ServicePlugins.targets and VstHost.targets follow the new output paths. The release build image no longer installs the .NET 8 runtime. The Core package the site API builds against is packed as net10.0; ezmuze-central moved to .NET 10 first, since a net8.0 consumer cannot reference a net10.0 package. The web host runs in the stock aspnet:10.0 containers on the VPS, which were recreated from aspnet:8.0 before this shipped (docs/web-hosting.md). The KNI fork's MGCB content builder is still built net8.0; it is a separate repo, only runs on developer machines, and does not block anything here."
    },
    {
      "title": "The mod rail's routings pane now opens from a chevron on its edge",
      "version": "2026.8.234",
      "date": "2026-09-23",
      "area": "UI",
      "type": "Improvement",
      "userFacing": true,
      "details": "The button that showed and hid a mod rail's routings sat at the far left of the rail, a long way from the pane it opened on the right. It is now a chevron in the rail's top-right corner, fixed to the pane's leading edge: pointing left when the pane is closed, it slides out with the pane and turns half a round to point right, and clicking it again pushes the pane back in and turns it back. The slide and the turn share one eased value, so they stay in step, and a click mid-slide reverses from where the pane is rather than snapping. The tooltip says what the next click does: Show routings or Hide routings."
    },
    {
      "title": "The Lua heap-estimate test no longer shares the process with other tests",
      "version": "2026.8.234",
      "date": "2026-09-23",
      "area": "Build",
      "type": "Internal",
      "userFacing": false,
      "details": "Internal. RetainedEstimateTracksWhatTheGcHolds compares the Lua runtime's retained-state estimate with GC.GetTotalMemory, which is process-wide, so tests allocating in parallel pushed the measured figure out of range (it failed once in a full net8.0 run). It now runs in its own non-parallel ProcessHeap collection."
    },
    {
      "title": "Songs from ezmuze 3.0 play from the first bar again, not from bar 4",
      "version": "2026.8.234",
      "date": "2026-09-23",
      "area": "Desktop",
      "type": "Bug fix",
      "userFacing": true,
      "details": "On desktop, the song is bounced to a disk cache in the background as soon as it opens, and once the whole song is cached, playback plays from that cache. A 3.0 song's pack loops keep decoding for a few seconds after it opens, and any bar bounced before its loop arrived was cached with that loop silent. When the loop landed, the bounce worker handed it to its renderer but never marked those bars stale, so the silence was played from disk (and carried into later sessions by the cache manifest). The reporter's recording shows exactly that: the bounce strip already covering the whole song before Play, and the mix cutting in on the bar-4 line. A decoded loop now invalidates the cache from the first bar that uses it (NaiveSongRenderer.FirstBarUsingLoop). The watermark rule moved into Ezmuze.Audio.BounceWatermark, which also refuses a bar that was being rendered when an invalidation reached it; before, an invalidation at that exact bar left the watermark unchanged and the stale bar was saved, for edits as well as decodes. Loops decoded between the worker's creation and its event subscription are caught up, manifest writes are serialised so a stale one cannot land last, and the cache format is bumped to 2 so existing caches that may hold silent bars are discarded.",
      "tickets": [
        242
      ]
    },
    {
      "title": "Browser Lua probe checks the memory budget in the AOT audio worker",
      "version": "2026.8.234",
      "date": "2026-09-23",
      "area": "Web",
      "type": "Internal",
      "userFacing": false,
      "details": "Internal. tools/WebWorkerProbe/lua.cjs now also runs a self-doubling string and a table grown every block in the browser AOT audio worker and asserts each goes silent, the worker keeps rendering, and corrected source recovers. A diagnostic run on 2026-09-23 confirmed GC.GetAllocatedBytesForCurrentThread returns real, advancing values under Mono WASM AOT, and that the two scripts fault with 'Processing exceeded its memory budget.' and 'Lua state exceeded its 8 MB memory budget.' respectively, so the #261 budget needs no browser fallback."
    },
    {
      "title": "A Lua component that eats memory is stopped instead of taking the app down",
      "version": "2026.8.234",
      "date": "2026-09-23",
      "area": "Sound engine",
      "type": "Bug fix",
      "userFacing": true,
      "details": "Lua components already had instruction, buffer and DSP-object limits, but nothing stopped a script (possibly one from the module store) from growing a table every block or doubling a string until the app ran out of memory. Each component now has an 8 MB memory budget. The Lua state it keeps between blocks is measured, paced by how much it allocates so ordinary scripts pay almost nothing, and each process or format call may allocate at most 4 MB, checked every 16 instructions so a self-doubling string is caught within a few doublings. Going over faults the component the same way an instruction-budget overrun does: it goes silent, the error shows as a graph diagnostic, and the rest of the channel keeps playing. docs/lua-components.md describes the budget.",
      "tickets": [
        261
      ]
    },
    {
      "title": "The background bounce cache no longer saves legacy pack loops unstretched after an edit",
      "version": "2026.8.234",
      "date": "2026-09-23",
      "area": "Sound engine",
      "type": "Bug fix",
      "userFacing": true,
      "details": "With Time stretch on, a legacy pack loop is stretched to four bars of the song. When an edit needs a new stretch of a loop that is already decoded (switching its normalise on, moving its cut), the renderer hands the stretch to the thread pool and serves the unstretched stand-in until it lands. That is right for live playback, but the desktop bounce worker re-rendered the edited bars straight away and could write the stand-in to its disk cache, which playback then serves once the whole song is bounced. Whether that happened depended on timing. Offline renderers now wait for the stretch: NaiveSongRenderer.BankStretchSynchronous computes a missing stretch on the spot, SongRender (exports) and the bounce worker set it, and the bounce worker calls PrepareBankStretches before each bar so the work happens outside the render lock. Live playback is unchanged. Exports could not hit this, because they never receive decoded pack loops."
    },
    {
      "title": "Switching bar-ahead rendering off during playback no longer garbles the current bar",
      "version": "2026.8.234",
      "date": "2026-09-23",
      "area": "Sound engine",
      "type": "Bug fix",
      "userFacing": true,
      "details": "With bar-ahead rendering on, a channel renders its whole bar on the bar's first span. The setting is process-wide and was re-read on every span, so turning it off part way through a bar (Preferences > Apply during playback) sent the rest of that bar down the per-span path: those frames were rendered a second time, from a graph already at the end of the bar, on top of the audio that was already there. A bar rendered whole is now served whole whatever the setting says by the time it plays. The same race, with another test resetting the flag on another thread, is what made BlockSizeIndependenceTests fail once in a full run (Too many chords, 1.9 dBFS wrong from frame 2048) and pass when filtered. ShippedDemoBarAheadParityTests.Changing_the_flag_mid_bar_does_not_change_the_audio pins it deterministically."
    },
    {
      "title": "Store previews are the same whether sent from the publish wizard or the control API",
      "version": "2026.8.234",
      "date": "2026-09-23",
      "area": "Modules",
      "type": "Improvement",
      "userFacing": true,
      "details": "There were two preview renderers: the publish wizard made 10 seconds of a module's current settings (an effect over a synth drum loop, dry then wet), while the control API's upload made the previews the store actually carries — 15 seconds cycling through up to five presets spread through the module's bank, and 45 seconds for an effect, every preset over a voice, a piano and a breakbeat. There is now one, ModulePreviewRender, and it makes the store's version for both. A module with no bank or a single preset plays its current settings for the full length; a preset pack plays its first preset through its module for the full length. Drum kits are recognised by their drum-lane editor, or by the Drums category when they declare no editor, and the kit beat now uses the shared pad map (the old one struck the clap where it meant a hat). The wizard's tooltips and lengths say what will be rendered, and store tiles no longer promise \"a 10-second preview\"."
    },
    {
      "title": "A copied device keeps its polyphony",
      "version": "2026.8.234",
      "date": "2026-09-23",
      "area": "Modules",
      "type": "Bug fix",
      "userFacing": true,
      "details": "Copying a device (a lane's sub-devices inside a copied device, and the module a preset pack's preview renders through) went through the module definition, which has no voice count, so every copy came out with one voice: an eight-voice synth played its chords as a single stolen note. The copy now carries the source device's voice count. Found because a preset pack's preview and the same module's own preview did not match sample for sample."
    },
    {
      "title": "The publish wizard's preview plays modules that carry their own samples",
      "version": "2026.8.234",
      "date": "2026-09-23",
      "area": "Modules",
      "type": "Bug fix",
      "userFacing": true,
      "details": "The publish wizard's preview render had no content source, so a module built on an embedded pack (its samples inside the module, like the rebuilt Trap Kit and Vintage Kit) rendered silent and the wizard reported nothing to upload. ModulePreviewRender now resolves a module's embedded files, and only those: a pointer into an installed pack still resolves to nothing, as it would for a buyer without the pack. The control-API preview path builds its content source through the same helper."
    },
    {
      "title": "Exporting a module no longer edits the module in your song",
      "version": "2026.8.234",
      "date": "2026-09-23",
      "area": "Modules",
      "type": "Bug fix",
      "userFacing": true,
      "details": "Gathering Bifrost for export or publishing wrote a \"[Bifrost panel refresh]\" marker into the description of the live definition the open song holds, so the marker could be saved into the song. It is now written on a copy. The copy made when a module embeds a song's SoundFont had the opposite fault: it named nine properties by hand and dropped the rest, so such a module exported without its category, description, voice count, art or embedded files. Both now use ModuleDefinition.ShallowCopy, which carries every property, with a test that checks each one by reflection."
    },
    {
      "title": "Pressing play on a store preview no longer stutters the app",
      "version": "2026.8.234",
      "date": "2026-09-23",
      "area": "UI",
      "type": "Performance",
      "userFacing": true,
      "details": "A store preview was fetched in the background but decoded on the UI thread once it arrived, a stall of tens of milliseconds every time a play button was pressed. The decode now runs on the thread pool, and a preview stopped or replaced while it decodes is dropped rather than played."
    },
    {
      "title": "Panel section headings follow the panel's theme instead of always shouting",
      "version": "2026.8.234",
      "date": "2026-09-23",
      "area": "UI",
      "type": "Bug fix",
      "userFacing": true,
      "details": "A module panel's group headings and its mod-matrix list heading were forced into capitals whatever the panel's theme said. Captions already went through the theme's own lettering rule (capitals only on themes that letter their panels that way, and never past 28 characters); the two headings now use the same rule, so a soft theme shows the author's own capitalisation and switching theme back restores it.",
      "tickets": [
        229
      ]
    },
    {
      "title": "Bifrost's wavetable displays cost less per frame",
      "version": "2026.8.234",
      "date": "2026-09-23",
      "area": "UI",
      "type": "Performance",
      "userFacing": false,
      "details": "The wavetable display decided whether to redraw its perspective stack by building a key string of every input, which with a modulated scan position meant a string allocation 40 times a second per oscillator. It now compares the frame array it drew against the one it would draw (every frame source hands back the same array while unchanged) plus the warp. It also stopped allocating a throwaway point list on every played-frame redraw. The Table card polled its design every frame, and reading a design back builds strings from per-character params; it now polls a few times a second, with commits and undo still arriving at once."
    },
    {
      "title": "Preset browser and mod rail toolbar buttons share the app's icon button",
      "version": "2026.8.234",
      "date": "2026-09-23",
      "area": "UI",
      "type": "Internal",
      "userFacing": false,
      "details": "The preset browser's toolbar and the mod rail's routing buttons each hand-built their own 26x22 symbol button; both now use IconButton, the one the module editor's toolbar already uses, so the glyph sizing and pointer capture match. The preset browser resolves its folder-and-tick selection once per refresh instead of three times, no longer re-reads the whole library once per ticked preset when a folder is ticked, and says \"1 preset selected\" rather than \"1 presets selected\". The routing overlay looks its font up once per draw rather than once per connection."
    },
    {
      "title": "Dropping files works again after the Lua editor closes some other way",
      "version": "2026.8.234",
      "date": "2026-09-23",
      "area": "Modules",
      "type": "Bug fix",
      "userFacing": true,
      "details": "While the Lua component editor is open it takes every file dropped on the app. It decided it was open from a flag only its own Close button cleared, so an editor taken down any other way (its window going, a tab closing) left every later drop swallowed until restart. It now asks whether its window is still on screen. The editor's code was also brought into the codebase's layout, the file port is found by id rather than by position 15 in the port list, and the button names are set once instead of twice."
    },
    {
      "title": "Faster browser edits during playback and faster tempo detection",
      "version": "2026.8.234",
      "date": "2026-09-23",
      "area": "Web",
      "type": "Performance",
      "userFacing": true,
      "details": "Each edit sent to the browser audio worker reconciled lists by searching the previous list for every item, a quadratic cost in reflection calls over a channel's clips; it now indexes the previous list once. The analysis worker also chained setTimeout(0) between 8 ms tempo-detection slices, which browsers clamp to 4 ms and which left it idle about a third of the time; it now continues with a message task. Also removes an unused array from the offline CPU STFT."
    },
    {
      "title": "A damaged imported wavetable no longer silences its channel",
      "version": "2026.8.234",
      "date": "2026-09-23",
      "area": "Sound engine",
      "type": "Bug fix",
      "userFacing": true,
      "details": "Imported (Vital) wavetable data with the wrong length or a non-finite sample threw during the channel's graph compile, taking the whole channel down, and threw again from the oscillator display on every redraw. Malformed data now falls back to the oscillator's built-in table, and non-finite samples read as zero."
    },
    {
      "title": "Lua components keep their sound when you turn a knob",
      "version": "2026.8.234",
      "date": "2026-09-23",
      "area": "Modules",
      "type": "Bug fix",
      "userFacing": true,
      "details": "A Lua component fed a file through its content port, or carrying an attached file in a format its script does not declare, reset all of its state (oscillator phase, delay lines, envelopes, script variables) every time the channel recompiled, which happens on every knob edit. The recompile compared the attached file against the bytes the last block happened to read, which never matched in those cases; it now compares against the file each runtime was compiled with. Also stops the audio thread building the p1..p8 parameter key strings on every 64-frame block."
    },
    {
      "title": "The browser-threading docs say which one is the spec",
      "version": "2026.8.234",
      "date": "2026-09-23",
      "area": "Build",
      "type": "Internal",
      "userFacing": false,
      "details": "Internal. Three design notes from the #243 work overlap: net10-web-threading.md (runtime migration), web-threading-options.md (options and the worker probe) and web-worker-implementation.md (what shipped). They were checked against each other and the code and kept, since each carries evidence the others do not, but web-worker-implementation.md now opens by saying it is the normative one and the other two are history, and points on to web-audio-headroom.md. design-guide.md's #252 modulation-routing heading takes the '(added date)' form the rest of its dated headings use."
    },
    {
      "title": "Three source files stop being treated as binary by git",
      "version": "2026.8.234",
      "date": "2026-09-23",
      "area": "Build",
      "type": "Internal",
      "userFacing": false,
      "details": "Internal. ModuleUiSurface.cs, ExternalWaveImport.cs and docs/vst-matcher-design.md each held a literal NUL byte (a pasted \"\\0\" in a C# string, and one in a shell example), so git's text=auto classed them as binary, stopped normalising their line endings and committed them with CRLF. Every later diff of ModuleUiSurface.cs was the whole file (~8,900 lines for ~1,000 real). The NULs are now the escape sequence \\0 - the same char at runtime - the three files are renormalised to LF in the repository, and .gitattributes declares *.cs and *.md as text so one stray byte cannot switch normalisation off again. No behaviour change."
    },
    {
      "title": "Play a store product's preview straight from its tile",
      "version": "2026.8.234",
      "date": "2026-09-23",
      "area": "Services",
      "type": "New feature",
      "userFacing": true,
      "details": "Store tiles and product pages now carry a play button wherever the product has a preview, using the same one-shot audition path as the loop browser: pressing play stops whichever preview was playing before and never touches song playback, and leaving the store stops it. The publish wizard can also render a preview for a preset pack, playing its first preset through the module it was made for. This work (commit 37e3676a, merged in 7569c79d) landed without a changelog entry of its own; this one was added in the post-merge tidy."
    },
    {
      "title": "The changelog's #209 submenu fix is back where it shipped",
      "version": "2026.8.234",
      "date": "2026-09-23",
      "area": "Build",
      "type": "Internal",
      "userFacing": false,
      "details": "Internal. The merge that brought the Core package publishing branch back (c72d267f) interleaved two entries: the shipped 2026.8.138 entry for the #209 submenu fix lost its title to a second copy of the Ezmuze.Core NuGet entry, and its remaining fields were rebuilt later as a fresh NEXT entry titled 'Submenus stay closed after the pointer leaves', which the 2026.8.231 stamp then claimed. The original 2026.8.138 entry is restored exactly from the pre-merge file, the duplicate Core entry is gone, and the misplaced copy at the top is removed, so 2026.8.231's What's new no longer re-announces a fix that shipped in 2026.8.138."
    },
    {
      "title": "See and edit modulation routings in every module rail",
      "version": "2026.8.233",
      "date": "2026-09-23",
      "area": "Modules",
      "type": "New feature",
      "userFacing": true,
      "details": "The mod rail includes a scrollable routing pane with bipolar depth sliders, live signed percentage readouts, and undoable removal. Hover a source, destination control, or routing to reveal curved connections and their depths. Routing discovery is cached between edits; sliders commit as one undo step. Narrow rails can switch between sources and routings, and existing standalone matrix elements remain supported.",
      "tickets": [
        252
      ]
    },
    {
      "title": "Switch large synth presets without repeated graph and panel rebuilds",
      "version": "2026.8.233",
      "date": "2026-09-23",
      "area": "Presets",
      "type": "Performance",
      "userFacing": true,
      "details": "Preset routing and values apply as one undoable edit, so renderers see only the finished patch. Module panels refresh once per frame, modulation controls reuse their binding index, polyphonic voices share compile-time interface descriptions while retaining independent DSP state, and the browser resolves folder selection once per refresh instead of once per row. Verified distinct imported Vital LFO shapes against the source bank; many source presets legitimately share default shapes."
    },
    {
      "title": "Manage presets and folders from the browser toolbar",
      "version": "2026.8.233",
      "date": "2026-09-22",
      "area": "UI",
      "type": "New feature",
      "userFacing": true,
      "details": "The preset browser replaces its bottom controls with Save preset, New Folder, Publish, All, None, Rename and Delete symbol buttons matching the sequencer toolbar, with tooltips and a separate selection count. Unavailable toolbar actions are dimmed and disabled. Folder checkboxes include all saved presets in subfolders for publishing or deletion. Folder renames preserve nested paths, delete confirmations count affected presets, and factory and installed pack presets remain protected. Bulk deletion updates the library index once and clears removed preset assets."
    },
    {
      "title": "Refresh imported LFO shapes and show their tempo",
      "version": "2026.8.233",
      "date": "2026-09-22",
      "area": "Modules",
      "type": "Bug fix",
      "userFacing": true,
      "details": "LFO curve and rate-strip editors now refresh on preset and undo updates. Vital tempo divisions map to the musical rate selector used by the mod rail and take precedence over legacy Bifrost timing wires. Existing imported presets are upgraded when applied; legacy long cycles and freeze are labelled instead of displaying the unused free-running rate."
    },
    {
      "title": "Keep complete filenames when dropping files onto DirectX windows",
      "version": "2026.8.233",
      "date": "2026-09-22",
      "area": "Desktop",
      "type": "Bug fix",
      "userFacing": true,
      "details": "Fixes the native Windows file-drop fallback truncating the last character of every filename, which prevented Vital banks and other files from opening. Reads Unicode paths with room for the terminating character, handles multiple files, releases the shell drop handle, and feeds the normal import queue exactly once. The fix also applies when using packaged KNI binaries."
    },
    {
      "title": "Drop Vital banks into Bifrost",
      "version": "2026.8.232",
      "date": "2026-09-22",
      "area": "Modules",
      "type": "New feature",
      "userFacing": true,
      "details": "Drop a .vitalbank or .vital file into the studio to create Bifrost presets, grouped by bank with author credits and per-preset conversion notes. A sandboxed offline Lua module maps parameters and modulation; managed helpers decode embedded wavetables and samples. Bifrost gains a Lua sample player, envelopes gain delay/hold, and presets retain imported assets, LFO retrigger wiring, source polarity and modulation of route depth. Browser presets use IndexedDB to accommodate large banks. Conversion is approximate: unsupported synthesis, filters and effects are named in the notes, not claimed as identical Vital sound. Includes a CLI converter and audio-check report."
    },
    {
      "title": "Bring outstanding branches and worktrees together",
      "version": "2026.8.231",
      "date": "2026-09-22",
      "area": "Core",
      "type": "Improvement",
      "userFacing": false,
      "details": "Integrates the outstanding local Serum converter, Bifrost panel and LFO diagnostics, module preview controls, decoding benchmark and Core publishing history with the shipped Lua and worker changes. Preview tests now use current instruments; fades precede peak normalisation, and preset previews restore modulation. Existing bank preview rendering remains available to the control API."
    },
    {
      "title": "Benchmark CPU and GPU WavPack sample decoding",
      "version": "2026.8.231",
      "date": "2026-09-22",
      "area": "Sound engine",
      "type": "Internal",
      "userFacing": false,
      "details": "Adds a standalone desktop D3D11 decoder benchmark with exact PCM checks, full upload/readback timings, real demo assets, synthetic batches, shorter-block experiments and CPU parallel comparisons. GPU batching beats one CPU thread in measured cases, while parallel CPU decoding wins on the tested 16-core desktop. No production decoding or project format changes."
    },
    {
      "title": "Serum's modulation matrix converts to Bifrost, source enum and all",
      "version": "2026.8.231",
      "date": "2026-09-21",
      "area": "Modules",
      "type": "Internal",
      "userFacing": false,
      "details": "The one field a Serum matrix row could not be read from was its SOURCE, because Serum exposes no \"Mod N source\" to cross-check against. `SerumConvert sources` settles it by experiment rather than by guessing: it writes the byte a person would otherwise click, stripping a real patch down to a single routing into the filter cutoff, setting that row's source to each candidate id in turn, loading the result and rendering it - then moving the thing the candidate is supposed to BE and measuring again. Fourteen of forty ids are pinned: 1 is the mod wheel, 5 to 12 are LFO 1 to 8, 13 is velocity, and 24 to 27 are the four macros. Two of those confirm what the scoping could only suspect. An id that responds to nothing, or to everything, is left unpinned, and an unpinned id is a routing the converter DROPS and names rather than guesses - a patch routed from the wrong source sounds plausible and is wrong. Three things had to be fixed before the measurement meant anything, and each produced a confident wrong answer first. A Serum state is the zlib stream PLUS a tail of ten thousand bytes; without it Serum loads the init patch and reports success, so the first run measured forty ids that were never loaded. Serum starts its oscillators at a random phase, so two renders of the same note are two different pieces of audio. And a VST3 parameter change stays changed, so the perturbations accumulated down the loop and produced a beautiful staircase that was an artefact of the order the loop ran in. With random phase and the FX rack off and every parameter restored before each render, the control row reads exactly zero and an identical render repeated reads exactly zero. Also in: the mapping table itself, the wavetable substitution, and the writer. Wind Simulator converts with all six of its routings, its four LFO rates as tempo-synced divisions, and its \"Mod Wheel to cutoff\" landing on a Bifrost macro."
    },
    {
      "title": "A Serum patch reads back as named parameters, named routings and a preset name",
      "version": "2026.8.231",
      "date": "2026-09-21",
      "area": "Modules",
      "type": "Internal",
      "userFacing": false,
      "details": "First slice of the deterministic Serum to Bifrost converter (docs/serum-to-bifrost-scoping.md). tools/SerumConvert reads every Serum instance out of a REAPER project and writes a text CAPTURE per track: 317 named parameters with Serum's own units and display text, the sixteen mod-matrix rows out of the state blob, and the preset name, author, comment, wavetable filenames and macro names that the parameter list does not carry. The capture is the point - it is text, it needs no plugin to read back, and it is what lets the mapping table be tested against the ten real patches without a licensed Serum installed. Every read cross-checks itself: a matrix row is non-empty if and only if its \"Mod N amt\" parameter is off its 0.5 default, the amounts must agree through 2v-1 to better than 5e-4, and an active row past the sixteen that are readable is reported rather than dropped. That check earned its keep immediately - Serum names the parameter \"Mod 9 amt\" and then \"Mod10 amt\", dropping the space at ten, so the first version silently found no parameter for rows 10 to 16 on every patch. It reported seven missing parameters instead of concluding seven empty rows, which is the difference this converter is built around. The ten captures now agree with the scoping's row counts exactly: 14, 14, 4, 1, 5, 10, 1, 4, 16, 6, seventy-five in total, with the init patch as the control at zero."
    },
    {
      "title": "The LFO was retriggering all along; the measurement had nothing to measure",
      "version": "2026.8.231",
      "date": "2026-09-21",
      "area": "Modules",
      "type": "Internal",
      "userFacing": false,
      "details": "The hand-built Bifrost patch was believed for a day and a half not to retrigger its LFO - four notes with clear gaps, the reference restarting three times and the build zero - and the cause was never in the patch or in the engine. It was in the ruler. The restart count is upward jumps in a detected f0, patch 0 plays A1 at 55 Hz, and the pitch detector's floor is 65 Hz, which tools/PatchMatcher/Registers.cs had already written down in as many words. So the built render came back with 0 of 684 frames pitched and 'count the rises in an all-NaN series' answered zero, honestly and uselessly. The reference escaped only because Phase Plant's model.voice.pitch transposes its voice an octave up, so the single candidate that COULD be measured was the one that looked like it worked. Printing the engine's own LFO state settled it in one run: retrig fires on all four notes, the shape plays from the top each time, and a detector-free crossing rate shows four notes each starting at the same rate and falling. Three things changed. The restart count now reports how many frames it actually had and says the number means nothing when it had none, and prints a second count from the zero-crossing rate, which has no floor. The built patch moves up an octave to where the reference sounds - Phase Plant's voice transpose, the second port here it has caught out, and it went unnoticed because the pitch gate runs at OCTAVE tolerance, so the one check that would have refused it was the check told not to care. And the depth sweep's full scale is 48 semitones, not the 24 its column header claimed: ModMatrixMath.Attach gives a row the signed span of the control's own wire and Osc A Coarse spans plus or minus 24, so the old sweep was searching 14 to 30 semitones of drop against a measurement that says eleven. A new --bifrost-why prints every identity the build resolves rather than leaving them to be reasoned about. No engine defect: mod.lfo's retrig port works through every path, as LfoRetriggerTests already said."
    },
    {
      "title": "Bifrost publishes panel refreshes as new bytes",
      "version": "2026.8.231",
      "date": "2026-09-22",
      "area": "Instruments",
      "type": "Fix",
      "userFacing": true,
      "details": "Bifrost's export now carries a deterministic panel-refresh marker in the authored metadata, so the store can distinguish a refreshed panel from an identical previous bundle without changing its sound."
    },
    {
      "title": "Bifrost marks the refreshed panel in its bundle",
      "version": "2026.8.231",
      "date": "2026-09-22",
      "area": "Instruments",
      "type": "Fix",
      "userFacing": true,
      "details": "The refreshed panel now carries its own authored marker so the store recognises the layout update as a new module version."
    },
    {
      "title": "Bifrost refreshes its published module metadata",
      "version": "2026.8.231",
      "date": "2026-09-22",
      "area": "Instruments",
      "type": "Fix",
      "userFacing": true,
      "details": "The refreshed Bifrost panel now carries explicit module metadata in its bundle, allowing the store to recognise the update as a new version."
    },
    {
      "title": "Bifrost gives the utility row more breathing room",
      "version": "2026.8.231",
      "date": "2026-09-22",
      "area": "Instruments",
      "type": "Improvement",
      "userFacing": true,
      "details": "The oscillator cards are slightly shorter, letting the filter and lower sections move up while giving performance, click, noise, LFO and macro values more vertical space."
    },
    {
      "title": "Bifrost aligns every filter knob",
      "version": "2026.8.231",
      "date": "2026-09-22",
      "area": "Instruments",
      "type": "Improvement",
      "userFacing": true,
      "details": "Cutoff, resonance, mix, drive, sub level, envelope amount and key tracking now share one consistent knob size across both filter stages."
    },
    {
      "title": "Bifrost gives Filter 1's controls equal weight",
      "version": "2026.8.231",
      "date": "2026-09-22",
      "area": "Instruments",
      "type": "Improvement",
      "userFacing": true,
      "details": "Filter 1 / Sub's Drive, Env and Key controls now match the regular knob size used by the rest of that stage."
    },
    {
      "title": "Bifrost makes compact controls easier to read",
      "version": "2026.8.231",
      "date": "2026-09-22",
      "area": "Instruments",
      "type": "Improvement",
      "userFacing": true,
      "details": "Bifrost's smaller envelope and filter knobs are larger, and control values now use a clearer, brighter readout so the current settings are easier to scan."
    },
    {
      "title": "Bifrost's panel gets an oscillator-first layout",
      "version": "2026.8.231",
      "date": "2026-09-21",
      "area": "Instruments",
      "type": "Improvement",
      "userFacing": true,
      "details": "Bifrost now puts larger wavetable displays and primary oscillator controls at the centre of the panel, gives both filter stages readable dedicated cards, and moves the dense modulation, effects and performance controls into a clearer lower hierarchy."
    },
    {
      "title": "Bifrost choices become readable dropdowns",
      "version": "2026.8.231",
      "date": "2026-09-21",
      "area": "Instruments",
      "type": "Fix",
      "userFacing": true,
      "details": "The wavetable bank no longer tries to show every name as a tiny segmented cell. Each oscillator now opens a readable choice list, and performance, click, noise, LFO and macro controls share one compact row."
    },
    {
      "title": "Bifrost gives oscillators and filters their own rows",
      "version": "2026.8.231",
      "date": "2026-09-21",
      "area": "Instruments",
      "type": "Improvement",
      "userFacing": true,
      "details": "The filter stages now sit below the three oscillators, leaving room for larger warp controls and waveform views. The envelopes are taller, and the effects tail has been widened and kept inside its panel."
    },
    {
      "title": "Bifrost controls get a larger touch target",
      "version": "2026.8.231",
      "date": "2026-09-21",
      "area": "Instruments",
      "type": "Improvement",
      "userFacing": true,
      "details": "Bifrost's regular, large and compact knobs are all slightly larger for easier reading and adjustment, with the tight utility rows re-spaced to keep their controls within their cards."
    },
    {
      "title": "REAPER Serum conversions open with the Bifrost panel",
      "version": "2026.8.231",
      "date": "2026-09-21",
      "area": "Import",
      "type": "Fix",
      "userFacing": true,
      "details": "Native Bifrost devices created by the REAPER importer now carry Bifrost's canonical definition identity, so the published Bifrost UI and preset bank resolve for converted instruments instead of leaving them as anonymous custom graphs."
    },
    {
      "title": "Conversion artifacts default to C:\\scratch",
      "version": "2026.8.231",
      "date": "2026-09-21",
      "area": "Import",
      "type": "Improvement",
      "userFacing": false,
      "details": "REAPER import output, import render checks, and default Serum capture output now go under C:\\scratch so generated tracks and diagnostics do not clutter the source project directory."
    },
    {
      "title": "REAPER imports can produce a fully VST-free mixdown",
      "version": "2026.8.231",
      "date": "2026-09-21",
      "area": "Import",
      "type": "Improvement",
      "userFacing": false,
      "details": "ImportReaper now accepts --freeze-mixdown and renders the complete imported project into an embedded stereo sample clip, preserving its tempo and length without requiring any VST at playback time."
    },
    {
      "title": "REAPER Serum projects can import as native Bifrost songs",
      "version": "2026.8.231",
      "date": "2026-09-21",
      "area": "Import",
      "type": "Improvement",
      "userFacing": false,
      "details": "ImportReaper now accepts --serum-captures and replaces matching captured Serum generator channels with native Bifrost devices while retaining the imported song's MIDI, channel tree, timing, and downstream effects."
    },
    {
      "title": "Serum source probe records the Env 2 route",
      "version": "2026.8.231",
      "date": "2026-09-21",
      "area": "Import",
      "type": "Improvement",
      "userFacing": false,
      "details": "The controlled Serum state probe confirms source 3 is Env 2, so the Pluck Melody 2 cutoff routing is documented as measured rather than left in the unknown-source gap list."
    },
    {
      "title": "Serum conversions carry non-sine sub oscillator octaves",
      "version": "2026.8.231",
      "date": "2026-09-21",
      "area": "Import",
      "type": "Bug fix",
      "userFacing": false,
      "details": "When Serum's square, saw, or pulse sub uses Bifrost's spare oscillator, its octave is now converted to the spare oscillator's coarse pitch instead of an accidental one-semitone offset."
    },
    {
      "title": "Serum verification keeps unpitched diagnostics",
      "version": "2026.8.231",
      "date": "2026-09-21",
      "area": "Import",
      "type": "Bug fix",
      "userFacing": false,
      "details": "The full Serum-to-Bifrost verification sweep now writes score records even when a deliberately unpitched patch produces an infinite pitch metric, instead of failing after rendering its audio pair."
    },
    {
      "title": "Serum conversion interpolates measured modulation curves",
      "version": "2026.8.231",
      "date": "2026-09-21",
      "area": "Import",
      "type": "Improvement",
      "userFacing": false,
      "details": "When a captured Serum modulation endpoint falls between probe positions, the converter now interpolates the measured display curve instead of reverting to proportional scaling. Nonlinear destinations such as filter cutoff retain their measured shape between samples."
    },
    {
      "title": "Bifrost republishes keep the current wavetable panel",
      "version": "2026.8.231",
      "date": "2026-09-21",
      "area": "Modules",
      "type": "Bug fix",
      "userFacing": false,
      "details": "Republishing an owned store module no longer treats its installed package origin as an export refusal. The Bifrost republish path now clears that package marker and carries the authored panel revision, including the oscillator wavetable designer, into the next version, even when an imported copy has a store-generated definition id or name metadata is supplied only by the listing. A newer authored layout also now outranks an older package-lent layout on this machine. The Play page restores the authored Serum-style perspective wavetable view beside each oscillator's Level and Pan controls, with the current frame highlighted among nearby scan frames. Its foreground trace is now a crisp one-pixel line without glow, and inactive frames fade progressively toward the back so the perspective reads cleanly. The development reseed path covers Bifrost even though it is no longer in the built-in picker catalogue."
    },
    {
      "title": "Store module previews show more presets and a livelier phrase",
      "version": "2026.8.231",
      "date": "2026-09-21",
      "area": "Sound engine",
      "type": "Improvement",
      "userFacing": false,
      "details": "Generator previews now sample five presets across the bank while staying fifteen seconds long, and the melodic preview phrase uses a three-bar bass pulse, syncopated arpeggio and closing chord so buyers hear more of a module's range and envelope behaviour."
    },
    {
      "title": "Serum conversions preserve oscillator filter routing",
      "version": "2026.8.231",
      "date": "2026-09-21",
      "area": "Sound engine",
      "type": "Internal",
      "userFacing": false,
      "details": "Bifrost now gives each converted oscillator and sub oscillator a patch-controlled filter send and a dry bypass leg. Serum's OscA>Fil, OscB>Fil and OscS>Fil switches are copied into appended route constants, preserving existing parameter slots while keeping unrouted oscillators audible outside filter 1."
    },
    {
      "title": "Serum source probing rejects an ambiguous source instead of guessing",
      "version": "2026.8.231",
      "date": "2026-09-21",
      "area": "Sound engine",
      "type": "Internal",
      "userFacing": false,
      "details": "The converter's source experiment now includes Pitch Bend and both Chaos-rate controls. Serum source ID 19 was tested against Vangelis Pad and left unpinned because Pitch Bend and Velocity responses were too close to separate (0.02683 versus 0.02076); unknown and ambiguous rows continue to be dropped rather than assigned a confident but incorrect source."
    },
    {
      "title": "Serum conversions preserve custom LFO shapes and more effect controls",
      "version": "2026.8.231",
      "date": "2026-09-21",
      "area": "Sound engine",
      "type": "Internal",
      "userFacing": false,
      "details": "The Serum converter now reads the second compressed state member that carries Serum's 1024-sample LFO curves, downsamples each to Bifrost's legal 16-point shape, and writes it into the matching LFO source. It also carries enabled wet amounts for delay, distortion, compressor, chorus and reverb, and uses the spare oscillator for non-sine sub shapes. The source fixture and shape round-trip tests pass; fidelity acceptance remains open for unsupported effect controls and unpinned modulation sources."
    },
    {
      "title": "Serum conversion now exports presets and preserves oscillator switches and envelope timing",
      "version": "2026.8.231",
      "date": "2026-09-21",
      "area": "Sound engine",
      "type": "Internal",
      "userFacing": false,
      "details": "Continued the Serum-to-Bifrost converter: writes preset JSON including generated wavetable recipes and modulation, respects disabled oscillators, copies macro values by index, avoids assigning Mod Wheel to an occupied Macro 4, and reports unsupported auxiliary modulation. Envelope times use a curve measured from the installed plugin because its display changes ms/s without updating unit metadata. Added plugin-free regression tests, preset save/load/apply checks, and nonzero verification exit status for silent segments. Wavetable substitutions and modulation depths remain approximate; this is not a completed fidelity acceptance run."
    },
    {
      "title": "Prepare upcoming compressed samples between web audio blocks",
      "version": "2026.8.230",
      "date": "2026-09-22",
      "area": "Web",
      "type": "Performance",
      "userFacing": true,
      "details": "The audio worker uses spare time to decode samples needed in the current or next bar, yielding between small decoder chunks whenever audio needs refilling. Preparation shares a 32 MiB decoded-PCM budget, cancels on edits, stop and seek, and publishes only complete samples. Oversized or late assets retain normal demand decoding. This reduces first-use decoding spikes without changing the sample PCM."
    },
    {
      "title": "Lower CPU and memory use for FFT sample pitch shifting",
      "version": "2026.8.230",
      "date": "2026-09-22",
      "area": "Sound engine",
      "type": "Performance",
      "userFacing": true,
      "details": "Existing songs using FFT pitch shifting reuse the same window coefficients and allocate only the buffers their fixed FFT size needs. Per-voice allocations fall by about 87%, with less CPU work during playback and at sample-clip starts. The arithmetic and playback state are preserved; comparisons against the previous engine verify identical PCM for fixed and changing pitches."
    },
    {
      "title": "Give web audio priority over background cache work",
      "version": "2026.8.230",
      "date": "2026-09-22",
      "area": "Web",
      "type": "Performance",
      "userFacing": true,
      "details": "Web playback fills its existing eight-buffer pool before starting, refills audio through yielding message tasks, and defers optional sample-cache work whenever audio needs refilling. Stale meter observations after a stall are coalesced. Buffer memory and steady playback lookahead stay unchanged. Adds a reproducible complex-song stress probe; the wider per-channel render-ahead work remains in progress."
    },
    {
      "title": "GPU accelerated offline sample stretching",
      "version": "2026.8.229",
      "date": "2026-09-22",
      "area": "Sound engine",
      "type": "New feature",
      "userFacing": true,
      "details": "Select Spectral (GPU offline) and enable GPU DSP in Audio preferences to accelerate suitable sample-clip cache, bounce and export jobs using DirectX or WebGPU. Offline rendering awaits complete results; unsupported devices, small or oversized jobs and GPU failures use the same algorithm on CPU. Existing algorithms and the default remain unchanged.",
      "tickets": [
        244
      ]
    },
    {
      "title": "Build audio and control components with Lua",
      "version": "2026.8.228",
      "date": "2026-09-22",
      "area": "Modules",
      "type": "New feature",
      "userFacing": true,
      "details": "Lua components use the existing MoonSharp engine with C# helpers that process whole 64-frame audio blocks. Includes oscillator, filter, envelope, delay, FFT and sample helpers; script-defined controls and value formatting; generator, effect and controller starters; undoable source editing and attached files. Scripts and files travel with projects and modules, including the browser audio worker and AOT builds. Instruction and helper-work limits silence faulty components.",
      "tickets": [
        259
      ]
    },
    {
      "title": "Browser media project restores on case-sensitive build hosts",
      "version": "2026.8.227",
      "date": "2026-09-22",
      "area": "Build",
      "type": "Fix",
      "userFacing": false,
      "details": "Corrects the browser's KNI media project reference to match the tracked XNA.Framework.Media.csproj filename exactly. The previous casing worked on Windows but prevented NuGet from restoring that dependency on Linux."
    },
    {
      "title": "Parallel release builds coordinate NuGet cache access",
      "version": "2026.8.227",
      "date": "2026-09-22",
      "area": "Build",
      "type": "Fix",
      "userFacing": false,
      "details": "Build containers now share NuGet's scratch directory alongside the package cache, allowing NuGet's filesystem locks to coordinate concurrent restores. This fixes missing temporary package files when parallel desktop heads first download the same .NET 10 runtime packs."
    },
    {
      "title": "Web host remains compatible with the deployed server container",
      "version": "2026.8.227",
      "date": "2026-09-22",
      "area": "Build",
      "type": "Internal",
      "userFacing": false,
      "details": "The static-file and service-proxy host retains its .NET 8 target and server package so the existing ASP.NET 8 container can start the new deployment. The browser application and its audio and analysis workers still use .NET 10 with AOT; their runtime is independent of the host."
    },
    {
      "title": "Linux release checks recognise platform wavetable rounding",
      "version": "2026.8.227",
      "date": "2026-09-22",
      "area": "Build",
      "type": "Internal",
      "userFacing": false,
      "details": "Unblocks the existing Linux beta test failure without changing audio generation. The wavetable identity test retains its exact Windows baselines and additionally accepts five measured Linux hashes. Comparing the same assemblies on both platforms found a maximum sample difference of 1.788139343e-7 across all 557,056 samples. Release tests now log assertion details instead of suppressing them with quiet verbosity."
    },
    {
      "title": "Beta web builds use AOT compilation",
      "version": "2026.8.227",
      "date": "2026-09-22",
      "area": "Build",
      "type": "Improvement",
      "userFacing": true,
      "details": "Beta browser builds now use the same ahead-of-time compilation settings as releases, accepting longer build times for representative interface and audio performance. The pipeline explicitly enables AOT, preserves IL needed by runtime fallbacks, checks that the application AOT object exists, and removes the obsolete interpreter-performance warning from the beta banner."
    },
    {
      "title": "Browser audio runs independently of the interface",
      "version": "2026.8.227",
      "date": "2026-09-22",
      "area": "Web",
      "type": "Improvement",
      "userFacing": true,
      "details": "Song playback now renders in a dedicated .NET Web Worker connected directly to the AudioWorklet through a bounded pool of transferable audio buffers. UI scheduling no longer drives audio refills. Ordered song snapshots preserve live graph identities and reuse unchanged sample assets; playback generations reject old audio after stop or seek. Playhead, meters and visualization data return to the interface. A separate worker handles tempo detection and waveform peak batches, with stale-result checks. This uses the .NET 10 AOT assets without enabling shared-memory Blazor threading or requiring cross-origin isolation.",
      "tickets": [
        243
      ]
    },
    {
      "title": "Prove AOT audio code can run in a separate browser worker",
      "version": "2026.8.227",
      "date": "2026-09-22",
      "area": "Web",
      "type": "Internal",
      "userFacing": false,
      "details": "Research for bug 243 compares direct .NET workers, SpawnDev, Tewr, shared-memory runtimes and OffscreenCanvas. A reproducible tools/WebWorkerProbe loads the existing AOT runtime in a dedicated worker without starting Blazor or enabling WasmEnableThreads. The existing C# oscillator/filter stress renderer runs while the main-thread UI timer continues, and its first PCM block matches the UI runtime byte-for-byte. The probe needs no cross-origin isolation. docs/web-threading-options.md records the results, limits, ownership and audio transport design, and a staged plan. This adds research tooling only; production audio and UI scheduling are unchanged and the ticket remains open."
    },
    {
      "title": "Studio application builds move to .NET 10",
      "version": "2026.8.227",
      "date": "2026-09-22",
      "area": "Build",
      "type": "Internal",
      "userFacing": false,
      "details": "The browser, web host, and Windows desktop heads now target .NET 10, matching Linux and macOS. The browser uses nkast.Wasm 10.0.3 and matching JavaScript assets; the sibling KNI Blazor backend gains a .NET 10 target and is rebuilt from source because the old bindings call the removed InvokeUnmarshalled API and the new bindings move JSObject to another assembly. The release image installs wasm-tools instead of wasm-tools-net8. Shared libraries retain .NET 8 compatibility, and the test suite runs on both runtimes. The managed gzip test uses an explicit empty gzip member because .NET 10's native compressor can emit zero bytes for an empty write. This is the runtime migration part of bug 243; browser threading remains under investigation, so this entry does not close that ticket. See docs/net10-web-threading.md for the compatibility findings and threading requirements."
    },
    {
      "title": "Bifrost draws its wavetables",
      "version": "2026.8.227",
      "date": "2026-09-21",
      "area": "UI",
      "type": "Improvement",
      "userFacing": true,
      "details": "Each of Bifrost's three oscillators now has a wavetable display beside its level and pan: the whole table drawn in perspective with the frame being played picked out of it, so moving Position reads as travelling through a table rather than one waveform changing shape. It draws the WARP RESULT rather than the raw table, which is the point - Bend, Sync, Fold and Mirror were the knobs whose effect nothing on the panel showed, and now a fold looks like a fold. A table you designed yourself is drawn too: its frames are generated from the recipe on the oscillator, once, off the audio and UI threads. The display samples a few dozen times a second rather than per audio sample and redraws nothing when nothing has moved, so three of them cost a fraction of a millisecond per frame.",
      "tickets": [
        256
      ]
    },
    {
      "title": "The Table designer gets a window of its own",
      "version": "2026.8.227",
      "date": "2026-09-21",
      "area": "UI",
      "type": "Improvement",
      "userFacing": true,
      "details": "Bifrost's Table designer was a page of an oscillator column, and a column is a tight fit for it: the family list showed about four rows against thirty-three families and did not scroll to the one you were on, and most of a family's settings ellipsised to \"Peak st...\" and \"Funda...\" with the real name only in a tooltip. It is a proper window now - resizable, draggable, opened from a one-line card where the Play/Design tabs used to be. The family list has the full height and scrolls the family you are on into view when it opens; every setting shows its whole name over its knob, still in its own units; the preview of the frames is several times the size it was. The card says what the oscillator is playing, so the column answers that question without being opened, and the column’s own knobs are live all of the time now the tabs are gone. Nothing about the tables themselves changed - a design is still the same object the built-in bank is a list of, still anchored by the family’s id rather than its position in a list that a module or a pack can move."
    },
    {
      "title": "Two lessons from the selector fix, written down beside the one they are cousins of",
      "version": "2026.8.227",
      "date": "2026-09-21",
      "area": "Modules",
      "type": "Internal",
      "userFacing": false,
      "details": "The selector work produced two things worth keeping that are not code, and both now sit in docs/vst-matcher-design.md beside section 5.5, which is about values that never arrive. Section 5.6 is a cost estimate that measured the wrong implementation. Widening the selector's declared output range was priced by rendering - change the declaration, fingerprint all nineteen factory devices, see what moves - and Laufeyson moved, and four other wires were shown to depend on the same number while sitting at the one step where a wrong span is invisible. The measurement was correct and the recommendation it produced, leave this alone, was wrong: it had measured changing the number in ONE place, when the number lived in four. Done properly, through a single named constant that every scaled wire is built from, all five track automatically and every device renders bit-identically. The tell is worth having: when the predicted damage has no pattern - five unrelated devices breaking - suspect the patch rather than the change, because that is the signature of measuring the sloppiest possible version of it. Locate what depends on a literal with the crude version; price the careful one. Section 5.7 is the test shape, and it generalises past selectors: a wire's scaler is affine, so at step 0 the slope does not appear at all, which is why four of the five wires rendered perfectly in their default patch and wrongly everywhere else, and why a fingerprint of the default patch could never have caught it. A control with N positions wants N assertions, not one, because the default state is usually the identity - the value that is right by accident under any wrong implementation. Writing them out per wire rather than as a loop also meant each failure named what that switch is FOR, so the Rat 030 fault was diagnosable from the failure text alone."
    },
    {
      "title": "The Table Designer is cramped, and the design guide now says so",
      "version": "2026.8.227",
      "date": "2026-09-21",
      "area": "UI",
      "type": "Internal",
      "userFacing": false,
      "details": "Looking at the finished Table Designer in the running app rather than at its tests turns up two things worth writing down before somebody rediscovers them. The family list viewport holds about four rows and there are thirty-three families to scroll through, and it does not scroll the selected row into view when the panel opens on an existing design - so a design made on a family near the end of the list opens on a list that appears to be showing something else. And four of the five settings on a typical family ellipsise to something unreadable: 'Peak st...', 'Funda...', 'Harmo...'. The tooltip carries the full name, which is the documented behaviour for every caption on that surface, but it is carrying more of the label here than a caption should have to. Both follow from the designer living inside an oscillator column, which was the right call for other reasons the design guide sets out - a panel already full has no spare space, and a table is designed FOR an oscillator, so a designer parked elsewhere would have to be told which of three it was pointed at. The fix is more room, a modal or a full-width page, and that is a decision about where the designer lives rather than a tweak to how it draws. Recorded as a known constraint in the design guide instead of quietly shipped as finished. Also here: audition screenshots join audition renders in .gitignore, on the same reasoning - evidence for whoever reviews a change, not something the repository carries afterwards."
    },
    {
      "title": "Design your own wavetables",
      "version": "2026.8.227",
      "date": "2026-09-21",
      "area": "Modules",
      "type": "New feature",
      "userFacing": true,
      "details": "Every oscillator column on Bifrost now has a Design tab beside its Play tab. Pick a synthesis family - phase distortion, a harmonic tilt, a wavefolder, any of the ones the built-in bank is made from - turn its settings in their own real units, watch the wavetable redraw as you turn them, and name it. The table you make is stored on the oscillator, travels with the song and with any preset you save, and plays exactly as a built-in one does: the shipped tables ARE saved settings of this panel, so there is one generator and one representation rather than a factory set and a user set that can drift apart. A design generated on a build that has its family and opened on one that does not is left alone and says what it is missing, rather than being quietly reset to something else. Generating a table costs tens of milliseconds, so the preview is made off the UI thread and a knob drag never stalls the panel.",
      "tickets": [
        255
      ]
    },
    {
      "title": "A panel tab stays open while you use it",
      "version": "2026.8.227",
      "date": "2026-09-21",
      "area": "UI",
      "type": "Bug fix",
      "userFacing": true,
      "details": "On a module panel with tabbed pages, anything that redrew the panel - committing a knob, dragging a modulation source onto something, resizing the window - threw you back to the first page of every tab set. Which page is open is now carried across the redraw (ModuleUiSurface.RestoreGroups). It was invisible on a page you only read and fatal on one you work on: the new Table Designer commits on every action, so before this it closed itself the moment it was used."
    },
    {
      "title": "Fifteen more wavetables, and every built-in table now has knobs",
      "version": "2026.8.227",
      "date": "2026-09-21",
      "area": "Sound engine",
      "type": "New feature",
      "userFacing": true,
      "details": "The bank goes from seventeen tables to THIRTY-TWO, off thirteen new synthesis families: phase distortion (CZ sweep), PD resonance (Resonator), a baked two-pole resonant filter sweep (Filter sweep), a comb sweep (Jet flange), band-limited hard sync (Sync lead), integer-ratio FM at three ratios (FM brass, FM nasal, FM glass), a wavefolder, a Chebyshev harmonic ladder, an even-harmonics-only table (Octave stack), a bit-crusher, a seeded noise walk (Drifter), a dispersive phase warp (Phaser sweep) and a dual pulse - two pulses per cycle whose widths move against each other, which is the one classical analogue shape the rest of the bank left out and the only one whose notches a single pulse cannot place. Thirty-two is the number the set was designed around: one screen, and every entry a different KIND of table rather than another flavour of one. Every table is written from published synthesis maths - nothing is derived from anyone else's wavetable data, which is what makes the set shippable at all. Anything built in the sample domain (folding, sync, crushing, phase distortion) is projected onto its first 32 harmonics before it is stored, so no new table carries the aliasing the mip tiers cannot fix; measured residual above harmonic 32 is around 1e-15 of in-band energy, which is float round-off. The same pass gave the original seventeen families real parameters, lifted from the literals their generators already carried, so every built-in table is now something the Table Designer can shape - and all seventeen still generate bit-identical frames at their defaults, which the identity test proves rather than assumes. Generation is lazy: the whole bank is 2.2 seconds and 70 ms a table, so a song that uses three pays for three rather than for thirty-two, which is what keeps the count free to grow on the web build. A new test prints the whole bank's spectral travel and holds every new table above the bar the bank's own dead-table figures set.",
      "tickets": [
        255
      ]
    },
    {
      "title": "Rat 030's square switch has never played a square, and now does",
      "version": "2026.8.227",
      "date": "2026-09-21",
      "area": "Sound engine",
      "type": "Bug fix",
      "userFacing": true,
      "details": "The 303's Waveform switch has two positions, Saw and Square, and the Square one has never reached the pulse wavetable it names. The wire was authored as a plain 4-to-1 scaler, which reads as 'step 0 is saw, step 1 is pulse' and is not what a wire does: a wire's scaler is affine over the SOURCE port's declared span, so step 1 landed at 3.57 and the oscillator played whatever that truncates to. The switch changed the sound, which is exactly why nobody caught it, but never to the waveform on the label. Found while widening the selector's declared output range, which is the other half of this change: that range is now one named constant instead of the number seven written out in four places, and every scaled wire in the app is built through the helper that multiplies by it, so none of them can drift from it again. There is a test per wire now, asserting what each STEP of each switch does, because that is the only shape of test that could have caught this - four of the five sit at step 0 by default, and step 0 is the one value a wrong span still gets right, so a render of the default patch looks perfect either way. Eleven Rat 030 presets were re-voiced as a result. Seven are squares that collapsed onto each other the moment the fix handed them the real pulse table, which is spectrally simpler than the accidental blend they had all been sharing; four are saws moved to restore the bank's margin. Each moved on the knob its name is about rather than being jittered until a metric passed - Hollow got a fixed formant, Clicky got full envelope modulation on the shortest decay, Square Slides got resonance and the longest decay in the bank so a glide has something to drag through. Every other device renders bit for bit as it did, checked by rendering them rather than assumed.",
      "tickets": [
        255
      ]
    },
    {
      "title": "Six decisions locked: the wavetable set is generated, and the converter never reads Serum",
      "version": "2026.8.227",
      "date": "2026-09-21",
      "area": "Modules",
      "type": "Internal",
      "userFacing": false,
      "details": "The six open questions in the Serum scoping write-up are answered, and two of them change the shape of the work rather than just settling it. The converter will be SHIPPABLE ONLY - it never reads a local Serum installation, there is no personal mode and no branch in the design, and every oscillator in a converted patch gets a generated stand-in. That lowers the converter's own ceiling, because a wavetable is the single largest contributor to a patch's identity and it is the one thing that will never cross over exactly; and it makes the generated set load-bearing, because everything else in the mapping is arithmetic that either works or is visibly wrong, while the oscillator is the one place quality can be lost and not got back. So the generator is now the first-class deliverable and the converter is its client. Thirty-two built-in tables, all generated from published synthesis technique so that no third-party wavetable data ships. The Table Designer panel ships WITH that set rather than after it, on the reasoning that if a generated table is always the substitute then users need to be able to shape one - and with a hard constraint: the panel and the built-in 32 must be one code path, the built-ins being nothing more than saved settings of the designer, because two code paths for one thing is exactly how a wavetable's panel and its audio graph came to disagree about which table was playing. A NEW wavetable content-pack format id rather than a reuse of wav, with the release-order cost accepted and the work sequenced so nothing waits on it: built-in set first and depending on the store not at all, the format id riding the next beta, store acceptance whenever that lands. The selector's output range gets widened too rather than left, which is the one that costs and is done on its own. And the Phase Plant matching is set aside for the ten tracks that became Serum, with Distorted Arp keeping its hand-build.",
      "tickets": [
        255
      ]
    },
    {
      "title": "What the wavetable fix moved, and the half of it deliberately left alone",
      "version": "2026.8.227",
      "date": "2026-09-21",
      "area": "Modules",
      "type": "Internal",
      "userFacing": false,
      "details": "The record of what #255 actually changed, written down while it is known rather than rediscovered later. 130 of the 132 factory presets across the two Bifrost banks render differently, because that is what fixing it means. No shipped demo moved at all, checked two ways that agree: a structural walk of all four ezproj files finds no selector above step 7, and a blunt text count over the same files finds 35 selectors with every value at 0, 1 or 2. None of the demos uses Bifrost. So there was nothing to re-pin and the entire audible fallout landed on the preset banks, where five presets were re-voiced for reasons given one by one. Before-and-after renders of the four most changed are in auditions/255-*, made by writing 7 into the table selector, which is exactly what the clamp did since the port's constant was the only path. Still open and deliberately not bundled: the selector's OUTPUT range is still 0..7, and that number is load-bearing - a wire's scaler is affine over the source's declared span, SelectorStep hard-codes the seven, and five factory wires are hand-computed against it. Widening it was measured to change Laufeyson's render, and four of the five wires would break silently rather than visibly because they sit at step 0 at init where the offset is unchanged. It would buy a scaled wire off a selector with more than eight steps, which nothing in the app wants, so the recommendation is to leave it. Written up in docs/serum-to-bifrost-scoping.md 5a-i-b so the next person to widen it knows what they are moving.",
      "tickets": [
        255
      ]
    },
    {
      "title": "Bifrost's nine missing wavetables are back: presets now play the waveform they name",
      "version": "2026.8.227",
      "date": "2026-09-21",
      "area": "Sound engine",
      "type": "Bug fix",
      "userFacing": true,
      "details": "Bifrost offers seventeen wavetables and could only reach the first eight. Anything above table 7 was silently replaced by table 7, Metallic, while the panel went on showing the name you picked - so Hyper saw, Vox choir, Dark pad, Hollow, FM bell, PWM sweep, Additive stack, Metal bar and Sub triangle were names you could select and never hear. It reached 81 of the 82 factory presets, across 174 oscillator slots, and both of Bifrost's second and third oscillators shipped defaulting to tables that did not exist for them. The cause was one number: a selector component declared its input range as 0 to 7 whatever its step count, and the audio graph clamps a knob to its declared range on the way in, using the raw declaration rather than the widened one the panel draws from. Only the input range moved; the output's 0 to 7 is load-bearing for every wire that scales off a selector and is deliberately left alone. Four presets needed their levels re-set afterwards, because a preset written against the waveform it never got is a preset written against the wrong sound: Metal Pluck had gone quiet under a band-pass that the real Metal Bar feeds far less energy into, Long Horn, Deep Water and Harbour Wall all clipped once their oscillators became the full-bodied tables they had asked for, and Sub Growl had lost its growl beneath a sub that was suddenly real. Everything else in the app renders bit for bit as it did.",
      "tickets": [
        255
      ]
    },
    {
      "title": "Four wavetable generators prototyped, and every one moves further than anything in the bank",
      "version": "2026.8.227",
      "date": "2026-09-21",
      "area": "Modules",
      "type": "Internal",
      "userFacing": false,
      "details": "The plan for a wavetable library is to generate it from first principles rather than ship anyone's tables, so the question that decides it is whether generated families are actually any good. Four were prototyped at Bifrost's exact geometry - 32 frames of 1024, every frame band-limited to 30 harmonics before storage - and measured with the bank's own yardstick, total spectral travel across the position knob. The yardstick was calibrated first, because a number is worth nothing until it agrees with a known one: the bank's comments record a plain sine-to-triangle crossfade at 0.17 and a sine-to-saw at 0.74, and reimplementing those two measured 0.18 and 0.75. So the new numbers are directly comparable. Casio phase distortion measures 1.18, a baked resonant filter sweep 3.45, CZ-style PD resonance 4.59, and a wavefold drive sweep 5.90 - every one of them further than the BEST figure recorded against the existing bank, and between seven and thirty-five times the weakest. That is what the category is for: their morph axis is a synthesis parameter being swept rather than two shapes being crossfaded, so the knob keeps doing something across its whole throw. Each is the published maths for a named technique, written from the technique - no third-party table was read and no generator parameter was fitted to one, and the write-up says so explicitly because that line is what makes the whole approach shippable. Renders of all four are in ~/Documents/ezmuze-wavetable-prototypes. Also here: the six decisions this scoping cannot make on the user's behalf, each recorded with a recommendation rather than quietly settled."
    },
    {
      "title": "Nine of Bifrost's seventeen wavetables have never reached the engine",
      "version": "2026.8.227",
      "date": "2026-09-21",
      "area": "Modules",
      "type": "Internal",
      "userFacing": false,
      "details": "Found while scoping a wavetable library, and it is the reason to fix before adding: a ctl.selector's value port declares Max 7, the selector compiles to a constant node, and an unwired input is clamped to its RAW descriptor range because the graph builds its port descriptor from the module registry rather than from the choice-enriched one the face uses. Bifrost's table selectors carry seventeen steps. So tables 8 to 16 clamp to 7 and render Metallic while the panel goes on showing the right name. Proved by rendering rather than by reading: a real device, one note, and the resolved constant read back out of the compiled graph - tables 8, 12 and 16 come back BIT-IDENTICAL to table 7, while 0, 4 and 7 differ from each other as they should. Osc B defaults to 12 and Osc C to 16, and 81 of the 82 Bifrost factory presets ask for a table above 7 across 174 oscillator slots, every one of them currently playing Metallic. The sub escapes it by writing its table straight onto the oscillator's own param. Not fixed here, because the obvious larger fix is wrong and wants its own commit: widening only the value INPUT restores the tables and leaves all nineteen factory devices bit-identical, but widening the OUT port as well changes Laufeyson's output, since Transform maps a source port's DECLARED span onto a wire's scale and five factory wires - Bifrost's sub octave, Freyja's high pass, Laufeyson's two wave selectors and Rat 030's waveform - are built on the 0..7 being exactly seven, as is the hardcoded 7 in SelectorStep. A census of all nineteen devices finds 57 selectors and exactly three over eight steps, all of them Bifrost's. Written up in docs/serum-to-bifrost-scoping.md."
    },
    {
      "title": "Seventy-five rows, not twenty: the Serum mod-matrix decode checked against every patch",
      "version": "2026.8.227",
      "date": "2026-09-21",
      "area": "Modules",
      "type": "Internal",
      "userFacing": false,
      "details": "The offsets for Serum's modulation matrix were cross-checked on two patches and then asserted for ten, which is the kind of gap that makes a scoping document wrong later rather than now. Run over all ten: every one of the 75 non-empty records matches its named 'Mod N amt' parameter to better than 2e-4, once the parameter's normalized 0..1 is mapped onto the row's signed -1..+1 as 2v-1, and the non-empty row count equals the number of amounts off default in every patch without exception. That second property is the one a reader actually needs - a record is non-empty exactly when its parameter is off its 0.5 default - and it now has evidence rather than a plausible story. The wider run also turned up something two patches could not show: Guitar Simulator fills all sixteen rows, so sixteen is the block's real size rather than a coincidence of small patches, and a patch carrying more than sixteen routings keeps them somewhere this has not looked yet."
    },
    {
      "title": "A Serum patch turns out to be readable, which makes a converter a mapping rather than a search",
      "version": "2026.8.227",
      "date": "2026-09-21",
      "area": "Modules",
      "type": "Internal",
      "userFacing": false,
      "details": "The song being ported has been rebuilt with Serum in place of ten of its eleven Phase Plant instruments, on the theory that Serum is closer to Bifrost and so easier to convert. Measured rather than assumed, and the theory holds. Serum reports 317 named parameters with units and its own display text - 'Fil Cutoff / Hz / 425', 'LFO1 Rate / 1/4' - straight through the VST3 host that already exists, and restoring each patch out of the project moves between 35 and 86 of them off their defaults, so the state verifiably arrives. The four things the parameter list does NOT carry all live at fixed offsets in the state blob, because Serum's state is its own zlib stream that inflates to exactly 172,736 bytes for every patch: the wavetable and noise filenames, the preset name and author, and the modulation matrix, which is sixteen forty-byte records at offset zero. That last one was cross-checked rather than guessed - the amount field of record N equals the named parameter 'Mod N+1 amt' on all twenty rows across two patches, the non-empty row count equals the number of amounts off default on all ten, and the destination field IS the VST3 parameter index, so the routing decodes into the same names the rest of the mapping is written against. Only the source enum is still unpinned, and that is one controlled experiment rather than a research problem. The scoping write-up is docs/serum-to-bifrost-scoping.md: the project inventory, the offsets, a feature-by-feature mapping table, and the measured ceiling - over the 576 values that are off default across the ten real patches, 55 percent map directly, 16 percent approximately and 29 percent not at all, with the synth core at 69 percent direct and the effects rack carrying almost all of the loss. tools/SerumProbe is the throwaway harness that printed it."
    },
    {
      "title": "A green test on wrong content, and the rule that fixes it: main is authoritative for anything it has",
      "version": "2026.8.226",
      "date": "2026-09-21",
      "area": "Build",
      "type": "Internal",
      "userFacing": false,
      "details": "A test passed on content that was wrong, and it is the worst member of the family in section 5.5 because a green test is what people reach for when they are tired. Merging this branch's changelog with main's, 46 entries existed on both sides - and in every one main carried the release pipeline's resolved 2026.8.224 while the branch still said NEXT, because main was stamped after the branch forked. Collapsing them by title and keeping both copies would have re-listed 46 SHIPPED entries as unreleased: the stamp shift CLAUDE.md warns about, arriving through a merge rather than a rebase. ShippedChangelogOrderTests was green throughout. It checks that entries descend by version and that nothing unreleased sits below something released, and both were true of the broken file because the duplicates were internally well-ordered. The test checks ORDERING; the fault was IDENTITY. A test tells you about the property it tests and nothing whatever about the one it does not, and a green result on a file you have just rewritten is worth exactly the overlap between the two. The durable answer is a rule about authority rather than a cleverer merge: MAIN IS AUTHORITATIVE FOR ANYTHING IT HAS - entries that exist only on the branch go on top as NEXT, everything else is main's list in main's order with stamps untouched. That rule survives the pipeline stamping again mid-merge, which it did twice while this was being resolved.",
      "tickets": [
        239
      ]
    },
    {
      "title": "Two independent probes, two different blind spots, one real fault sitting between them",
      "version": "2026.8.226",
      "date": "2026-09-21",
      "area": "Modules",
      "type": "Internal",
      "userFacing": false,
      "details": "The same fault produced a confident clean result from two independent probes, for two different reasons, and that pair is now the worked example beside the rule it proves. One probe rendered the phrase ALONE on a fresh device; the fault needs state carried across segments, so it could never fire. The other churned with NATIVE candidate renders; a native render cannot touch the hosted plugin's state, so it could never fire either. Both reported clean, in the same words a probe uses when it has genuinely cleared something, and between them sat a real intermittent fault that fires about one run in three. The lesson is the third clause of the rule this section already carried - when a check comes back clean, check what your check was blind to - and the question to ask of a green result is not whether it passed but what it would have had to touch in order to fail. Also merged from the other agent: a three-way sweep establishing that their two runs really were the same configuration, which rules out the segment-shortening explanation offered for the disagreement, and confirms the fault is visible under the short segments too at lower frequency - shortening reduces visibility rather than removing it. Their finding that rendering twice and keeping the second reading is NOT a repair is worth keeping loose from the rest: in 2 of 7 firings the later readings differed as well, which kills the obvious cheap workaround before anybody builds it.",
      "tickets": [
        239
      ]
    },
    {
      "title": "The drifting render is the plugin's, triggered by reusing one device across segments",
      "version": "2026.8.226",
      "date": "2026-09-21",
      "area": "Modules",
      "type": "Internal",
      "userFacing": false,
      "details": "The intermittent render drift is the HOSTED PLUGIN's, not this engine's, and the trigger is state carried across segments. Three variants, twenty passes each, same process, same machine. The phrase rendered ALONE on a fresh plugin device: identical on all twenty. The same phrase rendered through ONE device after the other four segments - which is exactly what the reference path does: DRIFTS on 2 of 19 repeats, worst 1.5e-1 at 4.499 seconds. A native module through the identical code path: identical on all twenty. So the engine is not implicated, the pin is the correct and only fix because a hosted plugin cannot be made deterministic from outside, and the search for a mechanism in our code stops here. The timing is not arbitrary either: 4.499 s is the LAST NOTE-OFF of the phrase, in both its polyphonic and monophonic forms, and the other divergence seen earlier at 0.01 s is the first note-on - both voice lifecycle events, which is consistent with something in the plugin's own state accumulating across renders and mattering most where a release decays toward zero. A tight loop rendering the phrase by itself never reproduced it in a hundred passes, which is why the first attempt to reproduce this came back clean and prompted a retraction that was itself wrong: the probe was blind to the one condition that matters, reusing the device. The drift probe is kept because it asks the question in the form that answers it - plugin against native, alone against in sequence - and it reports how many passes it took rather than declaring a clean run to be proof.",
      "tickets": [
        239
      ]
    },
    {
      "title": "The drifting reference was real after all, it is intermittent, and it lands on the column that orders the shortlist",
      "version": "2026.8.226",
      "date": "2026-09-21",
      "area": "Modules",
      "type": "Internal",
      "userFacing": false,
      "details": "A reported fault - a reference not rendering the same twice - was believed untested, then retracted as unreproducible, then found. All three steps were wrong in different ways and the sequence is the point. The report named patch 1 and blamed Phase Plant's seed random. Tested with the SHORT segments the fast build uses: all eleven references byte-identical across two separate processes, and seed random is not in patch 1 at all - it is in patches 6 and 7, which are also identical. So it was retracted. Tested again at FULL segment length: patch 4 drifts by 4.3e-3. Run again: patches 1 AND 4 drift, by 7.7e-3 and 1.5e-1. Run again: all clean. It is real, it is INTERMITTENT, and it needs the long segments to show - which is why one clean measurement proved nothing and why the retraction was itself an artefact of testing under the fast configuration. The original report was right about patch 1 and about the order of magnitude; only the cause was misattributed. WHERE IT LANDS IS THE BAD PART: both times the drift is on the \"phrase\" segment, the held-out one, which is the column the shortlist is ORDERED BY, and a 1.5e-1 wobble there is the size of the gaps between shortlist neighbours. So the held-out ranking for those two references has never been stable, and patch 4's second place - one of the five choices made by ear - rests on a number that moves. Pinning the reference is therefore a repair and not defence in depth, and the class comment saying otherwise has been corrected to what was measured. The verify now names the segment and the time, so the next occurrence localises itself.",
      "tickets": [
        239
      ]
    },
    {
      "title": "Every reference is now a stored file - and the fault that prompted it could not be reproduced",
      "version": "2026.8.226",
      "date": "2026-09-21",
      "area": "Modules",
      "type": "Internal",
      "userFacing": false,
      "details": "Each reference is now rendered once and kept as audio, and every score is taken against the stored file. The honest part first: this was built to fix a reported fault - that patch 1 \"Nasty Reese\" does not render the same twice, blamed on Phase Plant's seed random - and THAT FAULT COULD NOT BE REPRODUCED. Rendering all eleven references in two separate processes and comparing the stored audio sample by sample, every one is byte-identical on every segment. The attribution is wrong as well: seed random does not appear in patch 1's state at all, whose only seed is \"stable\"; the two patches that do carry a random seed are 6 \"I Saw A Sine\" and 7 \"Chaos Harp\", and both of those also rendered identically across processes. One untested thing might explain the original observation: a speed-up landing at the same time SHORTENED THE SEGMENTS, so two runs described as the same configuration may not have been, and a reference rendered before and after that differs for a reason which is not randomness. The pin is kept anyway, for the reason the design doc gave long before this came up: a stored reference is a fixed target BY CONSTRUCTION, so runs are comparable across time, across builds and across changes to the renderer, and no future argument about determinism can touch a number scored against a file. It costs one render per reference per configuration and a few hundred megabytes. That is defence in depth rather than a repair, and calling it a repair would be claiming a fault that could not be found. The coarse sieve's reference is pinned too, since it decides which hundred candidates are ever scored properly. Also adopted here: the other agent's speed-up, which takes a nine-reference sweep from 93 minutes to about five - at that price a re-run stops being a decision, which is what makes correcting a reference cheap enough to do the moment a doubt appears.",
      "tickets": [
        239
      ]
    },
    {
      "title": "The patch matcher's catalogue search goes from 87 minutes to 6.5, and the scores it changes are named",
      "version": "2026.8.226",
      "date": "2026-09-21",
      "area": "Build",
      "type": "Performance",
      "userFacing": false,
      "details": "Stage 0 ranks 634 candidates against 9 references — 5706 evaluations, about 44 hours of audio — and had been run three times at 87 minutes each. It now takes 6.5 minutes on the same box under the same load, from five cuts, and the profile that ordered them was the first surprise: rendering is a THIRD of this tool's work and scoring is two thirds, with the YIN pitch detector alone at 41%. The render cache everyone expected to be the structural win can only ever remove the third, so it is the weakest lever here and is off by default. (1) IT WAS BEING BUILT IN DEBUG. `dotnet run` builds Debug and Debug leaves Ezmuze.Audio unoptimised: 1145 ms a candidate against 288 ms Release, with scores BYTE-IDENTICAL over all 651 candidates. The biggest speed-up available was free and invisible, because a Debug run gives the right answer and just takes three times as long; the tool now refuses to start a long run from a Debug engine. (2) SEGMENTS AS LONG AS THEY NEED TO BE. Each was a hand-set 8 s chosen while the analysis window was broken and never re-justified; a segment now runs to its last note-off plus one second, the same rule the pitch window uses, which is 17.25 s of audio a candidate instead of 28. This one DOES change scores: measured over all nine references by rendering once at the old lengths and scoring the same audio twice, the top-ten set survives 5-10 of 10 and the leader changes on two of nine — but against the only human ground truth this metric has, the five candidates the user picked by ear, the mean rank moves from 2.0 to 2.6 and all five stay in the top four. (3) NO PITCH WHERE PITCH IS NOT TRUSTED. The veto has read only `hold` and `velocity` for months; YIN was still running on the other two at 689 ms a candidate, 18% of the whole search, feeding nothing. Scores byte-identical; the advisory transposition column now abstains where it used to say a confident '+1 st' on a 55 Hz reference the detector cannot hear. (4) TWO-STAGE. The whole catalogue is ranked on the first two seconds of the real segments, the top 100 are re-scored in full. Two seconds is a bad final ranking (the ear's five fall to mean rank 9.0) and a reliable sieve: across all nine references every top-ten candidate sits inside the coarse top 30, so a gate of 100 is three times past the evidence and the shortlist that comes out is the SAME shortlist, not an approximation. (5) A RENDER CACHE keyed by a hash of the engine's module version ids, the device's structure with its Guids replaced by positions, and the segment — with demo channels carrying a hash of their song file and anything that cannot say what it depends on refusing to be cached. Two new tests earn the rest: --prefix-check found that a shortened render was NOT a prefix of the long one (a bar is 2756 ticks AND 16 frames, so rounding the total length to a tick leaves the last bar ending mid-tick) by forty samples in the fifth decimal, which a tolerance or a comparison of scores would both have called equal; and --cache-verify scores a sample uncached, cold-cached and warm-cached and compares raw bits, with a demo channel forced into the sample. Three flags including --coarse-gate were also being parsed BELOW the --stage0-all handler, so the only mode that takes an hour accepted them and silently ignored them. Checking the gate also turned up something older and worse: A REFERENCE RENDER IS INTERMITTENTLY NOT REPRODUCIBLE. Three sweeps under an identical metric signature, catalogue signature, seed and binary give byte-identical rows on eight of the nine references; on patch 1 two of the three agree and one differs on 99 of its 100 rows by 6.0e-3, and at the old full segment lengths the matcher measured the same fault on patches 1 and 4 at up to 1.5e-1 — the width of the gaps between shortlist neighbours. It is NOT the `seed: random` field first blamed here: that does not appear in patch 1 or 4, and patches 6 and 7, which carry it, stay clean. A new --reference-drift probe hashes a reference rendered three times with churn in between: it fires in 7 of 14 trials, and in 5 of those only the FIRST reading is odd while the second and third agree bit for bit, so 'render it twice and keep the second' is not a repair either. The mechanism was then settled by DriftProbe with the control this probe lacked — the phrase alone on a fresh device is identical on 20 passes, all five segments through ONE device drifts on 2 of 19, and the same path with a native module is identical on 20 — so it is the hosted plugin and the trigger is state carried across segments within one device, which also makes 'only the first reading is odd' chance rather than structure at that failure rate. It matters because both full-length drifts land on the held-out phrase, which is the column the shortlist is ORDERED by. Pinning the reference as stored audio fixes it by construction.",
      "tickets": [
        239
      ]
    },
    {
      "title": "Correcting the phrase's velocity overturns a choice the ear had already made",
      "version": "2026.8.226",
      "date": "2026-09-21",
      "area": "Modules",
      "type": "Internal",
      "userFacing": false,
      "details": "The velocity correction was applied to the search segments first and the held-out phrase kept its own note velocities, so the shortlists came back with search scores that had moved and held-out scores identical to six decimal places. That looked like the exact-zero tell that has caught four other faults today; it was not - held-out was untouched BY CONSTRUCTION - but chasing it found that the fix was half done, and the missing half was the one that mattered. THE SHORTLIST IS ORDERED BY THE HELD-OUT PHRASE, so correcting only the search segments could change which candidates reached the top ten and could never reorder them. The phrase's four notes carry a musical shape - 0.9, 0.7, 1.0, 0.55 - which is worth keeping; what was wrong was its absolute level, striking a track that plays at a median of 42 of 127 as though it played at full force. The shape is now scaled by the track's median and the held-out column recomputed, which costs three minutes rather than another ninety because only ten candidates per reference carry one. It changes an answer. For Sawophysical, the softest-played track in the corpus, the candidate chosen by ear falls from second to EIGHTH and a new winner appears that nobody has heard - Bifrost / Squashed at 0.348, against a previous best of 0.410 and the chosen candidate's 0.464. The five tracks that play at 123 to 127 are unchanged, which is the sanity check that the mechanism does nothing where nothing should happen. Fresh A/B files are cut for the three tracks whose shortlists moved.",
      "tickets": [
        239
      ]
    },
    {
      "title": "The automated track needs no new reference, and a candidate that cannot play the part now says so",
      "version": "2026.8.226",
      "date": "2026-09-21",
      "area": "Modules",
      "type": "Internal",
      "userFacing": false,
      "details": "Two checks that between them avoided a re-run and flagged a result that should not be trusted. THE ONE AUTOMATED TRACK turns out not to need a new reference. Reese Chords is the only track in the project carrying plugin-parameter automation; the lane names itself in the .rpp as \"5: cutoff / Phase Plant\" and the patch's single modulation routing is macro 5 to cutoff, so the two agree. Its duration-weighted mean over the whole span is 0.181 and the saved value the reference already renders at is 0.149 - a difference of about 6% of a lane that travels 0 to 0.575. So the ranking stands as measured. What is not covered is the open state: the lane holds 0.510 for twenty-three seconds and ends at 0.575, which the matcher has never heard, and that belongs in the extremes check rather than the ranking - not done, because setting a Phase Plant macro from outside means rewriting state.json and re-zipping the component state and no tool here does that yet. THE MONOPHONIC FLAG is now printed: a candidate whose module has one voice, ranked against a track that plays chords, is marked in the shortlist as unable to play the part. It is MARKED AND NOT DEMOTED on purpose - demoting would change every ranking this run produced, which is a decision to take deliberately rather than inside a report, and a mark is the reversible half while being enough for a reader to stop trusting the row.",
      "tickets": [
        239
      ]
    },
    {
      "title": "A one-voice module is leading the shortlist for a track that plays chords 99.8% of the time",
      "version": "2026.8.226",
      "date": "2026-09-21",
      "area": "Modules",
      "type": "Internal",
      "userFacing": false,
      "details": "Every Phase Plant patch states its own polyphony in voice.polyphony_voices, so a native port never has to guess one: nine of the eleven declare six or eight voices and the two that declare ONE are exactly the two whose tracks essentially never overlap a note. The rule is therefore to take the declared value and fall back to eight, because one voice is right only for a part that never plays two notes and most of these do. The hand-built patch now reads that value rather than hard-coding a 1 - which happened to be correct for patch 0, and correct for that patch rather than in general. The more interesting half is the CANDIDATES. They carry their own module's voice count, and Ladder One and Rat 030 are one-voice modules while everything else is six to twelve. All four search segments are monophonic by construction, so a monophonic candidate is never asked for a chord while it is being ranked - only the held-out phrase asks, and only for polyphonic tracks. The result is already visible in the shortlists: three of the top four for \"Nasty Reese\" are Ladder One, a one-voice module, for a track that is polyphonic 99.8% of the time. It cannot play that part at all, and nothing in the score says so - the held-out phrase did not even penalise it, scoring 0.465 against a search score of 0.556. That is the fourth member of a family that now includes register, polyphony and velocity, and it is the odd one out: the other three made a candidate score badly for the wrong reason, while this one lets a candidate score well despite being unable to play the material. Marking such a candidate rather than ranking it first is noted in docs/vst-matcher-design.md 5.1b and not yet done.",
      "tickets": [
        239
      ]
    },
    {
      "title": "Four bugs, one shape: the value that never reached the thing it was setting",
      "version": "2026.8.226",
      "date": "2026-09-21",
      "area": "Modules",
      "type": "Internal",
      "userFacing": false,
      "details": "Four mistakes in the patch-matcher work turned out to be one mistake wearing different coats: a value that never reached the thing it was meant to set, every time presenting as a plateau or a dead feature rather than as an error. A knob keyed by a Guid that is regenerated on every build. An oscillator dial addressed as \"Osc 1\" when the module names them with letters. A constant 1 written to a port whose kind is Gate, which fires on a rising edge, so a value that is always high never fires - a setting of ON that means NEVER. And a param written to a port that is WIRED, where a wired input stops reading its param and the write is discarded in silence. The tell in three of the four was an EXACT zero: coordinate descent over a hundred parameters cannot honestly improve by 0.000000, and a score identical across a whole sweep is not a plateau. Three guards now exist, two static and one dynamic: a lookup that throws and names the nearest components instead of returning null; a write that refuses a port fed by a wire, which is the one worth having because it is invisible by inspection; and a probe that moves the first parameter to each end of its range before any long run and checks the score moves at all. That last one also found a genuinely dead control on its first outing while the candidates beside it improved, which is the discrimination it is for - one dead knob is information, every knob dead is a bug. Keying by POSITION rather than identity is what fixed the first for good: build order is stable, Guids are not. Written up in docs/vst-matcher-design.md 5.5 with the tells and the guards, because the next person will meet this shape rather than any of these four instances. Also here: three more LFO retrigger tests, covering the mod-matrix routing path and a drawn LFO shape as well as a direct wire - all pass, so the engine retriggers through every path that can be constructed, and the hand-built patch that started this still does not. That cause remains unfound.",
      "tickets": [
        239
      ]
    },
    {
      "title": "The LFO retrigger test was written to prove a bug and disproved it instead",
      "version": "2026.8.226",
      "date": "2026-09-21",
      "area": "Sound engine",
      "type": "Internal",
      "userFacing": false,
      "details": "mod.lfo's retrigger port had never been wired by any module or covered by any test, and a hand-built patch that tried to use it would not restart however it was wired - so it was raised as an engine bug. Writing the test to prove it disproved it instead. LfoRetriggerTests builds the smallest graph that can answer the question - one oscillator playing the note, one envelope shaping it, one LFO on the oscillator's pitch with its retrigger wired to the notes trigger - and the LFO retriggers correctly on every one of four notes. Two things make that a real answer rather than a green tick. A CONTROL test asserts the same graph's envelope fires on all four notes, so a failure could never be \"the notes bank was never connected\"; it caught a genuine mistake immediately, because the first fixture coerced a wire's nullable ScaleMin and ScaleMax to 0f, which means map this signal onto 0..0, and rendered silence. And the subject test was checked for vacuity by commenting the retrigger wire out, where it fails on the second note at 1.42x - it discriminates rather than passing for free. The measurement is detector-free on purpose: the LFO drives pitch through a falling saw and the test counts zero crossings in the first 40 ms of each note, which is proportional to the frequency it started at. So #253 closes as wired-wrong rather than as an engine fix, the port's comment now says PROVEN and points at the test, and the test stays because no module wires this port and it is the only worked example that exists.",
      "tickets": [
        239,
        253
      ]
    },
    {
      "title": "An LFO is per-voice, which is not per-note - and its retrigger port has never been used or tested",
      "version": "2026.8.226",
      "date": "2026-09-21",
      "area": "Sound engine",
      "type": "Internal",
      "userFacing": false,
      "details": "mod.lfo's `retrig` port carried a comment saying that a polyphonic device already gives each voice its own LFO \"so an LFO inside a synth is per-note whether or not this is wired\". PER-VOICE IS NOT PER-NOTE: a voice reused for the next note keeps the phase it had, so a line that returns to the same voice gets ONE sweep for the whole phrase rather than one per note - and every monophonic part is in exactly that case, which is most basses and most leads. The comment is wrong for the case people actually notice, and it cost a day: a hand-built patch skipped wiring the port on the strength of it and a listener immediately heard \"it sounds like everything is legato and thus there is only one pitch modulation\". Two more things now written where the port is declared. It is a GATE, so it fires on a rising edge, and setting the param to a constant 1 does not mean \"retrigger on\" - a value that is always high never rises and never fires, which is a parameter set to ON that means NEVER. And it is UNPROVEN: the port is wired by no module in the engine and covered by no test, and an LFO wired to the notes trigger - tried in the definition and in the flattened device, in both Play once and Infinite modes - counted ZERO retriggers across four separated notes on a trace where the reference plugin managed three of three. That is either a bug in the port or a wiring convention nothing in the codebase demonstrates; either way it is not something to build on until something exercises it.",
      "tickets": [
        239
      ]
    },
    {
      "title": "The song plays six of the eleven tracks far softer than the matcher was testing them",
      "version": "2026.8.226",
      "date": "2026-09-21",
      "area": "Modules",
      "type": "Internal",
      "userFacing": false,
      "details": "A third way the matcher's reference was not the patch the song plays, after register and polyphony. Every segment was struck at velocity 127, and six of the eleven tracks are nowhere near it - Pluck Melody 1 and 2 play at a median of 42, Octave Jumper 72, Wind Simulator 82, Guitar Simulator 85, Reese Chords 67. It is not a level difference: patch 3 routes MIDI velocity into an ENVELOPE'S DEPTH, and that envelope drives oscillator gain and two oscillators' phase offset, so velocity is how OPEN the patch is. The candidate the ear chose for it is called \"Muted Pluck\", picked against a target rendered at full velocity. The core segments now play at the track's median velocity and the velocity segment still sweeps 30, 70 and 127 as the extremes check, so moving one does not lose sight of the other; the velocity is in the metric signature, because for a velocity-routed patch a shortlist made at another one is not comparable. Found by chasing a user's report that a track's instrument \"starts very muted but opens up a lot\", which turned out not to be automation at all: reading every PARMENV in the project finds plugin-parameter automation on exactly ONE track, Reese Chords, one lane travelling 0.000 to 0.575 into a macro that drives cutoff, against a saved value of 0.149 - so that one IS being matched near-closed while the song opens it. There is no mod wheel data anywhere in the project; the only CC present is CC123, all-notes-off. Three tuning runs aimed at references now known to be wrong were stopped rather than finished, because hours spent optimising toward a wrong target look like progress.",
      "tickets": [
        239
      ]
    },
    {
      "title": "What a patch's pitch actually does over a note, measured rather than read off the file",
      "version": "2026.8.226",
      "date": "2026-09-20",
      "area": "Modules",
      "type": "Internal",
      "userFacing": false,
      "details": "A shortlist only ever sees a patch's MEDIAN pitch, and a median cannot tell a steady note from one that sweeps an octave, so --pitch-trace renders a reference's held note and prints the pitch frame by frame. It settled three things that reading the patch file could not. Patch 0's \"Amped Sub\" holds dead flat for 175 ms and then falls about eleven semitones over the next third of a second - and the plateau ending at 175 ms is exactly x=0.086 of the factory Pyramid LFO curve at its 8/16 sync rate, so the gesture is measured rather than inferred. The -12.00 in the patch is NOT that modulation: voice.pitch = -12 is a static octave transpose, the trap this project already has a note about, and the per-note sweep is a separate LFO routed to oscillator 1's pitch at amount 0.25. And patch 6's pitch, which carries a routing of amount 0.486 into oscillator pitch and so looked like the same kind of gesture, does not move at all: 0.1 semitones from p10 to p90 across the whole note. A routing existing in the file does not mean it is audible, and only the render can tell you which. The traces are what a port has to reproduce, in semitones and milliseconds, rather than in amounts against a full-scale range written down nowhere we control.",
      "tickets": [
        239
      ]
    },
    {
      "title": "The ear settled five patches, and the metric was validated against it for the first time",
      "version": "2026.8.226",
      "date": "2026-09-20",
      "area": "Modules",
      "type": "Internal",
      "userFacing": false,
      "details": "The user listened to the A/B files and chose five: Kenobi Muted Pluck for Sawophysical, Laufeyson Hoover Bass for Future Is Here, Bifrost Cathedral for Eight And A Half, Rat 030 Factory Saw for I Saw A Sine, and Cloudform Glass Keys for Alodyian Flute. That is the first real validation the metric has had, and it passed - with a caveat that has to come first, because the ear only ever heard the metric's own top four of 634. It measures whether the matcher ORDERS its shortlist the way a person would; it says nothing about whether the right answer was in the catalogue at all, and recall remains untested. Within that, the ear's choice was in the top three on four of five references by held-out score and five of five by search score - mean rank 2.8 against 2.0. That is the reverse of what the shortlist ordering assumes, and on one run of five it is a flag rather than a finding, but the search column has now tracked the ear at least as well as the held-out column and twice picked the winner outright. Patch 5 is consequently NOT a structural port after all. It was sent there the same morning because its best score was 0.686 with nothing near it and nothing in the catalogue seemed to be it; the ear disagreed and picked a catalogue answer, so that decision is withdrawn. The more useful half is what it says about the SCALE: 0.69 to 0.75 was being read as \"no good match exists\", and it is not - it is a match a person accepted by ear. The absolute numbers are a ranking, not a verdict, and no threshold on them should route a patch to a structural port. One near miss recorded with it: Bifrost Cathedral only reached the audition by timing, because the files were cut before the held-out column was recomputed for monophonic tracks, and afterwards it ranks 6th where a top-four audition would never have offered it. The same correction moved patch 6's ear-chosen candidate from 3rd to 1st, so it is one each way - but the shortlist offered to the ear should be wider than the number of files anybody expects to listen to.",
      "tickets": [
        239
      ]
    },
    {
      "title": "Stage 1 was tuning a device that had never heard of the knobs it was turning",
      "version": "2026.8.226",
      "date": "2026-09-20",
      "area": "Modules",
      "type": "Internal",
      "userFacing": false,
      "details": "The first stage-1 run swept nine candidates across about a hundred parameters each for eighty minutes and reported a gain of exactly 0.000 on every single one. That read as nine plateaus. It was a Guid. ModuleInstance.Id defaults to Guid.NewGuid(), and a candidate's device has to be rebuilt for every trial because a device carries DSP state once it has rendered - so a knob keyed by the component id it had in SOME OTHER build matches nothing, not one swept value was ever applied, and every trial faithfully scored its own starting point. A knob is now keyed by the component's POSITION in the device, which is stable because the build order is. With that fixed the same reference descends properly: 0.668 to 0.546, 0.599 to 0.512, 0.607 to 0.512 on the first coarse round alone. An exact zero was the tell, so the run now asks the question directly before spending an hour assuming the answer: before any round, move the first knob to each end of its range and check the score moves at all. If it does not, it says so loudly instead of reporting a plateau that is really a no-op. On its first outing the guard immediately earned its place by finding a genuinely dead control - \"Warm Env.Gate\" scores identically at 0 and 1, because the test signal's note-on drives that gate anyway - while the two candidates beside it warned about nothing and improved, which is exactly the discrimination it is for: one dead knob is information, every knob dead is a bug.",
      "tickets": [
        239
      ]
    },
    {
      "title": "The register run and the stage-1 list, written down where the next person will find them",
      "version": "2026.8.226",
      "date": "2026-09-20",
      "area": "Modules",
      "type": "Internal",
      "userFacing": false,
      "details": "The nine-reference run at each track's real register is written down where the next person will find it, with the stage-1 list it produced. Nine references, 634 candidates after de-duplication, 87.3 minutes on 16 threads pinned to mask FFFF - eight physical cores - which is NOT comparable with the earlier unpinned 79.2 minutes. Thread count measured rather than assumed on that pinned set, idle, two trials each: 8 threads 1030 and 1042 ms a candidate, 16 threads 823 and 811, so simultaneous multithreading is worth 1.27x and sixteen is right on eight cores. Three references are settled by the catalogue alone - Amped Sub at 0.152 against five Ladder One sub presets, Sawophysical at 0.410 against nothing but plucks and stabs, and I Saw A Sine at 0.314, the best held-out score in the corpus, against Rat 030, which is a 303 and monophonic exactly as that track is. Three go to stage 1: Nasty Reese, whose ten candidates sit on a flat plateau from 0.465 to 0.566 with nothing to choose between them; Chaos Harp, whose search score is still 0.643; and Hornvox, which the register fix only partly straightened. Two are silent and one, Eight And A Half, is the reference the register did not rescue: it is an 8-bit lead by the author's own description and it matches formant and cathedral pads, with Chip - the chiptune module - fifteenth of seventeen sources at 1.045. Nothing in the catalogue is it, so it joins the silent pair as a structural port rather than being tuned towards a catalogue that has not got it. The polyphony finding is written up as a property of the CORPUS rather than a note on one patch, because whoever reads these shortlists next needs it and it will bite again in the structural port: five of eleven tracks are monophonic, so a monophonic candidate is the RIGHT answer for them and any test that asks for a chord marks it down for being correct.",
      "tickets": [
        239
      ]
    },
    {
      "title": "Stage 1 sweeps a candidate's own controls, and sizing it first found two that change nothing",
      "version": "2026.8.226",
      "date": "2026-09-20",
      "area": "Modules",
      "type": "Internal",
      "userFacing": false,
      "details": "Stage 1 sweeps each shortlisted candidate's own controls, keeps the value that scored best, and goes round again - coordinate descent, deliberately dumb, because parameters interact and a simple method that is obviously correct beats a clever one nobody trusts. Which controls: the module AUTHOR's basic view, the ports a module says people actually reach for, since nothing outside the module could work that out. The budget is the design - the naive plan is seven hours for ONE reference - so it takes the levers the cost model lists: the top three candidates rather than ten, five coarse steps rather than nine, two-second coarse segments, and a fine round that pays full price on the full segment set. A round that gains less than 0.005 has found its neighbourhood and stops, and stopping is REPORTED rather than silent, because \"this one plateaued\" is a result about the patch. Sizing it before running it paid for itself twice. The cost model assumed twenty parameters; candidates have 89 to 138. And two of those turned out to be worse than expensive. The port shown as \"Steps\" is a selector's STEP COUNT, and the selector's own comment settles what sweeping it does: \"UI-only - the DSP emits value as-is\", so it changes no audio whatsoever, while on the quantiser it changes what every value of the selector it feeds MEANS - the search would not have explored the sound, it would have redefined the axis the next knob was being swept along. Ten of one candidate's 97 controls were that. And a selector's value is a POSITION, not a dial: it declares 0..7 as a plain range, so an even sweep tried it at 1.75 and 5.25, settings that do not exist, spending most of its steps between the positions rather than on them. It now sweeps whole positions up to the step count the instance actually carries. The filter that was supposed to skip output gain was also matching on port id, where a factory module gives every small component a generic \"value\" - so \"Output Level\" and \"Osc 1 Level\" were the same id and opposite answers. It matches on the component's label now, which is where the meaning is, and only the output side is skipped: oscillator mix is explicitly on the sweep list. The three candidates of a reference run together, because coordinate descent's only parallelism is the five values one knob is being tried at, and five threads of sixteen leaves a third of the machine idle.",
      "tickets": [
        239
      ]
    },
    {
      "title": "Five of the eleven tracks never play a chord, and the held-out phrase was asking them to",
      "version": "2026.8.226",
      "date": "2026-09-20",
      "area": "Modules",
      "type": "Internal",
      "userFacing": false,
      "details": "Counted the same way the registers were, note-on against note-off per MIDI item: FIVE of the eleven tracks never play two notes at once - Reese Downer, Vangelis Pad, Simple Pad, Distorted Arp and Arp Keys - and a sixth, Octave Jumper, is polyphonic 3% of the time. The matcher's held-out segment is a four-note phrase WITH OVERLAPS and every reference is asked to play it, which for those five is something the patch never has to do, and which penalises exactly the candidates that are themselves monophonic while the reference plays all four notes. That is not a worry, it is an explanation. Patch 6 \"Distorted Arp\" is monophonic in the song; Rat 030 is a 303, monophonic too, and it wins that reference's search decisively - best source 0.342 against 0.460 for the next - and takes ALL TEN shortlist places. The held-out phrase then punishes every one of those ten by 0.20 to 0.29, uniformly, which read cold is the textbook signature of a source that has learned the metric and is in fact a mono synth being asked for a chord. An earlier reading of the C3 run called that cluster metric-fitting and advised re-ranking it by held-out score, which would have thrown away the best answer in the catalogue; that advice is withdrawn. The A/B auditions now follow the track - a monophonic reference is played with the overlaps removed, same notes and same rhythm - and the auditions also got a real loudness match. Normalising each file to a fixed RMS on its own did not work and the first batch proved it: a pluck is peaky, so its gain hits the ceiling first and the reference came out 3 to 5 dB quieter than the pads it was being compared with, reintroducing the very difference the matching exists to remove. The level is now chosen for the whole comparison group - the loudest RMS every member can reach without clipping - so all five files of a patch have identical RMS and nothing clips. Making the SCORED held-out segment follow the track's polyphony is the obvious next change and it invalidates the register run's held-out column, so it is written up in docs/vst-matcher-design.md 5.1a as a proposal rather than taken quietly.",
      "tickets": [
        239
      ]
    },
    {
      "title": "A/B files the ear can settle, and a theory about the silent patches that did not survive being tested",
      "version": "2026.8.226",
      "date": "2026-09-20",
      "area": "Modules",
      "type": "Internal",
      "userFacing": false,
      "details": "Two tools, both in service of the standing rule that the metric ranks and the ear decides. AUDITIONS writes the reference and its top candidates to plain .wav files that can be played in a file browser - no harness - so minutes of listening can settle what would otherwise cost hours of parameter search. Three things make them honest: each patch plays WHERE ITS TRACK ACTUALLY PLAYS rather than on a held C3; the segment is the four-note phrase the search never sees, so the ear is not hearing the thing the ranking was allowed to optimise against; and every file is normalised to the same RMS, because the louder of two takes wins a blind comparison whichever is closer. Files are numbered so a browser's alphabetical order is the ranking, with the plugin first, and a README names each one. THE SILENT PROBE renders one patch repeatedly in a single process with waits between. It was written to test a specific theory about why patches 2 and 8 render silent - that the wavetable load is asynchronous and never completes in a host with no window, which our own Kilohearts log made plausible by carrying \"PostMessageW failed: Invalid window handle\" - and it DISPROVED it: six renders over fifteen seconds and a fresh device afterwards are all exactly zero. That is the fourth theory to die with evidence, after the missing asset, the factory path, and the content service not being installed; a vendor repair run afterwards changed none of the 1607 dependency files, so the install was whole all along. The eliminations and the one remaining observation - patch 8 renders in a flat 165 ms where a patch that sounds takes 430, so it appears to do almost no work - are written up in docs/vst-matcher-design.md section 5.4 rather than left in a transcript.",
      "tickets": [
        239
      ]
    },
    {
      "title": "The matcher tests a patch where its track actually plays, not on a held C3",
      "version": "2026.8.226",
      "date": "2026-09-20",
      "area": "Modules",
      "type": "Internal",
      "userFacing": false,
      "details": "Three things the matcher was measuring wrong, all of them in the TEST SIGNAL rather than in the weights. THE REGISTER. Every reference was compared on a held C3, whatever the track played. Counted over every note-on in the project, not one of the eleven tracks plays C3 and six are more than an octave and a half from it: \"Reese Downer\" plays a median A1, fifteen semitones below, and \"Guitar Simulator\" - which is a flute - plays C5, twenty-four above. That is a candidate cause of the two worst results in the nine-reference run: tested at C3 the FM pad \"Hornvox\" and the sub bass \"Amped Sub\" shared NINE of their ten shortlist entries and 88 of their top hundred, and the calibration's own pair matrix makes them the CLOSEST of all 36 pairs in the corpus at 0.264 against a median of 0.954 - a bright pad dragged two octaves down lands exactly where a sub bass lives. Segments are now built around a centre note taken from the track's measured median, the centre is part of the metric signature so a shortlist can never be compared across registers, and the range segment spans one octave either side rather than two because two octaves either side of a flute's C5 is above the pitch detector's octave-flip point. One honest cost, stated rather than discovered later: patch 0's real register is 55 Hz, BELOW the detector's 65 Hz floor, so its pitch gate abstains and it ranks on timbre alone - the right trade, since the gate is only ever a prune. DUPLICATES. Every candidate now renders one short note and is hashed before anything is ranked, and identical renders collapse to one, keeping the more useful name: a real preset over Init, Init over (defaults), a factory preset over a demo channel. Done by rendering rather than by name because names do not catch it - every module's (defaults) turned out to be its own Init preset, two demo channels turned out to be Ladder One presets, and \"Ladder One / Analog Pluck\" and \"Laufeyson / Wide Pluck\" are bit-identical, which no name rule would have paired. Four of the nine references had spent two of their ten shortlist slots on one of these pairs. THE ORDER. Shortlists are ordered by the HELD-OUT phrase, the one the search never sees, with the search's own position shown beside it. The two disagreed about the winner on five of the nine references and the list was being handed over in the order of the number we trust less.",
      "tickets": [
        239
      ]
    },
    {
      "title": "A matched patch is named by its track and its preset, not by the plugin eleven times over",
      "version": "2026.8.226",
      "date": "2026-09-20",
      "area": "Modules",
      "type": "Internal",
      "userFacing": false,
      "details": "The patch matcher's reports now identify a reference by the REAPER TRACK it sits on, read out of the .rpp alongside the plugin state. Before this every one of the eleven references printed as \"VST3i: Phase Plant (Kilohearts)\", which is the PLUGIN's name and therefore the same string for all of them - a nine-reference report that identified nothing, and the whole nine-reference run was reported that way. The track name is only half the label: the preset name inside the patch is the other half and the two disagree ON PURPOSE - \"Simple Pad\" is an 8-bit lead, \"Guitar Simulator\" is a flute, \"Wind Simulator\" is an FM pad - so the calibration comment now names both for every patch it judges, and docs/vst-matcher-design.md section 2 tabulates all eleven. Judging by track name alone would have called calibration check 4 \"a pluck against a distorted arp\"; both sides actually carry the author's own #pluck tag. Alongside it the nine-reference run prints the whole report per reference while it is in hand - measure availability, best-per-source with used and rejected counts, and the shortlist with held-out scores - rather than one line per patch, because a checkpoint keeps the ranking but not the shape of it: \"no candidate from that source reached the top ten\" and \"every candidate from that source was rejected\" look identical in a top-ten list and mean opposite things. The transposition column now prints its own caveat everywhere it appears rather than only in a document nobody has open - it is a magnitude and never a direction, and it is measured against a reference whose own pitch wanders, so \"+1 st\" can be reporting the REFERENCE. The run also drives the footer progress bar, so an eighty-minute render is visibly moving.",
      "tickets": [
        239
      ]
    },
    {
      "title": "The LFO retrigger port gets the worked example it never had, and its comment stops saying it is unnecessary",
      "version": "2026.8.226",
      "date": "2026-09-21",
      "area": "Sound engine",
      "type": "Internal",
      "userFacing": false,
      "details": "mod.lfo's `retrig` port is wired by no module in the engine, so until now there was no worked example of it anywhere and its own descriptor told you it was unnecessary: \"a polyphonic device already gets one LFO per voice with its own phase, so an LFO inside a synth is per-note whether or not this is wired\". PER-VOICE IS NOT PER-NOTE. A voice REUSED for the next note keeps the phase it had, so a line that returns to the same voice gets one sweep for the whole phrase rather than one per note - and every monophonic part is in exactly that case, which is most basses and most leads. That claim cost a day: a hand-built patch skipped the wire on the strength of it and a listener heard the result at once as \"everything is legato and thus there is only one pitch modulation\". The second trap is now written down beside it: the port is a GATE, so it fires on a rising edge, and setting its param to a constant 1 is a value that never rises and therefore never fires - a setting of ON that means NEVER. LfoRetriggerTests arrives with the correction as the worked example the port never had. It builds the smallest graph that can answer the question - one oscillator playing the note, one envelope shaping it, one LFO on the oscillator's pitch with its retrigger wired to the notes trigger - and asserts the LFO restarts on every one of four notes, directly, through a mod.amount matrix routing the way every factory synth routes its LFOs, and with a drawn LfoShape rather than a built-in waveform. Two things make it evidence rather than a green tick. A CONTROL test asserts the same graph's ENVELOPE fires on all four notes, so a failure could never mean \"the notes bank was never connected\" - and it caught a real mistake on the first run, because the fixture had coerced a wire's nullable ScaleMin and ScaleMax to 0f, which means map this signal onto 0..0, and rendered silence. And the subject was checked for vacuity: comment the retrigger wire out and it fails on the second note at 1.42x, so it discriminates. The measurement needs no pitch detector - the LFO drives pitch through a falling ramp and the test counts zero crossings in the first 40 ms of each note, which is proportional to the frequency it started at.",
      "tickets": [
        254
      ]
    },
    {
      "title": "The import log now says what it did NOT find, because a quiet log read as a clean bill of health",
      "version": "2026.8.225",
      "date": "2026-09-21",
      "area": "Sound engine",
      "type": "Improvement",
      "userFacing": false,
      "details": "Three things, all from the same root. FIRST, audio items loop as well as MIDI ones: REAPER's LOOP flag is set on all 1,545 audio items in \"show me love\" and it changes the sound on 31 of them, where the item outlasts what is left of its source and playback wraps to the source's start. Read as one region the import ran off the end of the file instead -- the hi-hat loop is 5.6 seconds under a 72-second item, so 66 of those seconds had no hats. A wrapping item now becomes the whole source fitted to one pass with the start offset on ContentOffsetTicks, which is how a clip already repeats; the other 1,514 are chops that never reach the end of their source and are untouched. SECOND, the conversion log reports absence as well as presence. It only ever spoke when it found something, so two lines looked like a project that imported whole -- which is how seventeen silent plugins and a quarter of the MIDI survived every check. Tempo map, markers, sends, frozen tracks, multi-take items, master FX and MIDI controller data each now report what was found OR that none was, and a standing note names what is never examined at all (stretch markers, take FX, item groups, colours, record state, other envelopes, extension state) so silence about those reads as \"nobody asked\" rather than \"there were none\". On the target project that turned a 2-line report into 64, and made visible 39 MIDI controller events -- CC and pitch bend -- that were being dropped without a word. THIRD, the master FX chain can be imported, behind an off-by-default masterFx flag (ImportReaper --master-fx). It is off by default on purpose: an effect the graph cannot resolve renders silence, and a silent node on the MASTER is the whole song gone on any machine without that plugin, which is a far worse failure than one quiet channel. It exists because the master chain is most of the level and some of the tone difference against REAPER's own bounce, and measuring that needs it."
    },
    {
      "title": "An imported REAPER project was silent where its plugins should be, and lost every looped phrase",
      "version": "2026.8.225",
      "date": "2026-09-21",
      "area": "Sound engine",
      "type": "Bug fix",
      "userFacing": false,
      "details": "The first whole-song render of \"show me love\" was made to listen to, and it proved two faults that every earlier check had passed over. FIRST: not one of the seventeen hosted plugins reached the mix. The host registered, all eighty bundles scanned, every instance loaded, every setState took, no channel raised a compile warning -- and a render with the VST host was BIT-IDENTICAL to one without it, 29,836,800 samples the same. The importer built each plugin device and added its component, and never wired that component to the device's own bus, so the plugin ran and wrote into a buffer nothing read. Nothing about a device is implicit (tools/Vst3Probe says so in a comment), and an unwired device is silent in exactly the way a patch that failed to load is silent, which is why five earlier investigations into two silent patches never noticed the other fifteen were silent too. An effect now takes the device's input bank and returns to its output bank; an instrument only returns, because its notes arrive as events rather than ports. SECOND: a looped MIDI item lost its repeats. REAPER's LOOP flag repeats a take's source for as long as the item is, and 24 of this project's 35 MIDI items are looped -- \"Pluck Melody\" is an eight-bar phrase under a forty-seven-bar item, \"Reese Downer\" one bar under sixteen. The pattern was being given the ITEM's length, so the notes sat in its first bars and the rest was empty: the song's whole intro, and fifteen of every sixteen bars of the bass. The clip keeps the item's length and the pattern now shrinks to what its notes need, which is what a clip repeating its pattern already does; with LOOP off nothing repeats, as before. Each one is reported in the conversion notes. RenderDemoSong gains --vst (load the host by name, the same way the app's VstHostBootstrap does), --whole (the export path: the whole arrangement plus its tail) and --warn (print what each channel's graph could not build), and exits the process when a host is loaded, because a Kilohearts plugin does not reliably let it."
    },
    {
      "title": "The LFO's comments now say what its code does, on the two points that cost a day",
      "version": "2026.8.225",
      "date": "2026-09-21",
      "area": "Sound engine",
      "type": "Internal",
      "userFacing": false,
      "details": "Two comment fixes on mod.lfo, both sitting exactly where a bug report went wrong. Ticket #253 claimed the LFO's retrigger port never fires; it was investigated, a minimal failing test was written for it, and the test PASSED - the port works, and the patch that prompted the report was wired wrong. The ticket was retracted. What made it believable for a day is in this file. First, LfoState's class summary said the LFO was \"free-running (never resets phase on gate - an LFO is not an envelope)\", which was written before the retrig port existed and was flatly contradicted afterwards by the first branch of its own Render: a rising edge on trigger sets the phase to zero. Anyone reading the class to find out whether a retrigger could possibly work was told in the summary that it could not. It now says what the code does - free-running by default, resets on a rising edge when something is wired to it - and points at LfoRetriggerTests as the worked example. Second, the twelve-line comment describing the MUSICAL rate port was attached to the wrong descriptor: it sat above `phase` (0..1, where in the cycle the shape starts) and described `tick` (0..112, an index into LfoDivision), declared on the next line. It has been moved onto `tick`, and now also states the naming trap plainly, because two people have now configured one port while meaning the other: on this module the port DISPLAYED \"Sync\" is the musical rate, and the one displayed \"Tempo sync\" is the legacy on/off switch beside `division`. The display names are deliberately left alone - renaming a shipped port changes behaviour for saved layouts and panels, and belongs in its own decision rather than riding along with a comment fix. No code changed; nothing sounds different."
    },
    {
      "title": "Written down: which pinned audio hashes can survive a different machine, and which cannot",
      "version": "2026.8.224",
      "date": "2026-09-21",
      "area": "Sound engine",
      "type": "Internal",
      "userFacing": false,
      "details": "Follow-up notes to the golden-hash failure. Two things worth keeping. First, why the cause was worth finding even though the remedy had already been decided: one LSB is a safe answer only because the residue is FLAT across the render, measured per eighth of three bars. The plate is a feedback network, and a perturbation injected into a feedback path is exactly the shape of thing that can compound - had the per-eighth figures climbed, the same -101 dB whole-render figure would have been an average hiding a divergence that reaches signal level in a long song, and the two hosts genuinely would not be playing the same music. That would have justified a managed sine on the same measurement. The number to look at when this recurs is not the total residue, it is whether the residue grows with length. Second, a map of every pinned hash in the suite: which are safe by construction (the Signalsmith port computes its own trig; the demo parity tests hash two renders in the SAME process, so a host difference cancels; WavPack is an integer codec), which are at risk and pass today only because the last-bit difference falls under 16-bit rounding for their particular material (the phase vocoder rows in StretchRegistryParityTests and Mnts5Tests.Golden_Renders, both of which call cos, sin and atan2 per bin), and roughly what converting each of those to behaviour would take. The rule underneath it: an absolute hash is only safe when nothing on the path calls the platform C library per sample, and a relative hash - two renders compared inside one process - is safe always, and is usually what the test actually wanted.",
      "tickets": [
        251
      ]
    },
    {
      "title": "CI is green again: the reverb and the stretcher are tested by behaviour, not by a hash",
      "version": "2026.8.224",
      "date": "2026-09-20",
      "area": "Sound engine",
      "type": "Internal",
      "userFacing": false,
      "details": "Seven tests failed on the Linux CI runner and passed on the Windows dev box, which blocked every commit from today reaching a beta. They were golden-hash tests: PlateParityTests pinned fx.plate's output at seven settings, and Mnts5HybridTests.Golden_Renders pinned MNTS v5.1's, sample for sample. The cause, measured on a container matching the runner rather than guessed: the platform C library. Every transcendental differs between the Windows UCRT and glibc in the last bit - sin, cos, exp, log, pow, atan2, in float and double - and only sqrt agrees, because IEEE mandates it. The plate reads its delay lines at an offset driven by a per-sample MathF.Sin, and the vocoder's per-bin phase maths does the same, so those two engines inherit the difference; the plate's setup maths is bit-identical on both hosts, and the one case with modulation switched off - the only one of the seven with no per-sample sine - was bit-identical too, and the only one CI did not fail. The divergence is bounded and does not grow: at most one LSB of the 16-bit output, a null-test residue of -101 dB at worst, flat across a three-bar render. The two hosts do render the same song, to within one sixteen-bit step. The fix that would have made it bit-exact everywhere is the one the Signalsmith port already uses - compute sine and cosine in managed code instead of calling the C library - and it is still available. It was declined: bit-exactness is a way of proving a change did not alter the sound, not a goal in itself, and here it forbade improving the reverb at all. So both tests now assert behaviour. fx.plate is asked, at all seven settings, whether it is audible and not railing, whether its tail is continuous in time rather than a handful of taps, whether it decays (or holds, at freeze), whether the two sides do what the width control says, and whether the same settings render the same samples twice in one process - plus a new test that the measured RT60 is within 25 per cent of the time the decay control claims, measured from the envelope's slope rather than inferred from the gain that was solved for. MNTS v5.1's paths are asked for exact length, determinism, no silence or railing or infinities, that a time stretch does NOT move pitch, that a pitch shift does, and that the formant switch reaches the DSP and not just the cache key. The demo-song hashes and tools/ShippedParity keep their hashes: those hold on every host, cost nothing, and are what proved this week's optimisation work changed nothing.",
      "tickets": [
        251
      ]
    },
    {
      "title": "The block-timing harness names the bars that miss, not just how many",
      "version": "2026.8.224",
      "date": "2026-09-20",
      "area": "Sound engine",
      "type": "Internal",
      "userFacing": false,
      "details": "RenderDemoSong --blocks reports the per-block render time split by whether the block crosses a bar line, which is the number the bar-line work is judged on. It now also prints the eight slowest blocks with the bar each fell on, because how many blocks miss a deadline does not say what missed it. On The Dark that turns an assertion into evidence: with the stretch cache off, every remaining over-budget block is a bar where sample clips START - a shifting voice being constructed and pre-rolled by the engine latency before its first frame - and not a bar paying for its own setup. That is a per-clip start cost, it cannot be spread without a lead, and it is a different problem from the one just fixed.",
      "tickets": [
        250
      ]
    },
    {
      "title": "A song with sample clips no longer stalls at every bar line",
      "version": "2026.8.224",
      "date": "2026-09-20",
      "area": "Sound engine",
      "type": "Performance",
      "userFacing": true,
      "details": "Playback renders one block of audio at a time and has the length of that block to do it in - 46.4 milliseconds for the 2048 frames the desktop asks for. On The Dark, 74 of the song's 75 blocks that missed that deadline were the blocks that cross a BAR LINE, whose median was 102.6 ms: more than twice the budget, while the blocks in between had a 99th percentile under 7 ms. Profiling the per-bar setup phase by phase - rather than trusting a sampling profiler, which this engine has already fooled once - found that everything it was suspected of is free. The mute tree, the solo gains, the note sources, the cached tails, the clip automation and the echo rotation together cost under a tenth of a millisecond each. Ninety-seven per cent of it was one thing: every channel carrying sample clips rendered a WHOLE BAR of them, synchronously, on that one block. A frame of a sample clip depends on its own position and on the clip and on nothing else, so none of that had to happen in front: the bar is now produced as it is consumed, a span at a time, and the block that crosses the bar line renders the same 2048 frames as any other. Measured in one process with the two versions interleaved, pinned to eight cores, best of three each: The Dark's bar-line block goes from a median of 103 ms to 7.7 ms and its misses from 73 to 10, and with the stretch cache cold - which is what playback does until it warms, and what a channel with a Sample pitch lane does for ever - from 198 ms to 12.9 ms and 109 misses to 4. Elevation goes from 331 ms to 14.6 ms cold. Total render time does not move, because it is the same arithmetic in a different order of wall-clock time; what moves is that none of it lands in one block any more. Every shipped demo renders BIT FOR BIT identically, before and after, which is the standing condition on this work: nothing here changes an arithmetic operation or the order of one, including the 256-frame steps a pitch-shifting clip takes.",
      "tickets": [
        250
      ]
    },
    {
      "title": "Channels can render on several threads at once, and the engine says which songs it helps",
      "version": "2026.8.224",
      "date": "2026-09-20",
      "area": "Sound engine",
      "type": "Performance",
      "userFacing": false,
      "details": "The engine rendered every channel of a song one after another on one thread. Its channels are independent of each other apart from three dependencies the render order already sorts - a cross-channel sidechain key, a strip-level SideChain source, and a group's children - so a run of consecutive channels can be rendered side by side with no lead, no second copy of any state, no snapshot and no seam, and with output that is byte-identical by construction rather than by measurement. ChannelRenderPool is a fan-out and a join on dedicated threads parked on a semaphore, with nothing allocated per span and the calling thread taking work itself because it is the one with the deadline; the join counts threads rather than finished indices, because counting indices lets the call return while a worker is still leaving the loop and that worker then takes an index from the next job holding the previous job's body - a channel rendered for the wrong span, silently. The batching is the whole result: the first version put every channel with a sidechain key into the serial remainder, and on Elevation, where almost everything ducks off the kick, that left one eligible channel and bought three per cent. The kick is one channel and its dependents are twenty, and they belong in the batch after it. Measured on whole songs at 2048 frames a block, pinned to eight cores: Elevation 37.8 to 30.5 seconds with its 99th-percentile block from 11.4 to 9.5 ms, the boot song 0.39 to 0.20 seconds, and Too Many Chords five per cent SLOWER because it has one eligible channel in most spans and pays the walk for nothing. The Dark does not move at all, and that is the finding worth having: 74 of its 75 blocks that miss the real-time deadline are bar-line blocks, whose MEDIAN is 102 ms against a 46 ms budget, while the mid-bar blocks the pool renders have a 99th percentile under 7 ms and miss once in the whole song - which the pool takes to zero. The cost that misses deadlines on a real song is BeginBar's per-bar setup, not the channel loop, and that is where the next piece of work is. Both flags stay off by default, the parity gates run all four combinations of them on every shipped demo on every commit, and the web gets none of it: the browser build is single-threaded WASM, so the pool has no workers there and the flag is inert by construction. Desktop only, and stated as such before more was built."
    },
    {
      "title": "Bar-ahead rendering is unblocked, engages on real songs - and the measurement says stop there",
      "version": "2026.8.224",
      "date": "2026-09-20",
      "area": "Sound engine",
      "type": "Internal",
      "userFacing": false,
      "details": "With the block-size bug fixed (#249), the guard that made the bar-ahead feature refuse every song this project ships has nothing left to guard and is gone: the per-span path now walks the same 64-frame chunks a whole-bar render does, at any tempo, so a bar rendered in one call is byte-identical to the same bar rendered in pieces. The gate says so on all four shipped demos rather than passing because the feature never ran. The coverage test is rewritten around the opposite question, because a feature that is switched on and never eligible looks exactly like one that is switched off: declines are now counted by reason, and over whole songs the feature serves 23 per cent of Elevation's channel-bars, 73 per cent of The Dark's and all of the other two demos', with the refusals split between channels that duck off another channel and bars whose audio is assembled somewhere other than the plain graph. Elevation serves nothing at all for its first fifteen bars, which is why the first version of the coverage test reported zero and would have been believed. Then the measurement that matters, and it is a stop sign: whole songs at 2048 frames a block, total render time does not move at all - which confirms on whole songs what was inferred earlier, that the unit of work is the control tick rather than the span - while the variance moves the wrong way. The median block gets cheaper because a channel served a whole bar does nothing for the rest of it, and every one of those bars is paid for in the single block that starts it: the 99th-percentile block goes from 14 to 74 milliseconds on Elevation and the blocks that miss the real-time deadline go from 20 to 85. That is what a synchronous whole-bar render on the audio thread costs, and it is exactly what a producer running on another thread exists to avoid. So the preference, the UI and the GPU backend are not built: the next thing worth building is the producer on a thread that is not the audio thread, because it is the only arrangement in which any of those numbers can improve. The flag stays off, the parity gate stays on every commit, and the numbers are in docs/bar-ahead-channels-design.md section 20.4."
    },
    {
      "title": "A song now sounds the same however the audio is chopped up - and an export matches what you heard",
      "version": "2026.8.224",
      "date": "2026-09-20",
      "area": "Sound engine",
      "type": "Bug fix",
      "userFacing": true,
      "details": "The engine's unit of work is a 64-frame control tick, and a bar is not a whole number of them - The Dark's bar is 85,354 frames, Elevation's 86,754 - so a 2048-frame audio block straddles the bar line. The renderer split the block there, every span in the next bar then started mid-tick, and a tick that one block size rendered in one piece another rendered in two. Any device doing work once per call rather than once per sample came out fractionally different, and a compressor or a reverb downstream turned that into a lot: rendering Elevation in 1024-frame blocks instead of 2048 differed from it by 5 dB, and The Dark by 19 dB. Nothing had caught it because the desktop and the browser both happen to ask for 2048 frames at a time, so they agreed with each other by coincidence rather than by construction - and the bounce, which renders a second at a time, did not agree with either. An exported WAV was not quite the song you had been listening to. Every span the renderer produces now ends on a control tick, and the few frames a caller has no room for wait for its next call, so the block size is invisible to everything below the render loop: the four shipped demos render bit for bit identically at 2048, 1024, 512, 256, 700, 63 and 44,100 frames a block. Fixing it changes the audio of existing songs once, which was a deliberate decision taken with before-and-after renders to listen to: The Dark moves by 34 dB below the music (its loudness envelope is within 0.08 dB everywhere), the boot song and Too Many Chords do not move at all, and Elevation moves audibly in the channels that duck off another channel - a song that was already rendering this differently between one block size and another. What a song sounds like is now a property of the song.",
      "tickets": [
        249
      ]
    },
    {
      "title": "A song renders differently depending on the audio block size, and that blocks the bar-ahead work",
      "version": "2026.8.224",
      "date": "2026-09-20",
      "area": "Sound engine",
      "type": "Internal",
      "userFacing": false,
      "details": "Wiring the bar-ahead feature into the renderer (docs/bar-ahead-channels-design.md section 20.3) turned up something older and larger than the feature. The parity gate - every shipped demo must render byte-identically with the feature off and on - failed on its first run, by one bit, on three channels of The Dark. Rendering the same bars through the same channel graph in isolation showed no difference at all for any of the twenty-two channels, so the cause was not the graph. It is where the renderer's spans start. A bar is not a whole number of 64-frame control ticks - The Dark's is 85,354 frames - so a 2048-frame block straddles the bar line, the renderer splits it there, and every span in the next bar starts mid-tick. The graph walks tick-aligned chunks from wherever it is given, so one pattern renders a tick in two pieces where the other renders it in one, and any node doing per-call work differs in the last bit; a reverb or compressor downstream turns that into more. Following that led to the real finding, which has nothing to do with the new feature: rendering a demo in 1024-frame blocks instead of 2048 differs by 5 dB on Elevation and 19 dB on The Dark. Every renderer in the project agrees today only because the desktop pump, the web pump and the bounce worker all happen to use 2048 - by coincidence of configuration rather than by construction. That is filed as its own bug, with a characterisation test kept and skipped so it says when it is fixed, and WAVs of each demo at both block sizes plus the normalised difference so the size of it can be heard rather than argued about. The bar-ahead feature is therefore guarded to refuse any song whose bar is not a whole number of ticks, which means it refuses every song this project ships. The mechanism is built, tested and correct wherever it runs, and it runs nowhere until the block-size bug is fixed. Refusing is the intended behaviour: a bar that is plausible and wrong is the failure this design exists to prevent.",
      "tickets": [
        249
      ]
    },
    {
      "title": "A channel can be put back where it was, so moving the cursor no longer costs the pre-render",
      "version": "2026.8.224",
      "date": "2026-09-20",
      "area": "Sound engine",
      "type": "Internal",
      "userFacing": false,
      "details": "Second slice of the bar-ahead design (docs/bar-ahead-channels-design.md sections 4.4a, 4.4b and 20.2). A channel's position in time can now be captured at a bar line and put back later, which is what a cursor move backwards or a loop wrapping to its first bar needs. The engine already knew what a channel's state IS - AdoptStateFrom moves all of it between graph instances, including note continuity and every polyphonic voice - but it hands the state over rather than copying it, and pre-rendering needs the other thing. The copy is done by reflection rather than by a Clone method per state class, deliberately: AdoptState's own comment warns that a module kind left out of it would go on restarting silently, and a hand-written clone has exactly that failure mode once per new module for ever. State is copied and dependencies are shared, and the rule errs toward copying, because a wrongly copied field costs bytes while a wrongly shared one is a bar that changes under the snapshot meant to preserve it. Measured on the shipped demos rather than estimated: a snapshot ranges from 84 bytes for a plain synth voice to 5.7 MB for a drum machine, and a whole song is between 0.7 and 17 MB. The shape of that cost is per-voice replication - eight voices carry eight reverb tanks - and not anything the snapshot does. Used-window trimming was measured at roughly twelve times on the dominant class and NOT done, because a delay's time is a port rather than a field and can be automated, so a snapshot trimmed to the time in force when it was taken reads silence where its tail should be if the time is longer when it is restored. That is a correctness risk taken for memory that already fits, so the ring is budgeted in bytes instead, which has no such edge. One bug worth recording: the copier checked the namespace before the array case, and an array's namespace is System, so every float array in the engine was classified as a shared dependency and shared storage with the live graph instead of being copied. The snapshot reported success with every state object present, at 397 bytes for a reverb-carrying channel, and restored nothing. It was the size assertion that caught it rather than the fidelity one."
    },
    {
      "title": "No crossfade at a bar line, decided by ear - and the crossfade that clicked was the broken one",
      "version": "2026.8.224",
      "date": "2026-09-20",
      "area": "Sound engine",
      "type": "Internal",
      "userFacing": false,
      "details": "The bar-ahead design (docs/bar-ahead-channels-design.md sections 8.2 and 21.2) needed to know whether a seam between two renders at a bar line needs a crossfade to hide it. Settled by listening rather than by argument: a sustained pad and a long reverb tail crossing the line, at a real level with no transient to hide behind, spliced with and without a crossfade, against a deliberately broken version for calibration. The plain seam is inaudible, measuring a 76 dB step at the boundary against 8 dB for the broken one, so no crossfade is added. The decisive argument turned out to be the calibration rather than the level: the broken seam sounded bad WITH the crossfade too, so a crossfade cannot mask a genuine fault - it would have been insurance that does not pay out, on a code path running at every bar boundary of every channel. That reasoning holds even if future material makes the step bigger, which 'the step is small' does not. The crossfade that clicked was chased rather than noted, because a crossfade being worse than no crossfade is a bug and not a trade-off. It used an equal-power law on two correlated signals, and equal-power holds power constant rather than amplitude, so two renders of the same audio sum to root two at the midpoint - a 3 dB bump over five milliseconds, measured at 2.21 dB RMS across the window. The shipping audio path does not have this mistake and in fact contains the correct implementation: the MNTS5 stretcher already measures the join correlation and normalises the gains for it, and the other equal-power uses are panning and blending two different sample banks, where it is right. Adopting the engine's own law in the audition takes the bump from 2.21 dB to zero."
    },
    {
      "title": "A channel can render a bar ahead of where it is heard, and a stale bar cannot reach the output",
      "version": "2026.8.224",
      "date": "2026-09-20",
      "area": "Sound engine",
      "type": "Internal",
      "userFacing": false,
      "details": "First slice of the bar-ahead design (docs/bar-ahead-channels-design.md section 20.1): ChannelBarProducer gives a channel two bar-length buffers, a current and a next, and swaps them at the bar line. It is deliberately not wired into playback - no GPU, no threads, no preference, no interface - so nothing anybody hears has changed. What it exists to settle is the half of the design that goes wrong silently, because a bar rendered before an edit, or rendered from the wrong position in time, sounds entirely plausible and is not the song. The epoch that decides whether a rendered bar is still valid is DERIVED from the channel's content rather than bumped by an edit path, because the set of edit paths is not closed and a missed bump has no symptom. It reuses the hashing the disk bounce cache already does and adds what a per-channel question needs: the song's tempo, the module definitions the chain instantiates, the channel's sidechain key source, and its children if it is a group. Nineteen tests say so, one per way a bar can go stale - including a note edit, which touches nothing on the channel at all, and a change to the channel a compressor is keyed off. The other half of the contract is tested too: an unrelated channel changing must NOT invalidate, or nothing would ever stay pre-rendered. Producing a bar from the wrong position is refused rather than guessed, and consecutive bars are proved byte-identical to the same graph driven directly, with a cold graph as the control that makes that assertion mean something. Two measurements landed with it. A bar-sized render saves nothing against the 2048-frame chunk - 1.00x inside a channel and 0 to 5 per cent across a whole song - because the unit of work is the 64-frame tick, not the span, so an earlier claim in that document is withdrawn. And the seam between two engines is now a set of files to listen to rather than an argument: a sustained pad and a long reverb tail crossing a bar line, spliced with and without a crossfade, against a deliberately broken version for calibration."
    },
    {
      "title": "A channel's DSP state can be snapshotted, and it costs far less than its arrays suggest",
      "version": "2026.8.224",
      "date": "2026-09-20",
      "area": "Sound engine",
      "type": "Internal",
      "userFacing": false,
      "details": "The bar-ahead design (docs/bar-ahead-channels-design.md) needed to know what it costs to snapshot a channel's position in time, because the decision was that channels with long reverb and delay tails should snapshot rather than drop to live playback on a cursor move - they are exactly the channels pre-rendering is worth having on. Measured with a new harness section that builds a real graph for every channel of every shipped demo and walks its state: the worst channel is 5.7 MB and a whole song is 17.1 MB, which is affordable, and not the tens of megabytes per channel that was feared. Almost all of it is capacity rather than state. A stereo delay allocates a 2.05 second ring whatever time it is set to, and a buffer allocates four seconds where the default is a quarter of one, so eleven of The Dark's seventeen megabytes is delay-line space nobody is using. A snapshot therefore copies the used window rather than the array, which projects to one and a half to three megabytes per song. Two earlier versions of the measurement were wrong in the same way and are worth recording: passing no module definitions makes every composite-device channel compile to an empty graph, and a polyphonic channel's nodes are not in the top-level graph at all - each voice is its own graph - so all three 144-module channels first read as holding nothing. The other finding is that the hard half of a snapshot already exists: AdoptStateFrom moves a channel's whole live state between graph instances, including note continuity and every polyphonic voice, so what is missing is only the ability to copy it rather than hand it over."
    },
    {
      "title": "Two architectures for pre-rendering the engine, designed and weighed",
      "version": "2026.8.224",
      "date": "2026-09-20",
      "area": "Sound engine",
      "type": "Internal",
      "userFacing": false,
      "details": "docs/bar-ahead-channels-design.md is a design-only write-up of two ways to get the audio engine ahead of where it is heard. Option A is a bar of lead per channel, two buffers each, a pointer swap at the bar line, the master staying live on the CPU, and a chain dropping to live when it is edited. Option B is the whole DSP engine resident on the GPU, with the CPU only pushing control events in and pulling finished stereo out. Three things were read from the source rather than assumed, and two of them correct earlier documents. Wireless routing never crosses channels and its delay is one tick rather than one chunk, which two GPU documents had wrong; the only real cross-channel paths are the group tree and the sidechain key, both acyclic and both already topologically sorted by the renderer. An automation lane is a deterministic function of song position, so an automated channel pre-renders normally - what cannot pre-render is a knob under the user's finger, which removes the settle timer the earlier proposal needed. And the engine renders on one thread, with no parallelism over channels anywhere, which changes what a GPU is competing against. Tested against the measured numbers, a whole channel route on the GPU is about 1.7 times faster than the one thread the engine actually uses and about 5.7 times slower than the cores it does not, so bar granularity is worth more on the CPU than on the GPU on desktop. Option B's transfer argument holds - 2.8 MB a second up and 0.4 down against 18.7 GB a second available - and three of its four expected blockers are not blockers in this engine, because voices are already a fixed pre-allocated pool, control rate is cheap enough to keep resident, and both branches of a selector already render. It is declined as the next project on cost, 25 to 35 thousand lines against 2 to 4, and because two engines cannot be made bit-identical, which would break whole-song parity, the bounce cache and the promise that a song sounds the same on every machine. The two turn out not to be alternatives: Option A's producer, given a GPU backend, is Option B's incremental path, so the recommended first slice is the same for both. Nothing is built."
    },
    {
      "title": "The WASM audio penalty grows with song size, and the busy-song number was measured",
      "version": "2026.8.224",
      "date": "2026-09-20",
      "area": "Web",
      "type": "Internal",
      "userFacing": false,
      "details": "docs/gpu-dsp-next.md section 11 replaces the projected busy-song figure with a direct reading, now that any bundled demo can be opened by URL. The projection was wrong: Elevation costs 5.3 per cent of the web build's single thread, not the 3.2 the earlier section calculated, and The Dark costs 5.5. The error was assuming one WASM-to-native ratio. It is not a constant. Measured with both sides pinned to the same cores, WASM AOT is 0.92x native on the three-note synth demo - marginally faster - and 1.92x on Elevation and 2.49x on The Dark. That is the signature of cache pressure and memory traffic rather than arithmetic, since a thirty-channel song touches far more memory than three notes. Two things had to be settled before any of it counted. RenderBlock genuinely renders rather than draining a buffer somebody else filled, and there is no render-ahead on the web to turn off, because the pump is a setInterval on the main thread and the background pump is desktop-only. And processor affinity had to be verified rather than set: assigning it after launch does not stick for a browser, which manages its own, while start /affinity applies it at creation and children inherit. Affinity removed the load sensitivity completely - the same song renders in 2.442 ms at 90 per cent system load and 2.488 at 10, where unpinned it ranged 2.440 to 4.408 with up to fifty underruns. The no-go stands, because the feature was justified by audio taking 17 to 34 per cent, but the margin is three to six times rather than an order of magnitude, the ratio is still climbing on the largest song measured, and unpinned a busy song already underruns on a loaded machine."
    },
    {
      "title": "Any bundled demo can be opened by URL, not just by clicking its card",
      "version": "2026.8.224",
      "date": "2026-09-20",
      "area": "Web",
      "type": "Internal",
      "userFacing": false,
      "details": "?demo=elevation, ?demo=thedark and any other DemoLibraryService key now open that shipped .ezproj on boot, the way ?open=central:<file> already opens a central song. Before this the only value ?demo= accepted was synth, which means the in-process builder song rather than any file, so the .ezproj demos were reachable only by clicking a card on the welcome screen - and a browser session cannot click a canvas, which left the web head with no way to be pointed at a busy song. That gap is why the #244 web measurement had to be taken on the smallest demo and then projected. synth, cerebrum and liquid keep exactly the meaning they had, so nothing that exists changes, and an unknown key boots normally instead of sitting on a blank screen. StudioOptions.ReadDemoKey takes the catalogue's keys as a parameter rather than reading DemoLibraryService, which keeps that file free of dependencies and lets DemoUrlTests cover it without dragging the head in; 18 tests, including that every key the welcome screen offers is reachable, so a demo added later cannot silently be unreachable by URL."
    },
    {
      "title": "Audio is 2 per cent of the web build's thread, not a third of it",
      "version": "2026.8.224",
      "date": "2026-09-20",
      "area": "Web",
      "type": "Internal",
      "userFacing": false,
      "details": "Gate 1 of the #244 render-ahead feature, measured on the real head, and it is a no-go that kills the feature before it was built. docs/gpu-dsp-next.md section 10. The web head was published with AOT, served locally and driven in headless Edge 154, and the audio cost read from the counters the shipping pump already keeps rather than from anything added for the measurement: ezmuze-audio.js records RenderBlock avg against a 2048-frame block, so the share of realtime is the app's own arithmetic. Two runs at different machine loads give 0.990 and 1.117 ms per 46.4 ms block, which is 2.1 and 2.4 per cent of the single thread, with no underruns. The same song rendered natively in the same 2048-frame blocks costs 0.903 ms, so WASM AOT is 1.10x native rather than the 1.5 to 3x the earlier write-up assumed from a published figure - and that single correction is what removes the case for the feature, since the whole justification was audio taking 17 to 34 per cent of the only thread. A busy song projects to about 3.2 per cent. Meanwhile TickDotNet, the Update and Draw pass, takes 2.4 to 2.7 ms of an 8.3 ms frame, so the UI costs roughly ten times what the audio does and is where any web performance work should look. WebGPU on the real head is confirmed good - a 3.2 ms submission floor, and the result is collectable within the SAME animation frame at every span tested, where the design had budgeted one to four - but there is almost nothing worth moving. Also corrects this document's own per-channel table, which overstates real cost about twofold by forcing every channel to sound where a real song culls silent branches."
    },
    {
      "title": "The warm-up a rendered bar needs is a per-channel property, not a constant",
      "version": "2026.8.224",
      "date": "2026-09-20",
      "area": "Sound engine",
      "type": "Internal",
      "userFacing": false,
      "details": "Section 9 of docs/gpu-dsp-next.md records gate 2 of the #244 render-ahead feature and what its COLD column implies for the design. Of the 55 modules tested, 37 produce an identical bar whether the renderer has seen the earlier bars or not, so a channel made only of those can be rendered ahead from a standing start with no replay at all. The other 18 need warm-up, and the cold error says how much: fx.multifilter is 33.5 dB down, meaning it has essentially converged within a bar, while fx.ensemble is 3.4 dB UP, meaning the cold render is not merely different but wrong. So an ahead-renderer does not need one warm-up rule; it can derive the length from the channel's own module list, which makes the cheapest version of the feature cheaper than the design assumed."
    },
    {
      "title": "A pre-rendered bar matches the live path, on every module",
      "version": "2026.8.224",
      "date": "2026-09-20",
      "area": "Sound engine",
      "type": "Internal",
      "userFacing": false,
      "details": "Gate 2 of the render-ahead feature in #244, and it passes. tools/GpuDspSpike --sections determinism renders every module in the catalogue four ways and compares bitwise with the FNV-1a DeterministicHash that PatternCacheChecksum and BounceCacheManifest already use. REPEAT: two fresh graphs, same bar. SPLIT: one bar as a single RenderSpan call against many small ones, which should be identical because RenderSpan chunks to 64-frame ticks internally, and is checked rather than assumed because the whole feature rests on it. REPLAY: a fresh graph replays the earlier bars discarding their output and then renders the one that matters, which is exactly what a second renderer has to do and is the trick BounceWorker already uses. COLD: the bar rendered from nothing, which is not a pass or fail but a design input saying how much warm-up an ahead-renderer needs. Result: 55 of 57 modules tested, ZERO failures on repeat, split or replay. The two flagged as the likely killers both pass - gen.noise and mod.drift are deterministic and replayable, so they are counter or position based rather than clock seeded, and both simply need warm-up. 37 modules need no warm-up at all; 18 do. Two could not be tested: util.zone, whose output had no effect through a multiplier and needs a note-dependent harness, and route.send, which is a sink with no output. Control-rate modules are not exempt and are wired through a multiplier, with a check that the wire actually changed the output so a module that did nothing cannot pass by accident."
    },
    {
      "title": "Evaluated rendering a bar ahead on the GPU, and what fits in a bar",
      "version": "2026.8.224",
      "date": "2026-09-20",
      "area": "Sound engine",
      "type": "Internal",
      "userFacing": false,
      "details": "docs/gpu-dsp-next.md evaluates two #244 follow-ups with numbers and builds nothing: compiling a whole module graph to one shader, and rendering a channel ahead once it settles. Both are judged against the budget the user set - one bar, which is 2.286 s at 105 BPM and 1.379 s at 174 - and a bar fits with 20 to 45 times to spare, at roughly 50 to 75 ms of GPU time for a whole song's channels. tools/GpuDspSpike --sections next measures every channel of every shipped demo warm through a real ChannelModuleGraph: the most expensive is 15.61 ms per second of stereo, all 22 together are 112.23, which is 11.2 per cent of realtime. That last number is why the bar budget matters more on the web than on the desktop: the desktop spends 11 per cent of one core out of 32, while the web build has a single thread that also draws the UI and feeds the audio callback, so the GPU there is not a speed-up but the only other processor. The graph-to-shader idea loses by 2.6x at the current 64-frame chunk, because 689 dispatches a second cost 41.3 ms per second of audio against a 15.61 ms channel, and stops mattering entirely per bar, so it is not a thing to build on its own. Shader compile cost is 3 to 616 ms and is not predictable from source length, so it needs caching by graph signature. Also corrects the framing of the earlier module verdicts: those were measured over two-second spans already, so they are per-bar numbers, but they were compared against all 32 cores - against ONE core, which is the web's situation, the same measurements are 2x to 11x. The recommendation is to build the render-ahead feature web-first, starting with a determinism check that could kill it in a day, and to measure the web frame budget, which is currently extrapolated rather than measured and is the biggest gap in the document."
    },
    {
      "title": "One device now reads the next one's buffer instead of being handed a copy",
      "version": "2026.8.224",
      "date": "2026-09-20",
      "area": "Sound engine",
      "type": "Performance",
      "userFacing": true,
      "details": "Where one device feeds another down a plain patch cable, the second now READS the first's buffer instead of being handed a copy of it. About half of every audio input a real song resolves is in exactly that shape - one source, unity gain, no offset, a control side that has not moved - which is 11.6 million of them in a render of Elevation and 12.6 million in The Dark. Worth 2.1-2.6% of a render, measured under load, so a floor rather than a headline. This is the one change in #245/#246/#247 that is not bit-exact by construction, and it is the one the user was asked about rather than told. Copying evaluates 0 + ((1 x) + 0), which turns a NEGATIVE zero into a positive one; sharing keeps the sign. Nothing else can differ, because every other term is an identity - and that is now counted rather than argued: resolving every shared buffer BOTH ways across the corpus gives 24.3 million differing samples out of 1.54 billion, every single one a signed-zero pair and not one anything else. The rendered output is unaffected even so, because both zeros saturate to the same 16-bit value: all four shipped demos render byte-identical, so no parity hash needed re-pinning after all. Safety rests on two things that are checked rather than assumed. A module may only be handed a shared buffer if it never writes to its own input, which is an opt-in - 13 of the roughly 63 kinds, with the DEFAULT being the safe one, so a new module needs to do nothing and keeps its private copy. InPlaceAuditTests renders every module in the catalogue with the buffer it was given under watch, from the moment it is filled to the moment it has rendered, and fails naming any kind that opted in and wrote to one; it also fails if a kind that opted in was never exercised, so an opt-in cannot pass by being untested. And a node's output can never be one of its own inputs, because the buffer pool frees a slot only when its last use is strictly before the consumer's position. Written up in docs/module-builder-design.md section 6a, where a module author will meet it.",
      "tickets": [
        247
      ]
    },
    {
      "title": "A channel stops measuring a level nobody asked for",
      "version": "2026.8.224",
      "date": "2026-09-20",
      "area": "Sound engine",
      "type": "Performance",
      "userFacing": true,
      "details": "A channel no longer measures its own level when nothing asked for it. vuCurrent and vuPeak are a SIGNAL a patch can take, not a readout the app draws - a channel can duck itself off its own meter - and they are reached from exactly one place in the engine. Most channels wire nothing to them, and those now skip the per-sample maximum entirely; whether anything reads them is settled once, when the graph compiles. The one case where this is observable is written down where the code is rather than glossed: a graph that skips the level does not keep the two fields current, and a recompile carries them across, so a live graph with no meter wire that is edited into one WITH a meter wire reads a stale level for its first chunk - 64 frames, 1.45 ms. It cannot happen in an offline render, a bounce or an export, because those compile the graph once and never recompile mid-render, so the value is either maintained from the first chunk or never read at all. VuSkipTests pins all three halves of that: a patch wired to its own meter still hashes exactly as it did before the skip existed, a patch without one hashes exactly as it did, and an offline render of the same song twice agrees bit for bit. All four shipped demos render byte-identical. This was the one item in #247 that could not be called bit-exact everywhere, so it was left as a decision rather than taken quietly.",
      "tickets": [
        247
      ]
    },
    {
      "title": "The matcher reads the note, not the tail - and an unmeasured pitch stops counting as a perfect one",
      "version": "2026.8.224",
      "date": "2026-09-20",
      "area": "Modules",
      "type": "Internal",
      "userFacing": false,
      "details": "Three faults of one shape, and a measurement that removed most of the argument. THE WINDOW: the matcher's test segments are eight seconds long and the note is two of them; the rest is release, reverb and noise floor. The pitch detector was being asked about all eight, so it answered about whatever was still ringing - patch 0's held C3 read 130.84 Hz at p10 and 781.65 at p90, a spread of 3094 cents, voiced on a quarter of frames. Pitch is now measured through the last note-off plus one second and no further; the same render then comes back voiced on 98% of frames with the spread down to 533, and across the corpus the median voiced share went from 0.25 to 0.92 with most patches landing spreads of one to thirty cents - real pitches. The extra second past note-off was measured rather than picked: it costs nothing for patches that stop (their voiced frame COUNT is flat from two seconds to eight, only the share falls because the denominator grows) and it takes a ringing pluck from 22 frames to 98, which is the difference between a reading and a guess. ABSENCE: Score.Compare read a missing pitch as `?? 0`, a PERFECT match, so a comparison the detector never spoke in sailed through the gate - 3 of 25 catalogue candidates did exactly that. PitchErrCents, RawPitchErrCents and TransposeSemitones are now nullable and an absence neither flatters nor vetoes; it is counted in MissingMeasures and printed as \"--\" so a score built from fewer terms looks like one. Swept the rest of the scoring for the same shape and found a third instance wearing the opposite face: EnvelopeDistance manufactured the WORST possible value, 2, for \"nothing here to measure\", and because it returned a plain double rather than a nullable the careful null-handling in Compare never saw it and averaged the fabrication in at full weight. That is the same bug the file already documents catching once, where scoring a null as the worst value made a patch score 0.8 against itself. It needs a silent reference to fire, which the runners skip, so it was latent - and invisible. EVIDENCE: the gate may now only veto on a segment that is trusted, that produced a reading, and whose reading rests on at least 40 frames in which BOTH renders were voiced - about half a second. No evidence, no veto: a candidate the detector could not read keeps its timbre score and takes its chances on the shortlist, where an ear settles it. Also found and fixed while in there: the per-reference analysis cache was a plain Dictionary read and written from Stage0's parallel loop, which can corrupt its buckets rather than merely lose an entry; and since pitch is now measured separately, the main measurement pass no longer asks for voice analysis it does not read, which drops a full-length YIN and a cepstral envelope per frame and more than pays for the shorter pass added. Ticket #239.",
      "tickets": [
        239
      ]
    },
    {
      "title": "The matcher's pitch measure, measured: two cliffs and a reference with no pitch",
      "version": "2026.8.224",
      "date": "2026-09-20",
      "area": "Modules",
      "type": "Internal",
      "userFacing": false,
      "details": "Every gate and every transposition figure in the patch matcher rested on one number, and a shortlist that reported almost every top candidate as needing exactly +1 semitone said that number was wrong. So the probe now asks the detector questions whose answers are already known - a semitone sweep from C1 to C7 read ABSOLUTELY, each tone against the frequency actually synthesised, rather than pairs that can only say the two disagreed. The detector turned out to have no drift and no bias at all: from C2 to B5 it is exact to about a cent, on pure sines and twelve-harmonic saws alike. What it has is two hard edges from its own constants - below 65.0 Hz it refuses outright (zero frames voiced) and above 1002 Hz it flips an octave down on every frame, confidently. The +1 semitone was neither: the Stage 0 reference patch has no stable pitch to be off by. Its own held-C3 render is voiced on a quarter of frames and its f0 runs 130.84 Hz at p10 to 781.65 at p90 - a spread of 3094 cents - so its median lands 50.5 cents sharp and Math.Round turns 0.505 into a confident \"+1 st\", and an octave-down candidate into \"+13\". The sign was never real either: Metrics takes the absolute value per frame before the median, so the error can never be negative and a candidate a semitone flat is indistinguishable from one a semitone sharp. Two more things the probe settled. Calibration check 4 returns infinity because the semitone gate VETOED it, not because a measure was missing - the two plucks read 35.1 cents and 44.7, a median of 39.9 against a threshold of 35 - and they are an octave apart, read on 8% and 10% of frames. And the held-out phrase rejects candidates because a pitch detector has no answer for a chord: asked for root-plus-fifth it locks the mixture's common period an octave below the root and reports 1201 cents with every frame voiced, and every overlapping dyad the phrase plays has a common fundamental of 49, 49 and 65 Hz - at or beneath the detector's floor by construction. Also found: Score.Compare reads a missing pitch measure as `?? 0`, a PERFECT match, which is the fault the file already warns about for the timbre terms applied in the opposite direction; 3 of 25 catalogue candidates passed the gate against patch 0 purely by being unmeasurable. No threshold or gate has been changed - the calibration check regresses, so the choice goes back to the user. Ticket #239.",
      "tickets": [
        239
      ]
    },
    {
      "title": "The demo songs' own instruments join the matcher's catalogue",
      "version": "2026.8.224",
      "date": "2026-09-20",
      "area": "Modules",
      "type": "Internal",
      "userFacing": false,
      "details": "The matcher's catalogue now includes the shipped demo songs' own instrument channels - 22 of them across four demos, taking it from 629 candidates to 651. They are the best candidates available and the easiest to forget, because they are not in a preset bank: a factory preset is a guess at what might be useful, a demo channel is a patch somebody tuned until a real song sounded right. They cost more to yield, since a demo channel can reference its song's wave bank, sound fonts and user-built module definitions, none of which exist outside that song - so the source keeps the song alive and hands each candidate the same GraphServices the renderer would, built once per song rather than once per candidate. Verified by probe rather than assumed: six demo channels render with peaks from 0.41 to 1.00, so the wave bank and definitions really do reach the graph. A device is deep-copied per use through a JSON round trip, because a device accumulates DSP state the moment it renders and two comparisons sharing one instance would let the first colour the second. Also adds checkpointing for the long runs: one JSON per patch with the ranked list, parameter values, seed and elapsed time, written temp-then-move so a kill mid-write leaves the previous file rather than a truncated one. Each carries a signature of the METRIC - its weights, its reject threshold and its test segments - and of the catalogue, and a checkpoint whose signature does not match the running code is re-run rather than loaded. The failure that guards against is not a crash but a metric that changed halfway through a resumed run, which would hold two scoring regimes side by side and rank them against each other with nothing about the numbers looking odd. Ticket #239."
    },
    {
      "title": "The matcher's first shortlist: it picks the sub basses, by ear not by name",
      "version": "2026.8.224",
      "date": "2026-09-20",
      "area": "Modules",
      "type": "Internal",
      "userFacing": false,
      "details": "Stage 0 of the matcher runs end to end and its first result is musically right. The catalogue is 629 candidates - 616 factory presets across 13 synth modules, plus each module at its defaults as a floor - yielded through a list of SOURCES so the demo songs' own channels can be added later as another source rather than a rewrite. Ranked against Amped Sub, a sub bass, the top two are Freyja's Sub Bass and Bifrost's Deep Sub, and seven of the top ten are basses; the metric has never seen a candidate's name, which makes this much stronger evidence than the calibration pairs because those were chosen and this was not. The three non-basses near the top are the useful half - they say the ranking is not merely sorting by low-end energy. Held-out scores on a phrase the search never saw track the search scores closely and one is better, which is the opposite of overfitting; a dash means a monophonic candidate could not voice the phrase's overlapping notes at all, which is a real property rather than a measurement failure. The cost was measured rather than estimated and the design doc's table was wrong by a factor of fifty-six: a candidate costs 5601 ms serial and 717 ms across 16 threads, because it renders 32 seconds of audio. Stage 0 is fine at that - 7.5 minutes for one reference - but stage 1 as planned would be seven hours per patch, so the doc now carries the measured numbers, marks the old estimates as superseded rather than deleting them, and lists the cuts that bring it back to about 18 minutes. Ticket #239."
    },
    {
      "title": "Songs render another 4.5% faster: the graph stops re-deciding what it already knew",
      "version": "2026.8.224",
      "date": "2026-09-20",
      "area": "Sound engine",
      "type": "Performance",
      "userFacing": true,
      "details": "Another 4.5% off a song render, and the ticket's own title turned out to be wrong - which is the useful part. #247 said node dispatch was now the biggest single cost. Instrumenting RenderChunk by phase, on a quiet machine, says it is not: per-node dispatch is about 3-4% of a chunk, roughly 7-16 ns per node visited. The 15% the sampling profiler attributed to 'RenderChunk self' is overwhelmingly module bodies the JIT INLINED into it, not the loop around them. What the phase split did find, as a share of a chunk: liveness 5.0% on Elevation and 10.5% on The Dark, the two phase-B passes 1.3-2.7%, the VU loop 3.4-3.5%, the bus 3.1-3.3%. Four things changed, all of them overhead rather than DSP. LIVENESS is no longer re-derived every chunk. It is a pure function of the graph's shape, which is fixed once it compiles, and of the gate ports - the other leg of a two-input math.mul - so every gate is still evaluated every chunk but the walk that turns them into an answer only runs when one of them moved. The Dark spent 10.5% of every chunk on this while skipping ZERO nodes out of 87.5 million visits, which is the whole case in one number. The two PHASE-B passes each walked the entire node list to find the one kind they care about - 175 million kind comparisons across a render of The Dark - and now read a list built when the graph compiled. The BUS AND VU loops were two passes over the same 64 floats and are now one. And math.mul and math.select3 resolve their input STRAIGHT INTO their output instead of staging it through a scratch buffer and copying, which is safe for a reason worth writing down: the slot allocator frees a buffer only when its last use is strictly before the consumer's position, so a node's output can never be one of its own sources. Measured in-process, min of four renders per process over five alternating rounds, because wall clock on the measuring machine drifts by tens of percent over minutes: Elevation -4.6%, The Dark -4.3%. BIT-EXACT throughout - nothing here changes an arithmetic operation or its order, only how often the same answer is recomputed - and proved with SHA-256 of all four shipped demos rendered before and after, identical. Together with #246 a busy song now renders about 16% faster than it did this morning.",
      "tickets": [
        247
      ]
    },
    {
      "title": "Two traps: a tool that overwrites your mixdown, and why two patches are silent",
      "version": "2026.8.224",
      "date": "2026-09-20",
      "area": "Modules",
      "type": "Internal",
      "userFacing": false,
      "details": "Two traps recorded in docs/vst-support-design.md, both found by doing the thing rather than reasoning about it. First, a data-loss bug in a tool: the reaper MCP's project_export_audio takes a path argument and IGNORES it, rendering to the project's own configured output instead - which on the target project is one of the user's own mixdowns sitting beside the .rpp. REAPER stops on a File already exists warning whose options are Increment, OVERWRITE and Cancel, and that warning is a modal that blocks the scripting bridge, so from outside it looks like a hung render rather than a question. Anything that dismissed it blind would have overwritten the mix. Second, the real reason two of the eleven Phase Plant patches render silent: they are precisely the two that use a wavetable generator, while every patch built on analog or sampler generators sounds. It is not the mod wheel, not a missing asset - the referenced factory wavetable is present on the machine - and not an asynchronous load, since a warm-up render and 600 ms of grace change nothing. The likely cause is the one thing the plugin complains about on every load, a Thrift pipe error from failing to reach the Kilohearts content service; factory wavetables live under managed content, so a plugin that cannot reach its content manager would resolve a sampler's file by path while a wavetable came back empty. Unconfirmed and flagged as such. Ticket #239."
    },
    {
      "title": "A plugin's mod wheel is now a port, like any other parameter",
      "version": "2026.8.224",
      "date": "2026-09-20",
      "area": "Sound engine",
      "type": "Internal",
      "userFacing": false,
      "details": "VST3 has no MIDI CC messages: a controller is mapped to a parameter and sent as an ordinary parameter change. The host now asks each plugin, during the scan, which parameter its mod wheel drives (IMidiMapping.getMidiControllerAssignment), caches the answer to disk with the rest of the scan, and exposes it as a PORT - so a wheel can be set, automated by a lane, or carried in a song exactly like any other parameter, with nothing on the audio path. It needs its own port because a plugin rightly does not flag a performance control as automatable, so it is not in the exposed set: Phase Plant maps its wheel to parameter 1073741825, well outside the ordinary id space, and without a port nothing could move it at all. Motivated by two of the eleven patches in the target project rendering silent at every pitch and every velocity - but honestly, it did NOT fix them, and they are still silent with the wheel up. The cause lies elsewhere and is now narrowed: one of the two is Stabber, which references a factory wavetable the project does not carry, and the other is Polaris, the patch the corpus analysis already singled out as an outlier with 52 modulation routings and seven random modulators. Both are excluded from the metric calibration's judgement pairs, because a comparison against silence saturates every term and would pass a check on an artefact. The mapped controller port is kept on its own merits. Ticket #239."
    },
    {
      "title": "The matcher's metric is calibrated, after three faults that all looked alike",
      "version": "2026.8.224",
      "date": "2026-09-20",
      "area": "Modules",
      "type": "Internal",
      "userFacing": false,
      "details": "The matcher's metric now has all four of its measures and passes every calibration check, after the calibration found three separate faults that each looked like the metric simply being weak. First and worst: every render was 0.13 SECONDS long. The bar length was written SampleRate * 4 * 60 / Bpm / 60, which is integer division twice and comes out sixty times too small, so the spectrogram had eight frames instead of seven hundred - flux correlation could never be computed and spectral distance only sometimes could. Nothing failed; the numbers were just quietly meaningless, and it took instrumenting the frame count to see it. Second: the source analysis was recomputed for every comparison rather than once per reference, which is what SourceAnalysis exists to avoid - the 220-comparison calibration reached ten gigabytes of live heap building spectrograms of the same four references over and over, and in a search of thousands of candidates against one reference that is quadratic. Third: pitch rejection threw out 97 of 220 comparisons, and the distribution showed why - the errors are bimodal, half under 15 cents and most of the rest piled at 1198 cents, one octave. Almost none of that is a wrong key; it is patches voiced an octave apart, which for matching is a transposition, and a pitch detector locking onto a second partial, which is an artefact. Pitch error is now folded to the nearest octave, the threshold is a semitone rather than a quarter tone, and the reject is a judgement about the candidate on the MEDIAN across segments rather than about any one segment - a candidate genuinely playing the wrong note is wrong on all of them. All four measures are now available in 220 of 220 comparisons; a patch scores 0.0000 against itself and 0.0011 rendered twice; a sub bass against a bright harp scores 1.278 and two plucks score 0.762, closer and in the right order. Ticket #239."
    },
    {
      "title": "Marked which CPU numbers in the GPU docs were measured cold",
      "version": "2026.8.224",
      "date": "2026-09-20",
      "area": "Sound engine",
      "type": "Internal",
      "userFacing": false,
      "details": "Two headline ratios this investigation reported - fx.plate's 7.5x and the four cheap modules' 5.7x - were largely artefacts of how tools/GpuDspSpike times things, and #245 and #246 have since profiled and fixed the real cost. docs/gpu-dsp-sweep.md section 9 is the diagnosis, reproducible with --sections warmth. The flaw is precise: Bench() made four calls where .NET's tiered JIT needs thirty to reach the optimising tier, and a two-second render is one call, so every CPU figure in those docs was taken at tier 0. That would be survivable if it scaled everything equally, but the cold penalty is code-shape dependent and therefore does not cancel in a subtraction or a ratio - measured, a large heavily-inlined method like the plate tank pays 1.80x for being cold while a tight loop over tanh pays 1.01x. Comparing the two at four calls each is not comparing their costs, which is how a real 1.6x was reported as 7.5x. The two ratios are withdrawn in both docs with a correction note where they were stated. Every GPU-versus-CPU verdict is unchanged and in fact strengthened, because the cold CPU mirrors made the CPU look slower and so flattered the GPU; a warm CPU widens every gap in the CPU's favour. The STFT result is untouched, being transfer-bound rather than compute-bound."
    },
    {
      "title": "No module in the catalog gets a GPU variant, and here is each one's number",
      "version": "2026.8.224",
      "date": "2026-09-20",
      "area": "Sound engine",
      "type": "Internal",
      "userFacing": false,
      "details": "Pass 2b of the #244 module sweep, in docs/gpu-dsp-sweep.md section 8, finishing the investigation. fx.saturator, fx.multifilter and fx.compressor each measured alone, per-sample and per-instance kernel shapes respectively, against the CPU on one core and on all 32. At the instance counts the shipped songs actually contain, the answers are 1.9x for the saturator at 17 instances, 0.6 to 1x for the multi-filter at 86 - the GPU is slower - and 0.3 to 0.4x for the compressor at 20, where the GPU is two to three times slower. The seven-to-eleven-fold figures only appear at 256 to 1024 instances, which no song comes within an order of magnitude of, and are partly the CPU benchmark hitting a memory-bandwidth wall rather than the GPU winning. This also answers the question the two class-B modules existed to ask: the plate's 97 KB of state was not the binding constraint, because a 32-byte serial module still needs about 200 instances to break even. Serial-in-time is what binds. The saturator's 1.9x is measured against a scalar MathF.Tanh and would plausibly vanish against a vectorised one. So nothing in the catalog gets a GPU variant: the surface - a separate algorithm identity, since a GPU result sits 72 dB from the CPU's, plus the gate and a fallback on every path, and never any live playback - is far too large for a number between 0.3x and 4x. The offline STFT path stays the one thing that pays, at 12x against all cores."
    },
    {
      "title": "The patch matcher's metric, and the calibration that caught it scoring wrong",
      "version": "2026.8.224",
      "date": "2026-09-20",
      "area": "Modules",
      "type": "Internal",
      "userFacing": false,
      "details": "tools/PatchMatcher is the start of the matcher (docs/vst-matcher-design.md): the test segments both sides play, one render path shared by the plugin and every native candidate, the score, and the calibration that has to pass before any ranking is believed. It does NOT yet search a catalogue - deliberately, because the calibration says the metric is not ready. On its first run a patch scored 0.8 against ITSELF: flux correlation comes back null when Metrics cannot compute it, and the score was charging a missing measure as the worst possible one, so the whole flux weight was added to every comparison including the identical ones. A missing measure is now simply not counted and the total is renormalised over the weights that were, because 'could not measure' is less informed rather than worse. With that fixed the five checks pass: a patch against itself scores 0.0000, the same patch rendered twice scores 0.0027 so the metric is not measuring noise, a sub bass against a bright harp scores 1.446, and two plucks score 0.973 - closer, in the right order. Two things the calibration then reported that nobody would have noticed otherwise: flux correlation is available in NONE of 220 comparisons and spectral distance in only 141, so the ranking currently rests on envelope and brightness with spectral two thirds of the time, and flux is exactly the measure that separates a pad from a pluck when their held spectra agree. It also found that two of the eleven patches render silent on these segments and excludes them from the judgement pairs, since a comparison against silence saturates every term and would pass the check on an artefact. Ticket #239."
    },
    {
      "title": "Oscillators can modulate each other's phase, and stack in harmonics",
      "version": "2026.8.224",
      "date": "2026-09-20",
      "area": "Sound engine",
      "type": "New feature",
      "userFacing": true,
      "details": "Two small things the engine could not do, both now ordinary parts of the kit. THE WAVE GENERATOR TAKES AN FM INPUT. Wire one generator's output into another's new FM in and the second one's phase is displaced per SAMPLE, with FM amount setting how far in cycles - the classic two-operator FM growl, bell and metallic timbre, which a control-rate LFO cannot produce however fast you set it because sidebands only appear when the modulator is itself in the audio band. It is the same law and the same two ports the wavetable oscillator has carried since 2026-08-02, now on the oscillator most patches actually reach for, and the source can be anything that makes audio - another oscillator, a sampler, a whole chain. The modulation is added to the read phase and never to the accumulator, so a patch stays exactly on pitch however deep the index goes, and at amount zero the render is bit-identical to what it was before, so nothing you have already made moved. HARMONIC (NTH PARTIAL) is a new Math component: a pitch in, an integer partial number, and the pitch of that partial out - pitch + 12*log2(n). Six sine generators at harmonics 1, 2, 7 and 13 off one note lane is a drawbar organ or an additive bell, built by wiring rather than by looking up four transpositions and typing them into dials where nobody can read back what they meant. The harmonic series is not linear - the steps are +12, +19.02, +24, +27.86 - so it was genuinely out of reach of the multiplier-and-scaler kit rather than merely inconvenient. Both were built for the native rebuild of the Phase Plant patches in the imported REAPER project, where eleven modulation routings are audio-rate phase modulation and one patch is six sine generators at fixed harmonics; both are general and ship for everyone. Ticket #239."
    },
    {
      "title": "Imported projects land at the right level, from a measured fader law",
      "version": "2026.8.224",
      "date": "2026-09-20",
      "area": "Core",
      "type": "Internal",
      "userFacing": false,
      "details": "The channel fader's law was measured with real renders rather than read off the source, and the importer now sets levels from the measurement. A leaf channel contributes exactly 0.7256 * v * v of what went in, and a FOLDER level contributes 0.6016 * v * v - both quadratic to better than 1% across four fader positions. Two things follow. The fader is held at maximum on every imported channel including the master, so it contributes a constant instead of a variable and every gain the project actually asked for rides on an fx.gain device where it is visible and editable; left at the 0.75 default it would take 2.5 dB at every level of the tree, invisible on the flat channel list a song here normally has and 7.5 dB on a three-deep REAPER project. And a folder is NOT transparent and cannot be made transparent with the fader - even at maximum it passes 0.6 of what reaches it - so each folder carries 1/0.6016 of make-up gain on its own device, applied per folder rather than accumulated at the leaves so a channel's gain still reads as the project's own number times one constant. On the target project this lifted the mix 12.6 dB with nothing clamped. What is left is a uniform -2.8 dB against REAPER, the leaf factor itself, which is a property of this engine's channel path and applies to every song rather than to imports; it is one fader move rather than a balance error. Five tests pin the constants, so if the renderer's channel or folder summing ever changes they fail before an import sounds wrong. Ticket #239."
    },
    {
      "title": "The reverb tank is 13% cheaper, and its sound is now pinned sample for sample",
      "version": "2026.8.224",
      "date": "2026-09-20",
      "area": "Sound engine",
      "type": "Performance",
      "userFacing": true,
      "details": "The reverb tank got 13% cheaper, and the ticket that asked for it was measuring something else. #245 reported fx.plate at 7.5x the cost of a plain implementation of its own structure - 34.75 ms per second of stereo against 4.60. That does not reproduce. The harness that produced it built a fresh graph INSIDE the timed region and rendered four bars, which is not enough warm-up for tiered compilation to promote PlateState.Render: the method holds the whole per-sample loop but is CALLED once per 64-frame chunk, so its loop is far too short for on-stack replacement and its promotion waits on the call-counting delay, which the measurement finishes before. The number was tier-0 code, unoptimised and without profile-guided info. Re-measured with the graph built outside the clock and the JIT warm, the tank costs 6.1 ms/s and the plain mirror 4.4 - 1.4x, not 7.5x, and part of that 1.4x is real work the mirror does not do at all (output trim, width, dry/wet, the denormal guards, the meter and the quiet countdown). It is also worth saying that no shipped demo uses fx.plate; Elevation's fourteen reverbs are fx.reverb2. This matters to HALL 9000 and to songs that reach for the plate, not to the demos. What WAS real, and is now fixed, is the output taps. Fourteen per sample, each one copying a twelve-byte entry out of a static table, turning its line index back into a delay line through a six-way switch, and recomputing offset x scale - a number that only changes when Size does. The table is still the source of truth but is resolved into line objects once in the constructor and the scaled offsets once per render, and the switch is gone; the JIT can now inline the tap read, which it could not before. 6.1 -> 5.3 ms per second of stereo per tank. BIT-EXACT: the same multiply with the same operands, just not recomputed per sample, and the taps are summed in the same order with the same signs. Pinned by PlateParityTests, seven settings across size, decay, damping, width, mix, diffusion and the freeze short-circuit, with hashes captured on the code before the change - so fx.plate's sound is now frozen the way a stretch algorithm's id is.",
      "tickets": [
        245
      ]
    },
    {
      "title": "A wire into a device now moves eight samples at a time on desktop",
      "version": "2026.8.224",
      "date": "2026-09-20",
      "area": "Sound engine",
      "type": "Performance",
      "userFacing": true,
      "details": "The second half of #246. Counting what a real song actually asks for: 47% of every audio input Elevation resolves, and 60% of The Dark's, are a single wire at unity gain with no offset and a control side that has not moved - in other words a plain patch cable. That case is now done with SIMD, eight floats per instruction on a desktop CPU, and so is the general one-wire and two-wire form around it. Elevation renders in 31.5 s against the 33.1 s it took after the first half of this ticket and the 37.2 s it took before either, so the two changes together are 15% off a whole song render; ResolveAudioInput has gone from 15.8% of the render to 5.1%. Guarded by Vector.IsHardwareAccelerated, which is a JIT constant: the web build does not enable WASM SIMD, where Vector is a software emulation that would be SLOWER than the plain loop, so the web keeps the scalar path and pays nothing for the check. STILL BIT-EXACT, and the reason is worth stating because the fast path looks like a copy and is not one: a packed single-precision multiply and add round each lane exactly as the scalar instructions do, nothing is contracted into a fused multiply-add, and the two additions stay in place even at unity gain because adding zero turns a negative zero into a positive one and a straight copy would keep the sign. Verified the same way as before - SHA-256 of all four shipped demos rendered before and after, identical. The buffer-sharing rework that would remove the copy altogether was measured and NOT done: it is worth about 3-4% more and it cannot be proved bit-exact, because handing a device the source buffer preserves negative zero where the arithmetic it replaces does not.",
      "tickets": [
        246
      ]
    },
    {
      "title": "Songs render about a tenth faster: the module graph stopped copying every wire twice",
      "version": "2026.8.224",
      "date": "2026-09-20",
      "area": "Sound engine",
      "type": "Performance",
      "userFacing": true,
      "details": "Profiled before it was touched, which changed what the fix was. ChannelModuleGraph.ResolveAudioInput was the single largest self-time entry in a whole-song render - 15.8% of Elevation and 14.9% of The Dark, more than any individual DSP primitive including the reverb - and the reason was that it walked the destination buffer once per wire plus once more for the control ramp. A plain one-wire audio input, which is most of them, wrote 64 samples of ramp and then read-modify-wrote them again to add samples that were already sitting in the source buffer. Two things changed. The ramp and every wire are now applied in ONE pass, and a control side that did not move between ticks - which is the normal state of an audio input, since a wired port has no knob behind it - is recognised as the constant it is instead of being recomputed per sample with an int-to-float conversion, a divide and a multiply. Elevation renders in 33.1 s where it took 37.2 s, The Dark in 28.9 s where it took 32.2 s, and ResolveAudioInput is down from 15.8% to 7.8% of the render. BIT-EXACT, and by construction rather than by hope: nothing is reassociated, so each sample still evaluates the same ramp and adds each wire's gain and offset in the same order with the same intermediate roundings; the loops changed which order SAMPLES are visited in, never the order of operations on any one of them. Proved by SHA-256 on the rendered WAV of all four shipped demos before and after - identical, every byte. Ticket #246 quoted 5.7x of graph overhead on a cheap chain; measured with graph construction outside the clock and the JIT warmed, the real figure was 1.6x, and the biggest part of it is now gone.",
      "tickets": [
        246
      ]
    },
    {
      "title": "The imported REAPER song plays, with every plugin loading",
      "version": "2026.8.224",
      "date": "2026-09-20",
      "area": "Core",
      "type": "Internal",
      "userFacing": false,
      "details": "show me love.rpp now opens in the studio and plays: 35 channels, 14 samples, 1544 sample clips, 36 patterns, 17 plugin instances each with its own patch, zero underruns, and the pattern body cache filling in as it goes (4 entries at load, 16 within seconds of pressing play) - which is the mechanism that makes eleven Phase Plants playable at all. Three fixes it took to get there, each one silent when wrong. REAPER'S CHUNK FRAMING IS NOT FIXED: every base64 line in a VST block carries its own padding, so joining the text and decoding once stops at the first '=' and yields 44 bytes of a 6042-byte blob; and the header grows with the plugin's bus count - 44 bytes for Phase Plant, 60 for the kHs effects, 156 for Multipass at 4-in/12-out - so any fixed offset works for exactly the plugins that happen to match it. Multipass was the one plugin in the project that would not load, and it failed by handing setState a blob starting in the wrong place. The state is now found by its shape: it is the last thing in the blob bar an 8-byte trailing field, with its length and a flag of 1 immediately before it, verified across all five plugins. A NODE WHOSE PLUGIN WOULD NOT LOAD is now guarded in the render path: the compile already reported it and left the node stateless, but the first chunk then dereferenced null, turning a documented silence into a crash. AND IMPORTED CHANNELS SIT AT UNITY: the fader is applied twice and multiplied by 1.3, so its effective gain is volume squared times 1.3 - invisible on the flat channel list a song here normally has, and very visible on REAPER's folder tree where it lands again at every level. The import carries REAPER's real gain on an fx.gain device, so the fader has no second job. Also adds a --render mode to tools/ImportReaper that renders the imported song offline and reports peak, RMS and speed, so \\\"does it make a sound\\\" has a number rather than a feeling. Ticket #239."
    },
    {
      "title": "A REAPER project can be imported, editable rather than baked",
      "version": "2026.8.224",
      "date": "2026-09-20",
      "area": "Core",
      "type": "Internal",
      "userFacing": false,
      "details": "src/Ezmuze.Reaper reads a REAPER project into a Song, and tools/ImportReaper is the driver. It follows the standing import rule - editable, not baked: a track becomes a channel, an audio item a sample clip you can drag, a MIDI item a pattern you can open, an FX chain a device chain. Nothing is rendered down, and everything that cannot be carried writes a ConversionNote so it shows in the conversion report exactly as a legacy import's losses do. On the target project it produces 35 channels from 34 tracks with the folder tree intact, 14 distinct source files in the wave bank feeding 1544 sample clips, 36 patterns from 36 MIDI items, 17 plugin instances each carrying its own patch, and 3 automation lanes - with exactly one lossy note, the master FX chain. Three decisions worth recording. Track volume and its envelope go onto an fx.gain DEVICE rather than the channel fader, because REAPER volume is linear gain that can exceed unity (five tracks here are between 1.09 and 1.95) while the fader is 0..1 through its own law and would clamp them - and being a component, fx.gain can carry the envelope as an ordinary lane, which a fader cannot. REAPER writes a VST3 class id with its first three fields byte-reversed, so the same plugin is EEB4229B003247D2007429F2006DAB52 in a project file and 9b22b4ee3200d247007429f2006dab52 here - two strings sharing no leading character, now converted and tested. And a parameter envelope names its target as a:b where b is the plugin's own parameter id, which is what a port is named after here, so it binds directly; verified against Phase Plant, where 11:431 labelled \\\"5: cutoff\\\" is parameter 431, a renamed macro. 18 tests, on small hand-written projects that isolate the decisions that are silent when wrong - a MIDI delta is decimal while its message bytes are hex, a note-on at zero velocity is a note-off, overlapping notes of one pitch both survive, and an unbalanced folder marker must not throw. Ticket #239."
    },
    {
      "title": "The fused GPU chain does not pay, and the graph overhead it exposed does",
      "version": "2026.8.224",
      "date": "2026-09-20",
      "area": "Sound engine",
      "type": "Internal",
      "userFacing": false,
      "details": "Pass 2a of the #244 module sweep, in docs/gpu-dsp-sweep.md section 7. The four highest-weighted per-sample-independent primitives - saturator, gain, multiplier, select3 - fused into one compute kernel, which pass 1 said was the only shape where the transfer floor is paid once for several modules. Fusing is worth exactly 3.95x against the same arithmetic as four round trips, which confirms to the decimal that transfer is the entire cost; the fused figure is flat from four instances upward, the signature of being transfer-bound rather than compute-bound. But against the CPU using all 32 threads the GPU wins by only 1.2x to 4x, and erratically - 1.2x at sixteen instances, where Parallel.For overhead and memory bandwidth trade places. Class A is the BEST case and was prototyped first for that reason, so nothing lower in the ranking can do better. The verdict is no. What the experiment did find is that those four modules cost 4.56 ms per second of stereo in a real ChannelModuleGraph where the same arithmetic written plainly costs 0.80 - 5.7x of per-node buffer traffic, short-to-float conversion and render dispatch, on modules whose work is four multiplies and a tanh. That is the same shape as fx.plate's 7.5x (#245), it is bigger and more certain than anything the GPU offers here, and unlike the GPU it is available live, on every head, with no gate and no new algorithm id. The CPU mirror is scalar, so the 8-10x against one core is an upper bound on the GPU rather than an estimate."
    },
    {
      "title": "How a hosted plugin channel becomes native modules, proposed",
      "version": "2026.8.224",
      "date": "2026-09-20",
      "area": "Modules",
      "type": "Internal",
      "userFacing": false,
      "details": "docs/vst-matcher-design.md proposes how a hosted plugin channel becomes native ezmuze modules, and docs/vst-support-design.md drops freeze-to-sample as superseded: a plugin channel does not end as baked audio, it ends REPLACED. Baked audio cannot be opened, retuned or learned from, and a demo exists to be taken apart - so the plugin becomes the reference a rebuild is measured against rather than something a demo contains. Three stages, cheapest first, each running only where the one before it fell short: a catalogue search that renders every native synth, preset and demo-song channel and ranks them against the plugin; then deliberately brute-force parameter rounds over the shortlist (coordinate descent, coarse then fine, presets tried as neighbours); then a structural port that reads the Phase Plant state.json and emits an equivalent module graph only where nothing else got close. All eleven patches in the target project were extracted and analysed for the evidence: the generator side needs just four kinds, six snapin types are half of all 82 effect instances, and parameters are plain scalars in sane units so they translate arithmetically rather than by ear. The difficulty is topology, not values - 11 audio-rate routings, 11 meta-modulations, 50 modulator-to-modulator routings and two levels of nested containers, nearly all concentrated in two patches. Six of eleven look portable one-for-one. The doc is honest that this is a shared toolkit plus eleven conversions rather than one general system, reuses the stretch harness's existing measures instead of inventing a metric, calibrates that metric on five pairs whose answer is already known before trusting any ranking, holds back test material the search never sees to catch overfitting, and never replaces anything without the user hearing an A/B. Also records what the pattern body cache does and does not do for a plugin channel, and the 1/4/6/11-channel playback measurements. Ticket #239."
    },
    {
      "title": "A plugin's patch now counts as part of what the audio caches are keyed on",
      "version": "2026.8.224",
      "date": "2026-09-20",
      "area": "Sound engine",
      "type": "Internal",
      "userFacing": false,
      "details": "Three hashes decide whether cached audio is still good, and none of them could see a hosted plugin's patch. ModuleInstance.ExternalState is opaque bytes held separately from Params, because the engine cannot interpret it - a plugin's wavetables, samples and modulation graph are all in there without being parameters - so PatternCacheChecksum.AddDevice, BounceCacheManifest.AddDevice and NaiveSongRenderer's graph signature all walked straight past it. The pattern body cache and the bounce cache would have kept serving the OLD sound after a patch changed, and the graph signature would not have recompiled, which matters more than it sounds because a plugin refuses a state load while it is active, so the patch is applied once at compile time and a graph that does not recompile never receives it at all. Audio that is wrong and confident is the worst thing a cache can serve. Five tests pin it, including that a device with no external state hashes exactly as before, so adding the field does not invalidate every pattern anybody has already rendered. Also: the plugin scan is now cached to disk at store/vst3-scan.txt, keyed on each bundle's path, size and modified time, which takes a full scan of the 80 plugins on this machine from 4074 ms to 38 ms; a cache that is missing, truncated, corrupt or from another version is discarded whole and everything is rescanned, because the failure being guarded against is the app not starting. And the instance pool now reaps unclaimed plugins above a cap, and says plainly in a comment why reuse is conditional on a lease being free: several graphs hold the same module instance at once and run concurrently on different threads, and a VST3 is not reentrant. Ticket #239."
    },
    {
      "title": "Surveyed every module in the catalog against the GPU",
      "version": "2026.8.224",
      "date": "2026-09-20",
      "area": "Sound engine",
      "type": "Internal",
      "userFacing": false,
      "details": "Pass 1 of the #244 module sweep, in docs/gpu-dsp-sweep.md: all 63 primitives the module graph designer offers, each measured through a real ChannelModuleGraph as a difference against the same graph without it, classified by structure from the source, and ranked by CPU cost times how much the shipped demos actually use it. tools/GpuDspSpike --sections sweep is the measurement. The headline reframes the task: NO module can pay as an individually dispatched GPU variant under today's render architecture, because the graph renders in 64-frame ticks, so one second of audio is 689 dispatches, and at the measured 0.06 ms submission floor that is 41 ms per second of audio before any DSP - more than the whole CPU cost of every primitive in the catalog except fx.plate, which is the one already proven GPU-hostile. In an offline span render the floor is transfer instead, about 0.14 ms per second of stereo per module boundary, which is paid once per FUSED kernel rather than once per module - so the win is in fusing, not in porting modules one at a time. Shortlist for pass 2: a fused chain of per-sample-independent modules, fx.multifilter (the highest real cost in the catalog at 86 instances across the demos, and only 32 bytes of state where the plate had 97 KB), fx.saturator (the only module whose inner loop is transcendentals), and fx.compressor. Ruled out by measurement rather than assumption: every delay-line effect, everything control-rate, and every content-bound module."
    },
    {
      "title": "A VST3 host of our own, wired into the audio graph and measured",
      "version": "2026.8.224",
      "date": "2026-09-20",
      "area": "Sound engine",
      "type": "Internal",
      "userFacing": false,
      "details": "Ezmuze.Audio.Vst is a new net8.0-windows library that loads and runs VST3 plugins, and it references nothing - not Ezmuze.Audio, not Ezmuze.Core, not KNI or GustUI - so it can be built and debugged on its own against real installed plugins instead of through the whole app. Nothing references it yet; wiring it into the graph is separate. It starts from the interop the Splice browser has been using in production and carries four proven additions across: a fallback to casting IComponent to IEditController when getControllerClassId says kNotImplemented (Phase Plant, Serum and every Kilohearts snapin are single-component plugins, four of the eight installed here, so treating that as a load failure loses half of them); parameter enumeration with an automatable filter of kCanAutomate and not kIsReadOnly and not kIsHidden, plus the plugin's own value formatter; component state save and restore over an IBStream; and RppReader, which lifts patches out of a REAPER project - the eight-byte prefix on that blob looks like sixteen, and reading it wrong cuts off the plugin's own header and setState answers a bare E_FAIL. New here: a host-side IParameterChanges and IParamValueQueue, which is the ONLY path by which host automation reaches a plugin's DSP - setParamNormalized talks to the controller, the UI half, and a plugin need not pass it on - preallocated whole so a block never allocates; an input overload of ProcessInterleavedStereo that copies a stereo block into the plugin's input bus, so effects work as well as instruments; getLatencySamples reported (not compensated - that belongs to whoever owns the graph); and Vst3Scanner, which enumerates .vst3 bundles recursively without loading any of them, finding 80 on this machine including the ones vendors nest a folder or two down. The API shape enforces the one ordering trap that costs an afternoon: setState is only accepted while the component is inactive, so a patch is handed to SetupAudio, which applies it in the last moment before setActive(1), and LoadComponentState refuses outright once audio is running rather than passing a doomed call down. tools/Vst3Probe is the harness. Its default run is the acceptance test and the first measurement of 64-frame blocks, which nothing had tried: Phase Plant with a patch out of a REAPER project, 48 kHz, 1500 blocks of 64 frames, peak 0.5395 - the same patch and the same 0.54 the 512-frame probe got - at a mean of 0.040 ms per block against a 1.33 ms budget, 3.0% of one core, with one 1.55 ms outlier on the first block. Ticket #239. THE GRAPH SIDE, and the measurement that decides the shape of the rest. Vst3ModuleHost presents a scanned plugin to the engine as an ordinary module: type id vst3.<class UID> (the plugin's permanent identity, not the filename, which moves, nor the display name, which is localised and which Kilohearts leaves the vendor field empty beside), one control port per AUTOMATABLE parameter named p<paramid> so an automation lane binds with no new mechanism, every range 0..1 because a VST3 parameter is normalised by definition. Phase Plant declares 2174 parameters and 94 of them are automatable, so the filter is not cosmetic. Instances are POOLED by component id and handed out against a weakly-held borrower, because graphs are never disposed and a channel recompiles on every knob move while a plugin costs 5-340 ms to load; two live graphs never share one, since a background bounce can render while the transport rolls. Registration is by reflection on a DLL beside the executable, copied only by dotnet build -p:EnableVstHost=true, so a release build gets the gate right by doing nothing. MEASURED end to end - eleven real Phase Plant patches out of show me love.rpp, in real ChannelModuleGraphs, at the engine's own 64-frame ticks: one channel renders 26x real time at 3.4% of the tick budget, four at 2.4x and 44%, six at 2.0x and 54% with five ticks over budget in 6860. Eleven at once is 1.0x real time and half its ticks miss - so a handful of plugin channels plays live and the whole song does not, which is what makes rendering a finished channel to audio the next thing worth building rather than a nice-to-have. Two bugs found by that harness and fixed: a node's audio output buffer is chunk-length and holds the chunk at index 0, not bar-indexed, so writing at the absolute frame walked off a 64-float array on the second tick of the bar; and pushing all 94 parameters every tick to every instance cost about five times the DSP itself, so only a CHANGED value is sent now."
    },
    {
      "title": "Measured HALL 9000 on the GPU, and designed the level-by-level batching",
      "version": "2026.8.224",
      "date": "2026-09-20",
      "area": "Sound engine",
      "type": "Internal",
      "userFacing": false,
      "details": "Two write-ups for bug board #244, no implementation. docs/gpu-dsp-spike.md section 11 answers whether HALL 9000's plate tank gains anything from a GPU: no. One instance runs 17x SLOWER on an RTX 3070 Ti than on one CPU core (73.6 ms per second of stereo against 4.4), break-even against one core needs 32 simultaneous tanks and against all 32 threads needs 512, and the busiest shipped demo has 14. The block-parallel form caps at 107 samples of width, the tank's shortest recurrence, and is dominated by simply batching more tanks. Two things did come out of it: the shipped fx.plate tank costs 34.75 ms per second of stereo where a plain mirror of the identical structure costs 4.60, so there is a 7.5x CPU optimisation sitting in it that beats anything the GPU offers here; and if a GPU reverb is wanted the thing to build is a convolution reverb, which is FFT-shaped and therefore the best case rather than the worst. docs/gpu-dsp-batching.md designs the wavefront: the levels already exist latent in ChannelModuleGraph's Kahn sort, channels are already independent within a chunk because sends carry a chunk of delay, and feedback is already cut at util.buffer - but the 64-frame chunk is fatal, because even one dispatch per chunk costs 41 ms per second of audio. The design therefore batches over time as well as channels, which util.buffer's one-tick delay constrains, and keeps every buffer resident on the device between levels because transfer is the whole cost."
    },
    {
      "title": "A new Preferences setting: Enable GPU DSP",
      "version": "2026.8.224",
      "date": "2026-09-20",
      "area": "UI",
      "type": "New feature",
      "userFacing": true,
      "details": "Preferences > Audio has an Enable GPU DSP switch. It lets the parts of the engine that can use it hand work to your graphics card instead of the CPU - for now that means offline work like filling the clip cache, bouncing and exporting, and nothing reads it yet. It is off by default while it is new. Anything the graphics card cannot do, or that goes wrong, falls back to the CPU on its own, so turning it on can never cost you audio. On a machine that cannot do it at all - the OpenGL desktop build, or a browser without WebGPU - the row is still there but greyed out, and the line under it says why rather than leaving you hunting for a setting that was never drawn."
    },
    {
      "title": "The GPU DSP gate every component will ask before using the graphics card",
      "version": "2026.8.224",
      "date": "2026-09-20",
      "area": "Sound engine",
      "type": "Internal",
      "userFacing": false,
      "details": "Ezmuze.Audio.GpuDsp is the one gate for bug board #244: Enabled is the user preference AND a cached probe of the machine, and TryGetContext hands out a device or returns false and leaves the caller on its CPU path. The CPU path is never optional - the gate closes on a missing provider, a probe that throws, and a device that cannot be created this time, each of which is a test. EzmuzeStudio.Data.GpuDspProbe is the platform half: Direct3D 11 at feature level 11_0 on the DirectX head (where cs_5_0 is mandatory, so a device that comes back IS the capability), navigator.gpu on the web, and an unconditional no on DesktopGL, which has no compute shaders at GL 3.3. EZMUZE_GPU_DSP=off forces the unsupported path on a machine that would otherwise say yes, which is the only way to see the disabled row and the fallback at all. Nothing uses the gate yet. GustUI ToggleSwitchElement gained Enabled to match BasicButtonElement, and the remote-control API gained gpuDspEnabled and gpuDspSupport."
    },
    {
      "title": "Tests for the new note-event compiler, and a switch for plugin hosting",
      "version": "2026.8.224",
      "date": "2026-09-20",
      "area": "Modules",
      "type": "Internal",
      "userFacing": false,
      "details": "Nine tests for PatternSignals.CompileNoteEvents, which is the one place in the engine that sees more than one note at a time: everything else reads the per-tick note buffers, which hold a single sounding note by construction, so a bug in the new compiler could not show up anywhere else - and the thing it exists for, a hosted plugin, is not in a shipped build and cannot be caught by a demo. They lock the three properties that matter: a chord emits every note rather than one, a note-on lands on its own frame rather than the 64-frame tick boundary, and a note-off carries its note's id so two overlapping notes of the same pitch cannot release each other. Writing them corrected an assumption too - a clip LOOPS its pattern, so a one-bar clip asked about bar 1 replays it rather than falling silent, matching CompileBar; which bars a clip covers is the caller's business. Also adds the UserPreferences.VstHostEnabled toggle, off by default: the real gate on plugin hosting is that the assembly is not in a release build, and this is the second, weaker one, for the build that does have it - off means no folder scan at startup and no third-party DLL loaded into the process, which is worth a switch on its own when a plugin runs in-process with no isolation. There is deliberately no account check. Ticket #239."
    },
    {
      "title": "The audio graph can now run a module it does not implement",
      "version": "2026.8.224",
      "date": "2026-09-20",
      "area": "Modules",
      "type": "Internal",
      "userFacing": false,
      "details": "Engine-side half of VST hosting, with no host attached yet and nothing referencing one. ExternalModules is a registration point in Ezmuze.Audio that names no plugin format: a head that has a host registers it, ModuleRegistry.Find asks it about any type id the static table does not know, and ChannelModuleGraph gains one NodeKind that delegates to it. Nothing registers a host in a shipped build, so every such id stays unknown and the existing rule applies unchanged - that component renders silence with a warning while the channel, its wires, its lanes and its saved params are preserved. That IS the missing-plugin behaviour, on every platform and in the browser, and nobody had to write it. The one thing that did need writing is notes. BeginBar reduces a Pattern to per-tick buffers holding ONE note at a time, because polyphony here is the generator device cloned per voice; a node that does its own voice allocation must not be cloned and so needs the notes themselves. PatternSignals.CompileNoteEvents compiles the bar into frame-placed on/off events off the same per-tick projection as CompileBar, so clip offset, tempo ratio, cropping and content looping are not re-derived, and the events carry note ids so two overlapping notes of the same pitch do not release each other. Also: a ModuleInstance can carry an external module's opaque state as base64, deliberately without a song-format bump (it is additive, and bumping would fail ShippedDemoFreshnessTests for every demo until each was re-saved); an external node is carried across a recompile like any other state, which matters because rebuilding one means loading a plugin off disk; and a new test forbids any shipped demo from containing one. Ticket #239."
    },
    {
      "title": "VST plan approved, and the two risks it rested on settled by probe",
      "version": "2026.8.224",
      "date": "2026-09-20",
      "area": "Modules",
      "type": "Internal",
      "userFacing": false,
      "details": "docs/vst-support-design.md now records the answers to its six open questions instead of the questions: gate by not shipping rather than by account, #239 covers the host AND the REAPER importer together, freeze-to-sample becomes its own ticket, patch bytes live inside the song, the editor is wanted but last, and show me love is the only target song. Both risks the design flagged as unverified were settled before any feature code. The poly cloner needs no surgery — a device with MaxVoices 1 is never cloned and effects were never inside the cloning, so the only real work is keeping a frame-domain note list at BeginBar; risk 1 is now plugin instance count instead, since a device is compiled into three separate graphs per channel at 200ms an instantiate. And the patches restore: all eleven Phase Plant instances lifted out of show me love.rpp loaded into a fresh plugin, and nine of the eleven made sound on a bare C3. What had made setState fail was an 8-byte REAPER prefix being read as 16, which amputates the plugin's own header and returns a bare E_FAIL; setState must also precede setActive. Ticket #239."
    },
    {
      "title": "Measured what a GPU would be worth for time stretching",
      "version": "2026.8.224",
      "date": "2026-09-20",
      "area": "Sound engine",
      "type": "Internal",
      "userFacing": false,
      "details": "A spike, not a feature: nothing is wired into the audio path. tools/GpuDspSpike runs the same STFT phase vocoder on the CPU, on a D3D11 compute device and (in tools/GpuDspSpike/webgpu) on WebGPU in a browser, and docs/gpu-dsp-spike.md writes up the numbers and the recommendation. Headline: on an RTX 3070 Ti the GPU does a second of audio in 0.06 ms against 8.6 ms on one core and 0.64 ms on all 32 threads, and 84 per cent of that 0.06 ms is reading the answer back over PCIe rather than computing it. In a browser it is 173x single-threaded JS. Round trip for a second of stereo is under 1.2 ms even with the card saturated, so lookahead covers it. The conclusion is to do the stretch cache and bounces first, as their own algorithm ids (a GPU render differs from a CPU one at -72 dB, so it cannot share a cache id), to use WebGPU on the web through JS interop rather than KNI, and to get a number from integrated graphics before promising anything live. Also corrects the ticket's assumption that the DirectX head is Reach-limited: it runs a real Direct3D 11 feature-level 11_0 device and cs_5_0 compute is already available there."
    },
    {
      "title": "Design: hosting VST3 plugins, as a tool for porting REAPER projects into demos",
      "version": "2026.8.224",
      "date": "2026-09-20",
      "area": "Sound engine",
      "type": "Internal",
      "userFacing": false,
      "details": "docs/vst-support-design.md works out how a VST device would fit: which format (VST3 only — the SDK went MIT in October 2025, VST2 has not been licensable since 2018, and Kilohearts, who make every plugin in the target REAPER project, ship no CLAP), how C# hosts it (in-process managed COM interop, generalising the host already in src/Ezmuze.Services.Splice/Vst3 that was built for the Splice browser), where processing happens (the render-ahead thread, 64-frame blocks matching the control tick, parameter changes through IParameterChanges because only the audio path may cross threads), how plugin parameters become module ports and automation lanes, and why a song naming a missing plugin already behaves correctly through the existing \"unknown module renders silence\" contract. A throwaway probe over the existing host code loaded all eight installed VST3 plugins, enumerated their parameters, read back their patch state and got audio out of Serum and Vital from a synthesised note, at under 5% of one core each. The design is not implemented and has open questions for the user. Ticket #239."
    },
    {
      "title": "The Phase vocoder is no longer offered for new work",
      "version": "2026.8.224",
      "date": "2026-09-20",
      "area": "Sound engine",
      "type": "Improvement",
      "userFacing": true,
      "details": "Phase vocoder has left the Time-stretch engine menu, in Song settings and on a clip. Listening to every algorithm on the same loops put it behind all of them on every measure - how much of the sound it keeps, how many hits survive, how smeared the attacks are, how true the pitch is - while costing about twice what Signalsmith Lite costs. What is left to choose from is Overlap-add, Signalsmith Stretch, Signalsmith Lite, MNTS v5, MNTS v5.1 Lite and MNTS v5 Smart. Songs that already use the Phase vocoder are untouched: they still open showing it, still play it, and sound exactly as they did - including the demos that ship with the app. You only stop being offered it for new work."
    },
    {
      "title": "New songs start on Signalsmith Lite",
      "version": "2026.8.224",
      "date": "2026-09-20",
      "area": "Sound engine",
      "type": "Improvement",
      "userFacing": true,
      "details": "The Time-stretch engine in Song settings starts on Signalsmith Lite for a new song, instead of the phase vocoder. After listening to every algorithm on the same loops - drums, melodic loops, vocals and full mixes - it is the one that sounded best, and it costs a third to two thirds of the CPU of the full Signalsmith Stretch. Only new songs are affected: a song you have already saved opens on whatever it was saved with. MNTS v5 Smart, which was the default for a day, is still there and is cheaper again if you want it, and you can still pick any algorithm per song and per clip."
    },
    {
      "title": "A looped clip no longer comes in loud on its first hit",
      "version": "2026.8.224",
      "date": "2026-09-20",
      "area": "Sound engine",
      "type": "Bug fix",
      "userFacing": true,
      "details": "Signalsmith Lite renders a clip's loop straight from the source, and it was warming up on the loop's own opening instead of on the material that actually precedes it - which, in a loop, is its tail. The loop's first hit came out louder than the same hit next time round: up to 9 dB over its first 40 ms on a piano loop stretched to twice its length, which is audible as a slightly loud first chord. It now warms up on the tail, at the right pitch, and the first hit matches the later ones to within a decibel. Nothing else about the sound changed - the algorithm's own quality measurements moved by a hundredth of a decibel."
    },
    {
      "title": "MNTS v5 Smart picks the right stretch for each clip",
      "version": "2026.8.224",
      "date": "2026-09-20",
      "area": "Sound engine",
      "type": "Improvement",
      "userFacing": true,
      "details": "MNTS v5 Smart chooses between MNTS v5 and MNTS v5.1 Lite for each clip on its own: hits keep MNTS v5's timing, held sounds get the cleaner vocoder, and you do not have to decide. It was briefly what new songs started with; after listening, new songs start on Signalsmith Lite instead, and Smart sits one entry away in the same menu as the cheaper choice when CPU matters more than the last of the quality. Songs you have already saved are untouched either way."
    },
    {
      "title": "MNTS v5 Smart picks the right stretch engine for each clip, and the Formants row only shows where it counts",
      "version": "2026.8.224",
      "date": "2026-09-19",
      "area": "Sound engine",
      "type": "New feature",
      "userFacing": true,
      "details": "MNTS v5 Smart is a new choice in the stretch algorithm menu. It looks at each clip once, before it renders, and picks MNTS v5's overlap-add for drums and noise or MNTS v5.1 Lite's vocoder for chords, pads and vocals - so a drum loop costs what v5 costs and sounds exactly like v5, and a pad costs what Lite costs and sounds like Lite. The choice is made from the whole clip, so it cannot be heard changing mid-clip, and it is the same every time for the same clip, so a cached render never disagrees with itself. Asking to keep formants always picks the vocoder, which is the only one that can do them. On the test corpus it chose the overlap-add for seven clips of eleven. Two smaller changes go with it: MNTS v5.1 (the full split) leaves the menu - MNTS v5.1 Lite matches it at a third of the CPU, and songs that already name v5.1 still play it, unchanged - and a clip's Formants row is now hidden when the chosen algorithm cannot hear it, instead of being shown with a note saying so. The clip keeps its Formants answer either way, so switching back to an algorithm that honours it restores the choice. The default algorithm for new songs is unchanged. See docs/sample-clips-design.md 8.8."
    },
    {
      "title": "Signalsmith Lite: the same stretch for a fraction of the CPU",
      "version": "2026.8.224",
      "date": "2026-09-19",
      "area": "Sound engine",
      "type": "New feature",
      "userFacing": true,
      "details": "A cheaper companion to Signalsmith Stretch in the algorithm menu. It is the same algorithm on a longer hop (a 40 ms step instead of 30, with the analysis window unchanged), and it renders a clip's loop straight from the source instead of resampling it first and shifting it back - so there is no run-in to throw away either. Measured on the listening harness it costs 17 ms of CPU per second of audio against the full one's 28: a third of the CPU on big tempo changes, two thirds elsewhere. On drums, mixes, guitar and vocals it measures within half a decibel of the full version; on pads and piano, where a longer hop shows most, it is 2 to 4 dB behind, so the full one is still there for those. Nothing about the existing Signalsmith Stretch changed."
    },
    {
      "title": "MNTS v5.1 Lite: v5.1's sound on chords and pads at a third of the CPU",
      "version": "2026.8.224",
      "date": "2026-09-19",
      "area": "Sound engine",
      "type": "New feature",
      "userFacing": true,
      "details": "A new choice in the stretch algorithm menu, between MNTS v5 and MNTS v5.1. It is v5.1 without the harmonic/percussive split, which is where v5.1 spent most of its CPU: the vocoder takes everything, the hits are still copied whole by MNTS v5, and the hit detector listens harder because a hit it misses is one the vocoder would smear. Measured against v5.1 on four drum loops, a chord, a pad, a bass, a piano and a vocal: a third of the CPU (11 ms per second of audio to stretch, against 36), chords within 1 dB, the tonal clips within 0.02, and drum attacks actually sharper - the split had been sending a hit's tonal body through the vocoder. What it gives up is a few more stray onsets on busy drums. MNTS v5.1 itself is unchanged and still there. Also in this change: a grain whose source is silent is no longer run through the resampler, which is the same output to the bit and cuts pitch shifting from 39 to 29 ms per second in Lite. See docs/sample-clips-design.md 8.7."
    },
    {
      "title": "The clip Formants switch, checked in the running app",
      "version": "2026.8.224",
      "date": "2026-09-19",
      "area": "Sound engine",
      "type": "Internal",
      "userFacing": false,
      "details": "Drove the new per-clip Formants row through the remote control API on a sample-clip song: the row shows in the clip properties and says which algorithm it is (or is not) heard by, choosing Keep is one undo step, the choice is written into the song and comes back on reload, and toggling it re-renders that clip's cached lap - confirmed with a temporary log line in the cache, which logged the same lap twice, once per setting. MNTS v5 and MNTS v5.1 were also picked in both the song settings dropdown and the clip menu, and played with no errors. The one test left behind is the cache-key one: the switch changes the key for MNTS v5.1 and does not for algorithms that ignore it."
    },
    {
      "title": "Stretch harness: the live path and the Elevation mix pin",
      "version": "2026.8.224",
      "date": "2026-09-19",
      "area": "Build",
      "type": "Internal",
      "userFacing": false,
      "details": "tools/StretchHarness renders its live variants through the app's own SampleClipBarRenderer rather than a copy of the old live composition, so the page plays what the app plays. The pinned checksum of the Elevation mix corpus item moves with it: that render contains shifting clips, which now keep their top octave and their timing."
    },
    {
      "title": "Pitch-shifted clips play on the beat, not behind it",
      "version": "2026.8.224",
      "date": "2026-09-19",
      "area": "Sound engine",
      "type": "Bug fix",
      "userFacing": true,
      "details": "A sample clip that shifts pitch used to play late by whatever its algorithm takes to think: 37 ms on the Phase vocoder, 120 ms on Signalsmith. Nothing made that up, so a shifted clip dragged behind the rest of the song, and its cached loop dragged by the same amount. Each clip now runs its engine ahead by exactly that much, so it lands on the grid — live, and once cached, identically. MNTS v5 and Overlap-add have nothing to make up and are unchanged."
    },
    {
      "title": "Sped-up clips keep their high end while they play, not only once cached",
      "version": "2026.8.224",
      "date": "2026-09-19",
      "area": "Sound engine",
      "type": "Improvement",
      "userFacing": true,
      "details": "The cache learned to keep the top octave of a sped-up clip; playback had not, so a clip could sound dull and then brighten when its cache arrived. Every clip that shifts now has a pitch shifter of its own, shifting before the faster read exactly as the cache does, and MNTS v5 plays live straight from the source. What you hear while a clip plays is what its cache settles into. It costs more CPU on a channel stacked with shifting clips, since clips used to share one shifter: about 3.5x for four clips. Clips that do not shift are unchanged, as is the match between a converted song and the pattern form it came from except on channels whose clips shift."
    },
    {
      "title": "New time-stretch algorithm: MNTS v5.1, tight drums and clean chords, with a Keep formants switch",
      "version": "2026.8.224",
      "date": "2026-09-19",
      "area": "Sound engine",
      "type": "New feature",
      "userFacing": true,
      "details": "MNTS v5.1 is a new choice in the stretch algorithm menu, beside MNTS v5 and Overlap-add, which are unchanged. It splits the sound into its held part and its hits. The held part (chords, pads, bass, vocals) goes through a phase-locked vocoder, and the hits go through MNTS v5, which copies each one whole and on time. On a steady chord it puts almost nothing between the notes (-34 dB, against -20 to -24 dB for v5 and -25 to -28 dB for Overlap-add), and on drum loops the hits stay within a fraction of a millisecond and within 1 dB of the source's sharpness. Sample clips also get a new Formants row in their properties. Set it to Keep and a voice pitched up or down keeps its vowel size instead of turning into a chipmunk or a giant; only MNTS v5.1 hears it. v5.1 is heavier on CPU (about 37 ms per second of audio to stretch and 70 to shift pitch) and renders in the background. Until a clip's render is ready, it plays with MNTS v5. The default algorithm for new songs does not change. See docs/sample-clips-design.md 8.6."
    },
    {
      "title": "MNTS v5 finds drum hits more reliably",
      "version": "2026.8.224",
      "date": "2026-09-19",
      "area": "Sound engine",
      "type": "Improvement",
      "userFacing": true,
      "details": "MNTS v5's hit detector had three faults, all found while building v5.1. A held note's spectrum rippling as it was analysed could read as a hit every 67 ms, which put unaligned joins into sustained sounds. An attack early in its analysis window was placed up to 4 ms late, so its first milliseconds sat in a crossfade. And a softer hit soon after a loud one could be missed. On the four test drum loops at double length, hits now land within 0.2 ms instead of 0.8, and ghost hits drop from 0.10 to 0.03 per hit. One reece bass warbles a little more at x1.5. Cached MNTS v5 renders are redone (the algorithm's version is now 2)."
    },
    {
      "title": "Pitch trick effects go the right way on every stretch algorithm",
      "version": "2026.8.223",
      "date": "2026-09-19",
      "area": "Sound engine",
      "type": "Bug fix",
      "userFacing": true,
      "details": "On a song using the Phase vocoder, Signalsmith or MNTS v5, a Pitch region on the trick lane played the opposite way: +5 semitones came out 5 down. That included imported TriggerFX PitchUp, PitchDown and Native Pitch. The effect passes legacy's pitch ratio, where above 1 is lower. Both of legacy's engines read it that way, but only Overlap-add (MNTSV4) does here, so the others were handed it back to front. Every algorithm now gets the effect's ratio in legacy's convention, and a test renders +5 and -5 through the song renderer on each one. Overlap-add songs are unchanged. Imported songs saved with legacy's RubberBand engine, which plays as the Phase vocoder, now pitch their tricks the way legacy did; 12 songs in the legacy corpus change. No shipped demo uses a Pitch trick."
    },
    {
      "title": "Help > Credits now shows the third-party licences",
      "version": "2026.8.223",
      "date": "2026-09-19",
      "area": "UI",
      "type": "Improvement",
      "userFacing": true,
      "details": "Credits gains a Licences button that shows the full licence texts for third-party code built into ezmuze (Signalsmith Stretch and its STFT/FFT, both MIT, and the fdlibm sine/cosine kernels). The same notices now ship as THIRD-PARTY-NOTICES.txt with every build: beside the desktop app in data/, in the macOS bundle under Contents/Resources/data, and at the web app's root. The release build checks each head carries it."
    },
    {
      "title": "Every bar number in the app now counts from 0, matching the rulers",
      "version": "2026.8.223",
      "date": "2026-09-19",
      "area": "UI",
      "type": "Improvement",
      "userFacing": true,
      "details": "Following the rulers, the other places that name a bar count from 0 too: the status bar's \"Placed ... at bar N\" and \"Selected bars A-B\" messages (a single bar now reads \"Selected bar N\"), the loop pack build and import messages, and the publish dialog's \"channel - bar N\". The control API's pack_loops bar field counts from 0 like the rest of the API. Also fixed: the MCP transport tool advertised startBar/endBar for a loop range while the app reads start/end, so a loop range set through MCP was silently ignored."
    },
    {
      "title": "New time-stretch algorithm: MNTS v5 keeps every drum hit tight at any tempo",
      "version": "2026.8.223",
      "date": "2026-09-19",
      "area": "Sound engine",
      "type": "New feature",
      "userFacing": true,
      "details": "MNTS v5 is a new choice in the stretch algorithm menu, for a song and for each sample clip. Overlap-add (MNTSV4) is unchanged and still there. v5 grows out of it. Every drum hit is copied whole, once and exactly on time, and the stretch goes into the decay between hits, so there are no more flams or doubled hits at bigger tempo changes. Joins are lined up with the waveform on the mid signal and are short, so pads, bass and vocals warble much less. Both sides are cut together, so the stereo image holds. It works at any sample rate, follows pitch automation live, and uses the band-limited resampler when it changes pitch. Measured on four drum loops, hits land within about 1 ms, where MNTSV4 is off by 1-3 ms. Attacks stay as sharp as the source, where MNTSV4 is up to 10 dB softer, and ghost hits drop from as many as 0.9 per hit to 0.1 at double length. It costs about 6 times MNTSV4's CPU to stretch and 20 times to shift pitch, still about a tenth of the phase vocoder's. Live, before the cache has rendered a clip, it plays 58 ms late, because it has to hear a hit coming. The default algorithm for new songs does not change. See docs/sample-clips-design.md 8.5."
    },
    {
      "title": "New time-stretch option: Signalsmith Stretch",
      "version": "2026.8.223",
      "date": "2026-09-19",
      "area": "Sound engine",
      "type": "New feature",
      "userFacing": true,
      "details": "A third time-stretch and pitch algorithm joins Phase vocoder and Overlap-add in the song settings and the sample clip menu: Signalsmith Stretch, a polyphonic phase vocoder with phase locking that keeps chords, pads, vocals and full mixes clean when you change their tempo or pitch. It runs live (stereo, following a pitch lane block by block) and in whole-buffer renders, where both sides of a stereo loop are stretched together so bar-fitting keeps the stereo image. It is a managed C# port of the open-source Signalsmith Stretch 1.3.2 (MIT licence, see THIRD-PARTY-NOTICES.md) with its STFT and FFT, checked sample-for-sample against the original C++; it computes its own sine and cosine so desktop and web render the same bits. It costs about 16 ms of CPU per second of stereo audio on desktop, less than the existing phase vocoder, and has 120 ms of live latency. The default for new songs is unchanged."
    },
    {
      "title": "The sequencer and piano roll rulers now count bars from 0",
      "version": "2026.8.223",
      "date": "2026-09-19",
      "area": "UI",
      "type": "Improvement",
      "userFacing": true,
      "details": "The bar numbers along the top of the sequencer timeline and the piano roll start at 0 instead of 1, so the labelled bars land on 0, 4, 8, 16 - the starts of the sections songs are built from - rather than 1, 5, 9, 17. Between bars the bar:beat label counts beats from 0 as well. This is a display change only: songs, clip positions and files are unchanged, and status-bar messages that name a bar still count from 1 for now."
    },
    {
      "title": "Sped-up sample clips keep their high end",
      "version": "2026.8.223",
      "date": "2026-09-19",
      "area": "Sound engine",
      "type": "Bug fix",
      "userFacing": true,
      "details": "A pitch-corrected sample clip played faster than its source lost its top octave once its stretch was cached: at double speed, everything above about 11 kHz was gone. The clip was read faster first, which band-limits it, and then shifted back down. The cache now shifts at the source's own rate first and reads faster afterwards, so the full band survives. Every streaming algorithm gets this automatically, including ones added later. Slowing down and plain varispeed (pitch correction off) are unchanged. What plays live before the cache is ready, and clips under a moving Sample pitch lane, still take the old order."
    },
    {
      "title": "Overlap-add clips shift pitch the right way",
      "version": "2026.8.223",
      "date": "2026-09-19",
      "area": "Sound engine",
      "type": "Bug fix",
      "userFacing": true,
      "details": "A sample clip using the Overlap-add (MNTSV4) algorithm with a pitch change played it in the wrong direction once its stretch was cached: +5 semitones came out 5 down. That affected any clip with a pitch setting in cents. MNTSV4's engine reads a pitch ratio the opposite way from the others. The algorithm registry now has one convention (a ratio above 1 is higher), and MNTSV4's entry adapts to it. Legacy trigger effects keep driving MNTSV4 exactly as before, and a test checks every algorithm, current and future, on every clip path."
    },
    {
      "title": "Time-stretch listening harness: every algorithm, one A/B page",
      "version": "2026.8.223",
      "date": "2026-09-19",
      "area": "Sound engine",
      "type": "Internal",
      "userFacing": false,
      "details": "tools/StretchHarness (docs/sample-clips-design.md 8.4 step 2) renders a pinned corpus of drums, melodic loops, vocals and a full mix through every entry in StretchAlgorithms, discovered from the registry. It uses the app's own sample-clip cache job, plus the live stand-in for non-streaming entries. Lengths are x0.5 to x2 and pitch is +-1, 5 and 12 semitones. It writes WAVs and a loudness-matched A/B page to the git-ignored auditions/stretch-harness/, with objective measures per render: CPU, latency, onsets kept or extra, attack smear, pre-echo, spectral distance, top-octave loss, flux, and pitch, voicing and formant shift on vocals. The corpus uses in-repo CC0 and demo audio; legacy pack items are referenced by path and skipped when not installed. One command: dotnet run --project tools/StretchHarness -c Release. The default algorithm for new songs is unchanged."
    },
    {
      "title": "Sample clip fade curves settled as the standard shapes",
      "version": "2026.8.222",
      "date": "2026-09-19",
      "area": "Sound engine",
      "type": "Internal",
      "userFacing": false,
      "details": "The seven fade shapes are the standard curves each name describes (docs/sample-clips-design.md 9.1), no longer placeholders awaiting a measurement from REAPER renders; that measurement is dropped. The design doc also records a known, parked difference: The Dark's Vocal High past bar 34 differs between its pattern and sample-clip forms with the pitch shifter on only, cause unknown, render block size ruled out."
    },
    {
      "title": "Sampler notes start from exactly the right point, even with automation",
      "version": "2026.8.221",
      "date": "2026-09-19",
      "area": "Sound engine",
      "type": "Improvement",
      "userFacing": true,
      "details": "Follow-up to notes on polyphonic instruments starting exactly on time. A wave-bank sampler note now starts between two samples when that is where it falls, as a sample clip does, rather than up to a sample early. And an automated sample start point is read at the note itself rather than at the engine's control step just before it, so a start point that is automated to change right as a note begins - the usual shape in songs ported from REAPER - no longer starts that note slightly too early in the sample."
    },
    {
      "title": "A slipped clip's waveform now shows the part that plays",
      "version": "2026.8.220",
      "date": "2026-09-19",
      "area": "UI",
      "type": "Improvement",
      "userFacing": true,
      "details": "Slipping a clip's content with Alt+drag, or trimming its front, changes where the sound starts, but the clip kept drawing its waveform from the top. The face now starts where the sound does - the end of the loop first, then round to its start - and moves under the pointer while you slip, for sample clips and pattern clips alike."
    },
    {
      "title": "Drag a sample clip's top corners to fade it in and out",
      "version": "2026.8.220",
      "date": "2026-09-19",
      "area": "UI",
      "type": "New feature",
      "userFacing": true,
      "details": "Each sample clip now has a small handle in its top corners. Drag it inward to fade the clip in or out, as in REAPER; the fade's curve is drawn across the clip in the shape chosen in the clip's properties. The handle only takes the very top of the corner, so the rest of the edge still trims the clip as before. Each fade drag is one undo step."
    },
    {
      "title": "Loops placed from a pack channel's tabs land as sample clips",
      "version": "2026.8.220",
      "date": "2026-09-19",
      "area": "Sequencer",
      "type": "Improvement",
      "userFacing": true,
      "details": "A channel added From Pack (and every channel of a song started from a pack) now has no instrument: its tabs of loops are how you pick one, and dragging a loop onto the timeline places a sample clip of it - with the trim, stretch, slip, fade and pitch controls every sample clip has - instead of a one-note pattern for a wave-bank instrument to play. A pack loop dragged onto any other channel, or below the channels, lands the same way. Songs made before this keep working: they are converted when they open, as before."
    },
    {
      "title": "Notes on polyphonic instruments now start exactly on time",
      "version": "2026.8.220",
      "date": "2026-09-19",
      "area": "Sound engine",
      "type": "Improvement",
      "userFacing": true,
      "details": "An instrument playing more than one note at once used to start each note at the next 64-sample step of the engine's control clock - up to 1.45 ms late, by a different amount each time - while single-voice instruments and sample clips were already exact. Every voice now starts on its note's own sample, the same in playback, export and the background bounce, so chords and fast lines on polyphonic synths, samplers and kits sit tighter against the beat. Back-to-back notes keep the voice they were on, so glide and legato behave as before. The Dark's Vocal High, where two clips carrying the same audio overlapped and the late voice combed against the early one, now doubles cleanly."
    },
    {
      "title": "Clips snap to the edges of other clips as you drag them",
      "version": "2026.8.219",
      "date": "2026-09-19",
      "area": "UI",
      "type": "New feature",
      "userFacing": true,
      "details": "Moving a clip, or dragging either edge of one to trim or stretch it, now snaps to the start or end of any other clip on any showing channel when it comes within a few pixels of it, as REAPER does - ahead of the bar grid, which still applies everywhere else. When moving, either end of the dragged clip can catch. The reach is measured on screen, so it works the same at every zoom. A thin line marks the edge being lined up with while the snap holds. Holding Shift still turns every kind of snapping off for fine placement."
    },
    {
      "title": "A clip dragged with Shift stays where you let go of it",
      "version": "2026.8.219",
      "date": "2026-09-19",
      "area": "UI",
      "type": "Bug fix",
      "userFacing": true,
      "details": "Shift-dragging a clip (moving it, trimming or stretching either edge) placed it at the fine position you saw, but the timeline then drew it at the whole bar it sat in, so it looked as if it had snapped back to the grid on release. Clips are now drawn at their exact start and length, and pressing, hovering and edge-grabbing all go by where a clip is drawn rather than the bar it sits in, so a clip that starts part-way through a bar can be picked up where you see it. Letting go of Shift a moment before the mouse button also no longer snaps the clip back to the grid, and dragging a clip that starts off the grid no longer jumps it on the first move. Moves are checked for overlap to the tick, so a clip can land right up against another in the same bar."
    },
    {
      "title": "Automate a channel's sample pitch: the Sample pitch lane",
      "version": "2026.8.218",
      "date": "2026-09-19",
      "area": "Sequencer",
      "type": "New feature",
      "userFacing": true,
      "details": "Stage 6 of docs/sample-clips-design.md. On a channel with sample clips, + Add lane now offers Sample pitch: a lane in cents (up to four octaves either way) added to every sample clip on the channel, on top of each clip's own pitch. It changes pitch without moving timing, glides as the curve moves, and works with tricks like any other lane. Pitch-shifter automation from converted songs lands here."
    },
    {
      "title": "Songs made with sample patterns open with sample clips instead, and say what could not be carried over",
      "version": "2026.8.218",
      "date": "2026-09-19",
      "area": "Sequencer",
      "type": "New feature",
      "userFacing": true,
      "details": "Stage 5 of docs/sample-clips-design.md (song format v37). Opening a song converts every wave-bank pattern - a pattern whose notes pick a sample - into sample clips that play what the notes played: the same slice, fitted the same way, at the same speed and pitch. A loop spanning its pattern keeps its clip exactly; a drum pattern becomes a clip per hit. The wave-bank instrument goes; anything else built into it (a filter, a distortion) moves to the head of the channel's effects, keeping its automation. Pitch-shift automation becomes the channel's Sample pitch lane. Kits, chop-and-cancel output and melodic samplers are left as they are. If anything could not be carried over, the song says so once when it opens, and Song settings keeps the full report (with Copy). Legacy imports and the built-in demos land as sample clips too. Measured against the old sound on the legacy corpus and the demos: the converted songs null, apart from clips whose edges sit inside a bar, which now play exactly where they are drawn (the old sampler played them from the bar line)."
    },
    {
      "title": "Dropping a loop no longer waits while its tempo is worked out",
      "version": "2026.8.217",
      "date": "2026-09-19",
      "area": "Sequencer",
      "type": "Improvement",
      "userFacing": true,
      "details": "Listening for a dropped sample's tempo used to happen on the spot, holding up the app for the moment it took - longest in the browser. It now runs in the background: the sample lands at its natural length straight away and snaps to whole bars a moment later, as part of the same drop, so one undo still takes the whole drop back. If you have already done something else by then, the clip is left where you put it. Replacing a sample's audio in the wave bank now also forgets the old audio's tempo and works out the new one's, instead of fitting the new sound to the old tempo."
    },
    {
      "title": "Samplers play back cleaner at any pitch",
      "version": "2026.8.216",
      "date": "2026-09-19",
      "area": "Sound engine",
      "type": "Improvement",
      "userFacing": true,
      "details": "Wave-bank samplers, Capacitor and every loop now read their samples through a band-limited (windowed-sinc) resampler instead of taking the nearest sample. Anything played faster or slower than it was recorded - tuned drums, key-tracked samples, loops fitted to the song's tempo - no longer picks up the metallic aliasing that used to come with it, most audibly on hats and cymbals pitched up. Existing songs sound slightly different wherever a sample plays at a speed other than its own; a sample at its own speed plays exactly as before. Sample clips use the same read, so converted songs still match."
    },
    {
      "title": "Dropped loops work out their own tempo and fit the song",
      "version": "2026.8.216",
      "date": "2026-09-19",
      "area": "Sequencer",
      "type": "New feature",
      "userFacing": true,
      "details": "When you drop a sample, ezmuze now works out how many bars it is so a loop lands the right length and follows the song's tempo. It reads the tempo from wherever it is stated first - the service it came from, a tag in the file name like 122bpm, or the tempo a WAV file carries from the software that made it - and otherwise listens for it, lining the hits up against the beat grids that would make the loop a whole number of bars. It only answers when it is sure; anything else still comes in at its natural length, and a one-shot is never stretched to a bar. Where a loop could be read at two speeds (four bars at 87 or eight at 174) it picks the one nearest your song, so it stretches least. The answer is stored with the sample, and shown in the clip's properties as detected or from metadata."
    },
    {
      "title": "Tempo detection for samples: the detector and metadata readers",
      "version": "2026.8.216",
      "date": "2026-09-19",
      "area": "Sound engine",
      "type": "Internal",
      "userFacing": false,
      "details": "Stage 1b of docs/sample-clips-design.md: working out a sample's tempo and how many bars it is, so a dropped loop can be fitted to the song. Ezmuze.Core's SampleTempo.Resolve asks, in order: the pack (a legacy loop is four bars; a loop pack states its tempo), the service's stated tempo, a tempo tag in the file name (122bpm, 122 BPM, bpm122), the WAV file's ACID chunk, and then the new tempo detector. The detector lists the tempos that make the sample a whole number of bars and scores how well the hits line up with each one's beat grid, answering only when it is sure; the octave (4 bars at 87 or 8 at 174) is taken nearest the song's tempo. Against 721 legacy pack loops it answered 394 and got none wrong (96 % of breaks answered); 34 of 34 demo one-shots were left alone. Plain managed code, so it runs on the web build too."
    },
    {
      "title": "Stretched sample clips are rendered ahead in higher quality, and loop without a seam",
      "version": "2026.8.216",
      "date": "2026-09-19",
      "area": "Sound engine",
      "type": "Improvement",
      "userFacing": true,
      "details": "Stage 4 of docs/sample-clips-design.md. Each sample clip's region is rendered once, ahead of time, stretched and pitched as the clip asks, and played from that cache; until it is ready the clip plays live exactly as before, so nothing waits. The cached render uses the new band-limited resampler, renders the loop point as part of the audio so it is seamless, and runs the clip's chosen algorithm: a streaming one exactly as live playback settles into, and Overlap-add (MNTSV4) with its real whole-sample engine, which live playback can only approximate. Clips that agree on sample, region, tempo and settings share one render. Exports render the cache on the spot. The control API's /stats reports how many laps are cached."
    },
    {
      "title": "Samples you drop on the timeline are now sample clips, with their own pitch, gain, pan, fades and reverse",
      "version": "2026.8.216",
      "date": "2026-09-19",
      "area": "Sequencer",
      "type": "New feature",
      "userFacing": true,
      "details": "Stage 3 of docs/sample-clips-design.md. Dropping a sample, a wave-bank entry, a pack loop or a slice of one now places a sample clip rather than a pattern with one note in it. A sample clip plays straight into the channel's effects, whatever instrument the channel has (or none), so any channel takes one, and dropping below the channels makes an Audio channel with no instrument. A sample that says how many bars it is snaps to whole bars; otherwise it comes in at its natural length. Select one and the pattern explorer shows its properties: which part of the sample plays, the bars it is fitted to, reverse, pitch in semitones and cents, whether stretching keeps the pitch, which algorithm, gain, pan, and fade in and out with REAPER's seven shapes. Alt+drag on a clip slides the audio under it (slip), and S splits the selected clips at the playhead. Existing songs keep their sample patterns until the conversion lands."
    },
    {
      "title": "Time-stretch algorithm registry and a band-limited resampler",
      "version": "2026.8.216",
      "date": "2026-09-19",
      "area": "Sound engine",
      "type": "Internal",
      "userFacing": false,
      "details": "First step of docs/sample-clips-design.md section 8.4. StretchAlgorithms is the registry of time-stretch and pitch algorithms (section 7): stable ids that songs store (MNTSV4, FFTStretch, and RubberBand as an alias of FFTStretch), a version for cache keys, a display name, description, cost, latency, whether it can run live and what stands in when it cannot, and factories for its whole-buffer and streaming engines. Unknown ids are kept in the file and play with the fallback. Bar-fitting, TriggerFX pitch effects, fx.pitchshift and the song settings menu now all read it; bit-exact tests pin that their output is unchanged. Song settings no longer rewrites a stored engine name it does not recognise when only the tempo changes. Resampler is a new windowed-sinc resampler (32 taps, Kaiser beta 9, about -93 dB aliasing, about 1 percent of a desktop core per stereo stream) with a Hermite option, for the sample clip DSP and MNTS v5; nothing plays through it yet, so no existing song sounds different."
    },
    {
      "title": "Sample clips play: audio on the timeline goes straight into a channel's effects",
      "version": "2026.8.216",
      "date": "2026-09-19",
      "area": "Sound engine",
      "type": "Internal",
      "userFacing": false,
      "details": "Stage 2 of docs/sample-clips-design.md. The renderer plays sample clips: each reads its region through the clip's window (looping when the clip is longer), shifts pitch through the song's or the clip's engine (pitch correction, cents, and the channel's Sample pitch lane), applies gain, pan and fades, and layers with any other clip on the channel. They skip the instrument: on a channel with none they run through the whole effect chain, on a channel with one they take the generator's place in front of its effects, join a cached pattern body, or meet the live generator's output span by span. The non-real-time lane runs over them and the channel PCM window can read them. A sample clip nulls against the one-note wave-bank pattern it replaces, stretched and pitch-corrected included, which is what the stage-5 conversion depends on. Nothing creates sample clips yet."
    },
    {
      "title": "Sample clips: the song format can now hold audio placed directly on the timeline",
      "version": "2026.8.216",
      "date": "2026-09-19",
      "area": "Core",
      "type": "Internal",
      "userFacing": false,
      "details": "Stage 1 of docs/sample-clips-design.md. A clip can carry a SampleClipSource (which wave-bank sample, the region of it that plays, the bars that region is fitted to, pitch in cents, pitch correction, algorithm, gain, pan, fades with REAPER's seven shapes, reverse), and a wave-bank entry can record its declared bars and tempo and where that tempo came from. SampleClipMath holds the window arithmetic: which source frame is heard at any tick, the read rate, and the length a dropped sample snaps to. Song format v36, so older builds refuse songs that could hold sample clips instead of silently dropping them; the shipped demos are restamped. Nothing plays or edits sample clips yet."
    },
    {
      "title": "Sample clips design: converted instruments keep their built-in effects",
      "version": "2026.8.215",
      "date": "2026-09-19",
      "area": "Sequencer",
      "type": "Internal",
      "userFacing": false,
      "details": "docs/sample-clips-design.md, third round of decisions. On conversion, modules built into a wave-bank instrument (a filter, a distortion) move into a new effect device at the head of the channel's effects, keeping their ids so their automation still works; only inputs that cannot exist there (note gates, the player's control outputs) are dropped and reported. The conversion report stays; the offer to save a copy of the original song is dropped. Alt+drag slip editing is confirmed for both sample and pattern clips. Design only."
    },
    {
      "title": "Design for sample clips: audio placed directly on the timeline",
      "version": "2026.8.214",
      "date": "2026-09-19",
      "area": "Sequencer",
      "type": "Internal",
      "userFacing": false,
      "details": "docs/sample-clips-design.md: samples on the sequencer become their own kind of clip (a REAPER-style media item) instead of a pattern holding one note played by a wave-bank instrument. Covers the clip model (a region of the sample fitted to a length in bars, looped by the clip's window), how sample clips skip the channel's instrument and feed its effects, live stretching with a cache, a tempo detector, pitch in cents with a Sample pitch channel lane, fades, gain, pan, reverse, split and Alt slip editing, converting every existing wave-bank pattern on load (format v36) with a report of anything dropped, and a growing list of selectable time-stretch algorithms with stable ids, including an evolution of MNTSV4. Design only; no code yet."
    },
    {
      "title": "Looping a selection now repeats long drum loops properly",
      "version": "2026.8.213",
      "date": "2026-09-19",
      "area": "Sound engine",
      "type": "Bug fix",
      "userFacing": true,
      "details": "Loop a selection over a loop that is one long note - a four-bar drum loop is a four-bar pattern holding a single note across all four bars - and the first time round played right, but every time after that played on past the end of the selection into the rest of the sample. When playback jumped back to the start of the selection it landed on the same note it had just been playing, so nothing told the sampler to start again. Short loops hid it: a one-bar loop has already finished by the time playback jumps back, and a finished sample restarts on its own. Whenever the playhead jumps now (a selection coming round, or the song wrapping to the top), every channel starts the note at the new position as if you had pressed play there, part-way through the note if the jump lands mid-note. Reverb and delay tails keep ringing across the jump."
    },
    {
      "title": "Choosing Glue on a Korben now fills its lanes",
      "version": "2026.8.212",
      "date": "2026-09-18",
      "area": "Modules",
      "type": "Bug fix",
      "userFacing": true,
      "details": "A new Korben starts with empty lanes, and a preset like Glue is what puts a compressor, saturator and stereo width in each band and a limiter on the output. Picking it from the preset browser or the panel's Presets menu said \"Applied\" but left every lane empty: both applied the preset's knob settings and modulation and skipped the part that says which modules go in which lane, so the settings had nothing to land on. It had been that way since bare Korbens went empty on 2026-08-28. Both now go through one shared apply that builds the lanes first and then sets them, a lane module the song does not have is named in a warning instead of being dropped silently, and the panel redraws when a preset, or the undo of one, changes what a Korben holds. Undo works for this now too: before, undoing a lane add, a lane removal or a preset that rebuilt the rack left the lanes as they were, because the lanes were never part of the undo snapshot."
    },
    {
      "title": "A trigger effect can now play a bar it does not sit on",
      "version": "2026.8.211",
      "date": "2026-09-18",
      "area": "Sequencer",
      "type": "New feature",
      "userFacing": true,
      "details": "Every trigger effect that rearranges time - reverse, retrigger, shuffle, half speed, double up, stutter hold, segment and stretch - has a \"Reads from\" setting in its slot menu, measured in bars and beats, forwards or backwards, up to eight bars either way. Set a reverse to read one bar ahead and the region plays the NEXT bar, backwards, over the one it sits on; set a retrigger a bar back and the fill quotes the phrase that just played. It is off by default, so nothing you already have changes. Three ready-made presets are that setting pre-set, under names that say what they do: Reverse Ahead (the next bar, reversed), Stutter Back (a retrigger that repeats the bar before) and Rise Ahead (a grain stretch of what arrives two bars later - the drop in slow motion, before the drop). A region that reaches writes it on its face, as an arrow and a distance; select it and a translucent marker lies over the stretch of timeline it reads from, on the same lane, so you can see the link. Audio the app has not rendered yet leaves that bar dry until it catches up, and the status bar says so rather than letting a bar pass with nothing happening; reading past either end of the song is silence, because there is nothing there.",
      "tickets": [
        220
      ]
    },
    {
      "title": "Selecting a trigger FX region now redraws it straight away",
      "version": "2026.8.211",
      "date": "2026-09-18",
      "area": "UI",
      "type": "Bug fix",
      "userFacing": true,
      "details": "Clicking a trigger FX block to select it only marked the view dirty, and the dirty pass repositions the blocks that already exist rather than rebuilding them - so the selected state, which is drawn INTO a block (its full-strength fill and its outline), did not appear until some other edit happened to rebuild the lane. SelectTfxRegion and ClearTfxSelection now redraw the one band between them (SequencerView.RedrawTfxBand). Found while adding the \"reads from\" marker for #220, which is drawn the same way and so never appeared at all."
    },
    {
      "title": "An imported song's limiter now hears the whole bar, the way ezmuze 3 did",
      "version": "2026.8.210",
      "date": "2026-09-17",
      "area": "Sound engine",
      "type": "Improvement",
      "userFacing": true,
      "details": "ezmuze 3's master limiter looked at a whole bar at a time: if anything in the bar was going to clip, it turned the bar down and mixed it again, so a hit on beat 4 pulled beat 1 of that same bar down with it. The studio has been sizing the same gain per render block, which can only duck from the moment the clip happens, and over-ducks a long loud passage. The Legacy Limiter device grew a Window control - Per span or Whole bar - and importing an ezmuze 3 song sets it to Whole bar, because that is the only kind of song with an original render to match. Nothing else changes: a song without the device, which is every song made here and every demo, renders byte for byte what it did, and all four shipped demos are MD5 identical. The cost is that the engine has to render a bar ahead of what you hear, so a song with it on takes a moment longer to start and to seek (a bar's worth: about 20 ms on most songs, 100 ms on the heaviest of the reference set), and an edit made while it is playing is heard up to a bar later. Measured against the shipped ezmuze 3 renders, Mania - the only one of the fifteen that reaches the limiter at all - moves from 2.1 to 1.7 dB of within-bar shape error and 2.7 to 2.5 dB of level error."
    },
    {
      "title": "Signing out drops only what each lender lent, and the seed layers are thread-safe",
      "version": "2026.8.210",
      "date": "2026-09-17",
      "area": "Build",
      "type": "Internal",
      "userFacing": false,
      "details": "InstalledModules.ClearSession used to call PackageSeeds.ClearAll, a process-wide wipe of every package's lent definitions, panels, presets and images. The seed layers are shared by two lenders - installed modules and listen grants - and each already drops its own on the same key-change event, so the blanket wipe reached across into the other's. It is now owner-scoped like Forget is, and ClearAll is gone so the shape cannot come back. PackageSeedLayer also takes a lock on every read and write: one layer per kind serves the whole process, packages open where a download finished, and an unsynchronised Dictionary enumerated during a seed threw \"Collection was modified\". This was why PresetPackTests.An_installed_preset_pack_is_indexed_as_one_and_lends_its_presets and ListenSessionsTests.Signing_out_drops_every_grant_and_everything_it_lent failed about one full-suite run in five: another class's ClearSession emptied their seeds mid-assert. Pinned by InstalledModulesTests.ClearSession_leaves_alone_what_another_owner_lent; PresetPackTests now counts its product codes rather than drawing three hex digits."
    },
    {
      "title": "TriggerFX on an ezmuze 3 song's master row now import and play",
      "version": "2026.8.210",
      "date": "2026-09-17",
      "area": "Sound engine",
      "type": "New feature",
      "userFacing": true,
      "details": "ezmuze 3 applied the master row's TriggerFX (retriggers, tape stops and the rest) to the whole mixed bar before the master's own lanes and limiter, and imports used to drop them. They now import as regions on the master's non-real-time trigger lane, editable like a channel's. To have the whole mixed bar before it plays, the engine renders the channels a little ahead of what you hear, but only on a song whose master has such regions and only in the bars just before one; everywhere else nothing changes. While it is ahead, a change you make to a channel is heard up to about a bar later, as it was in ezmuze 3. Measured against the shipped ezmuze 3 renders, EnlargedHippopotomusPoops goes from 0.962 to 1.000 correlation, For The Love from 0.986 to 1.000 and Wet Hands Remix from 0.992 to 0.995, with its master TriggerFX bars now sample-exact to within -30 dB.",
      "tickets": [
        220
      ]
    },
    {
      "title": "Channel PCM window: ask a channel for its audio at any song position",
      "version": "2026.8.210",
      "date": "2026-09-17",
      "area": "Sound engine",
      "type": "Internal",
      "userFacing": false,
      "details": "NaiveSongRenderer.ReadChannelPcm returns a channel's generator audio at any song frame from the pattern body and tail caches, without copying them, and reports each frame as blank, live-only (unavailable) or not rendered yet. A not-yet read asks for the render (SongRepository.PumpNeededPatternPcm, polled by the sequencer). A channel's non-real-time trigger lane now reads outside its bar through the window, so a region reaching past its clip hears the next clip rather than the same pattern repeated, and it waits a bar rather than rearranging silence if that audio is not rendered yet. The master's version is mix-ahead: the renderer splits into a producer (channels and inner mixdown) and a consumer (master lane, strip, chain, final mixdown) with a ramped lead and a gate that renders a region bar on the spot after a seek. Forced through the split path, output is bit-identical to the ordinary path. /stats gains an audio block: render-ahead underruns, mix-ahead lead and bursts. Design: docs/channel-pcm-design.md sections 7 and 9."
    },
    {
      "title": "Imported automation tricks are pinned to ezmuze 3's per-sample law by a test",
      "version": "2026.8.210",
      "date": "2026-09-17",
      "area": "Sound engine",
      "type": "Internal",
      "userFacing": false,
      "details": "Investigating why Mania's bars under red/blue lane tricks played about 6 dB loud against the shipped ezmuze 3 render. The lanes are exact: across all 154 corpus songs, 168 lanes with tricks (1,873 bars, 958,976 points on legacy's 512-a-bar grid) match clamp(base + trick x amp, 0, 1) followed by the cube, and removing Mania's tricks or filters changes nothing. The excess is legacy's master limiter, which sizes its gain over a whole bar, so a peak on beat 4 also ducks beat 1. The engine sizes it per span instead (renderer deviation #4). Of the 15 reference songs only Mania reaches the limiter. TrickLaneLegacyLawTests pins the lane law for gain and filter lanes; the limiter itself is unchanged, and the numbers are in docs/legacy-import-review.md."
    },
    {
      "title": "An imported sidechain follows its source channel's fader, as it did in ezmuze 3",
      "version": "2026.8.210",
      "date": "2026-09-17",
      "area": "Sound engine",
      "type": "Improvement",
      "userFacing": true,
      "details": "ezmuze 3's SideChain and InverseChain listened to the source channel after its fader, so pulling the source down made the channel duck less. An imported song froze that level at the fader it was saved with, so moving the source's fader afterwards changed nothing. The Sidechain device has a new Key fader setting: Follow source multiplies the cross-channel key by the source channel's fader, refreshed every bar, and Key level then carries only the constant part (1.3). Imports turn it on; sidechains you add yourself keep the default, Ignore, and sound exactly as before. At the fader a song was imported with, the duck is unchanged."
    },
    {
      "title": "Imported ezmuze 3 TriggerFX are now trigger regions you can edit",
      "version": "2026.8.209",
      "date": "2026-09-17",
      "area": "Sequencer",
      "type": "Improvement",
      "userFacing": true,
      "details": "An ezmuze 3 song's TriggerFX used to arrive rewritten as chopped notes plus hidden speed, reverse and pitch lanes - so removing one reverse meant editing a note and a lane, and several effects played wrong (tape stops and ramps ran flat, Trance corrupted the volume lane, Stretch played three octaves down). Each channel's TriggerFX now import as regions on its non-real-time trigger lane: one per effect per quarter, stacked in the original order, using the trigger effects that reproduce ezmuze 3 sample for sample. Show a channel's trigger lanes to move, restack or delete them like any you drew yourself; the clips underneath are just the loop. Measured against the shipped ezmuze 3 renders, Mania goes from 0.43 to 0.94 correlation, The X-Files Theme Remix from 0.75 to 0.99 and Wet Hands Remix from 0.88 to 0.99. Also fixed on the way: a channel on a TimeStretch song can now play its trigger effects at all, and an echo or reverb ringing out of a channel's last block no longer cuts off at the next empty bar. TriggerFX on a song's master row are not imported yet; they need the master to be able to read its own mix ahead."
    },
    {
      "title": "Imported ezmuze 3 songs play the right loop on every channel",
      "version": "2026.8.209",
      "date": "2026-09-17",
      "area": "Services",
      "type": "Bug fix",
      "userFacing": true,
      "details": "An ezmuze 3 block that names its loop only by the old loop id could mean a different loop on each channel type (loop 20 of a pack's atmos folder and loop 20 of its sfx folder share that id). The importer made one wave bank entry per id, so whichever channel came first decided what the other channel played: 356 blocks in 16 of the 154 songs in the local corpus played the wrong loop. The importer now works out each block's exact loop id for its own channel type. This needs no pack, and it agreed with the installed packs for every block in the corpus. Each loop now gets its own entry, and the corpus has 0 wrong-loop blocks. Songs imported before this still hold the old shared entry, and are not repaired when opened. (Legacy import review item 1.)"
    },
    {
      "title": "Imported songs keep their loop levels: Normalise is back, per sample",
      "version": "2026.8.209",
      "date": "2026-09-17",
      "area": "Sound engine",
      "type": "Bug fix",
      "userFacing": true,
      "details": "ezmuze 3 had a song-wide Normalize switch that peak-scaled every loop to 65% of full scale as it decoded. 97 of 154 corpus songs used it, and without it their channel balance was off. Each wave bank entry now has its own Normalise setting, filled in from the song's switch at import and toggled from the wave picker's row menu (undoable). It uses ezmuze 3's own maths and is applied once when the loop decodes. Cached pattern renders now include the setting, so switching it re-renders the pattern. Against the shipped ezmuze 3 build, the level-matched residual on the Normalize songs went from -6 to -13 dB without it to -26 to -70 dB with it. The setting is left out of the song file when off, so existing songs are unchanged. (Legacy import review item 6, decision D7.)"
    },
    {
      "title": "Imported TimeStretch songs keep their key, stretched the way ezmuze 3 did",
      "version": "2026.8.209",
      "date": "2026-09-17",
      "area": "Sound engine",
      "type": "Bug fix",
      "userFacing": true,
      "details": "With TimeStretch on, ezmuze 3 stretched each pack loop to four bars of the song with its pitch engine when it decoded, keeping the key. The import resampled instead, so 33 corpus songs not at 98 BPM played in the wrong key. The renderer now does the same decode-time stretch whenever the song's TimeStretch is on, so the setting takes effect live from Song settings. Imported songs also use MNTSV4, the engine the shipped build actually ran whatever the file named (FFTStretch was never in the shipped build). Until a stretch is ready, the imported loop device's pitch shifter corrects the key: the shifter gains Fit rate and Keep key inputs, and the TriggerFX pitch interval still applies on top. Against the shipped build, the only TimeStretch reference song (2.0 dnb) went from correlation -0.01 with a 2296-frame lag to 0.988 with no lag. Several other songs improved slightly from the engine change. (Legacy import review item 7, decision D8.)"
    },
    {
      "title": "Long loops from the 2.0 and hamster packs no longer play fast in imported songs",
      "version": "2026.8.209",
      "date": "2026-09-17",
      "area": "Sound engine",
      "type": "Bug fix",
      "userFacing": true,
      "details": "ezmuze 3 cut every loop from the 2.0 Edition and hamster Edition packs to exactly four bars at 98 BPM before using it. Several of those files run for 4.25 to 6.22 bars, and the import fitted the whole file into four bars, so they played up to half as fast again. Wave bank entries now store how much of the source file plays, and the import sets it to four bars at 98 BPM for those two packs. The cut is applied at decode, before Normalise, as ezmuze 3 did. (Legacy import review item 13.)"
    },
    {
      "title": "Imported channels are named after their pack type and no longer show a file path",
      "version": "2026.8.209",
      "date": "2026-09-17",
      "area": "Services",
      "type": "Bug fix",
      "userFacing": true,
      "details": "Some ezmuze 3 songs stored a channel's type as the full path of the pack file. The import copied it as-is, so the row was grey, had no pack tabs, and named its samples \"C:/...\". It now keeps only the type name (12 channels in the corpus). Channels the user never named showed \"Ch 3\". ezmuze 3 showed the type's display name, and the import now fills that in: the installed pack's name for the type, or the type word, e.g. \"breaks\", when the pack is not installed (139 channels in the corpus). (Legacy import review items 18 and 19.)"
    },
    {
      "title": "Imported ezmuze 3 gain and filter lanes show the values their knobs show",
      "version": "2026.8.209",
      "date": "2026-09-17",
      "area": "Sequencer",
      "type": "Improvement",
      "userFacing": true,
      "details": "An imported gain lane used to store ezmuze 3's raw automation units, where 0.75 is unity, while its Gain knob showed 1.0; the curve drew squashed, filter lanes ran upside down, and copying a lane onto another parameter changed its loudness. Import now converts gain and filter lanes into the device's own units (1 = unity gain, 0 = filter open), adding points where ezmuze 3's cube curve bends so the lane stays within 0.5% (under 0.05 dB) of what ezmuze 3 played. Across the 154-song corpus 989 of 1,157 such lanes convert; the 168 that carry tricks keep raw units, because ezmuze 3 added a trick before the cube and no points can express that, and the lane editor now draws, edits and copies those in the knob's units too. Songs imported before this keep their raw lanes and play exactly as they did. (Legacy import review item 16, D3.)"
    },
    {
      "title": "Imported songs go quiet between blocks: effects stop at empty bars, as they did in ezmuze 3",
      "version": "2026.8.209",
      "date": "2026-09-17",
      "area": "Sound engine",
      "type": "Bug fix",
      "userFacing": true,
      "details": "ezmuze 3 did not process a channel in a bar with no block, and each block started from its own automation. An imported lane was joined point to point instead, so it ramped across every empty stretch and held its last value to the end of the song: across the corpus 5,962 empty bars had a gain lane off rest, 3,321 a filter lane and 1,118 an echo still open. Every run of blocks now steps in from rest and back out to it, so an echo or reverb tail is cut at the first empty bar (D5); all of those counts are now 0. Trance's gate is no longer written into the gain lane, where it played about 7.5 dB too loud, closed fully instead of to half, and deleted steps of the lane (about three quarters of all imported gain error); Trance waits for the gate effect of its own. Zero-depth tricks (22) no longer import as clips. (Legacy import review items 4, 8 and 20.)"
    },
    {
      "title": "Imported SideChain and InverseChain channels keep their amount, on a Sidechain device",
      "version": "2026.8.209",
      "date": "2026-09-17",
      "area": "Sound engine",
      "type": "Bug fix",
      "userFacing": true,
      "details": "An ezmuze 3 SideChain or InverseChain slot read its amount from automation a migrated channel no longer carries, so SideChain never ducked and InverseChain went silent while its source played (25 automated channels). Import now puts a Sidechain device at the end of the channel, keyed off the source channel, with its Fader lane holding the slot's amount and Inverse set for InverseChain, and switches the hidden ezmuze 3 duck off for that channel. The Sidechain device gained two settings, both defaulting to what it always did: Key level, which import sets to ezmuze 3's post-fader level (1.3 × the source's fader as imported), and Absent key, which import sets to Pass through so a bar where the source has no block leaves the channel untouched as ezmuze 3 did, instead of an InverseChain falling silent. The envelope is followed per sample rather than from ezmuze 3's 4096-a-bar peak table: over 23 corpus SideChain sources the average duck is -17.2 dB against ezmuze 3's -17.6 (median -12.2 against -11.5), with a median per-sample difference of 1.4 dB. (Legacy import review item 5, D10.)"
    },
    {
      "title": "Every imported channel can take patterns, and the master row's automation comes with the song",
      "version": "2026.8.209",
      "date": "2026-09-17",
      "area": "Sequencer",
      "type": "Bug fix",
      "userFacing": true,
      "details": "An imported channel with no loops got no devices, and a channel without a device cannot take a pattern: 108 corpus channels were rows nothing could ever be put on. Every imported channel now gets its loop player and Gain. The master row's ezmuze 3 blocks, which were never played and drew as dead clips (303 across the corpus), now import like any channel's: gain, red and blue filter slots (the master Reverb included) and echo become devices and lanes on the master chain, in front of the Legacy Limiter, neutral wherever the master row had no block (5 songs). Master TriggerFX is not yet imported. (Legacy import review items 10, 17 and 20, D9.)"
    },
    {
      "title": "Automation steps no longer click: gain and effect amounts glide between control ticks",
      "version": "2026.8.209",
      "date": "2026-09-17",
      "area": "Sound engine",
      "type": "Improvement",
      "userFacing": true,
      "details": "Automation is evaluated once per 64-frame control tick, and a Gain device used to apply each tick's value as a constant, so a gate or a sharp step in a gain lane jumped in a single sample and clicked. A lane-driven Gain now moves per sample along a straight line from one tick's value to the next, which also makes a smooth lane play smoothly rather than as a 1.45 ms staircase; a step lands on its beat with the ramp into it spread over the tick before, as ezmuze 3 did between its automation points. The same per-sample reading drives the amount of the Filter, Rate crush, Bit crush, Clipper, Echo and Reverb devices, and the five that can sit in an ezmuze 3 red or blue filter slot also slew their amount at that build's 0.0025 per sample (about 9 ms at 44.1 kHz), for knob moves as well as lanes. A control nobody is moving takes the old constant path, so unautomated devices sound and cost exactly what they did; a lane that moves on every tick of all six devices costs about a quarter more render time on those devices. Only lane-driven values glide: a wired modulation source is still read per tick. (Legacy import review item 11.)"
    },
    {
      "title": "A step in an automation lane plays its new value from the step's first frame",
      "version": "2026.8.209",
      "date": "2026-09-17",
      "area": "Sound engine",
      "type": "Bug fix",
      "userFacing": true,
      "details": "A step in a lane is two points on the same beat. The lookup stopped at the first point at or after the beat, so exactly on a step it read the OLD value, and because the engine samples a lane at the first frame of every bar, a step on a bar line played the previous value for the bar's first 64 frames (measured on an imported Feel The Chill: a gain of 2.2 for 1.45 ms where ezmuze 3 is silent). Lane lookups are now right-continuous at a step, in one shared helper (AutomationLaneSampling.PointValueAt) that the engine and the automation fingerprint both use, and the sequencer's lane drawing follows the same rule. The lookup also keeps a cursor through the bar instead of scanning from the first point on every tick, which matters on imported lanes with thousands of points. (Legacy import review item 14.)"
    },
    {
      "title": "Legacy song import is measured against the shipped ezmuze 3 build",
      "version": "2026.8.209",
      "date": "2026-09-17",
      "area": "Build",
      "type": "Internal",
      "userFacing": false,
      "details": "A new harness imports each of the 15 songs in render-reference the way the app does (import, then migration with no audio resolver), renders them, aligns each against the render from the shipped ezmuze 3.0 build (commit 0046954), scales ours by its peak the way that build normalised every export, and reports level, residual, correlation and per-bar envelope error. The worst bars list what was playing in them. ShippedBuildParityTests checks the first 16 bars against per-song ceilings and does nothing when the renders or packs are absent. tools/ShippedParity writes the whole-song report to %LOCALAPPDATA%. The renders are read from a local cache or the share and are never copied into the repo. LegacyMigrationParityTests also stops passing an audio resolver the app never passes. Baseline and how to read it: docs/legacy-import-review.md, \"Measuring against the shipped build\"."
    },
    {
      "title": "Every ezmuze 3 TriggerFX is now a trigger effect you can place",
      "version": "2026.8.209",
      "date": "2026-09-17",
      "area": "Sound engine",
      "type": "New feature",
      "userFacing": true,
      "details": "The non-real-time trigger lane now offers the whole ezmuze 3 TriggerFX roster, each one reproducing the old effect sample for sample when placed on a quarter of a bar: Segment 1-4 and Shift Segment 1-4 (play another part of the bar), Slow, Spin Up (ezmuze 3's On), Wiki, Forward, Double Down 2, Stretch (the grain stretch that keeps pitch), Trance 1/2/4 (the gate that dips to half), Cut, Sample Clipper/Flanger/Chorus, and Tune Up/Down by 1 to 12 semitones or two octaves. Sample effects can now read audio from outside their own region - another quarter of the bar, as the instrument made it or as the effects below left it - which is what Segment and its relatives are. Each was checked against the shipped ezmuze 3 code at tempos whose bars do not divide evenly, alone on every quarter and in the stacks real songs use, including the way the old Stop, Slow, On and Forward changed what later effects in the bar heard. This is the groundwork for importing ezmuze 3 TriggerFX as editable trigger regions."
    },
    {
      "title": "Tape Stop, Double Up and In Place now sound like ezmuze 3's",
      "version": "2026.8.209",
      "date": "2026-09-17",
      "area": "Sound engine",
      "type": "Bug fix",
      "userFacing": true,
      "details": "Double Up went silent for the second half of its region; it now plays the doubled-up audio twice, as ezmuze 3 did. Tape Stop used a made-up curve; it now follows ezmuze 3's, which starts at half speed and slows a little every two samples, so it lands differently at different tempos exactly as the original did. Half Speed, Retrigger and Shuffle now leave the few frames their slices do not cover alone instead of filling them, and a quarter of a bar is placed where ezmuze 3 put it rather than rounded, which moved some regions by a frame. The In Place clipper preset now uses ezmuze 3's amount (0.96875, was 0.85); an install that already has the preset keeps its old value until it is re-added."
    },
    {
      "title": "Tricks are now clips you drag onto an automation lane",
      "version": "2026.8.209",
      "date": "2026-09-17",
      "area": "Sequencer",
      "type": "New feature",
      "userFacing": true,
      "details": "The Trick button used to open a panel of knobs that put one LFO across a whole automation lane. Tricks now work like patterns. Click a lane's name to select it and the pattern explorer turns into a Tricks browser listing every legacy waveform, each previewed at the rate you are about to place it at. Drag one onto the lane and it becomes a clip: move it, resize it, halve or double its rate with the [/2] and [x2] buttons before its name, and set how hard it swings with the two amplitude sliders at its ends - it fades between them across the clip. Click a trick's name for every rate ezmuze 3 offered, the odd ones like 3x and 5x included (the rate beside the explorer's buttons does the same for what you are about to drag), and hold Alt while dragging a trick to slide its waveform along inside the clip, which sets where in its cycle it starts. Tricks can never overlap: a move stops against its neighbour and a resize or drop is cropped to fit. The Trick button is now a toggle per lane: on, you edit the tricks and see the lane as it plays; off, you edit the lane's points with the tricks drawn faintly behind. A trick's waveform now runs from the start of its own clip rather than restarting every beat, which is what lets a clip be moved and slowed down; older songs are converted when opened and sound the same. Imported ezmuze 3 songs bring their tricks in as these clips, joined into long ones wherever the original was one continuous trick. Also fixed: a song holding a trick from the old panel could not be saved."
    },
    {
      "title": "Blocks no longer come up blank when their sample was still loading",
      "version": "2026.8.208",
      "date": "2026-09-17",
      "area": "Sequencer",
      "type": "Bug fix",
      "userFacing": true,
      "details": "Opening a song raced two background jobs against each other: the one decoding its samples and the one drawing the waveform on each block. A block drawn first got silence - there was no audio to draw yet - and that flat line was then cached against the pattern, which does not change when the sample turns up. Nothing ever asked again, so the block stayed blank for as long as the song was open, over a sample sitting perfectly intact in the wave bank. Whole channels of a big imported song came up like that, and which ones varied from one open to the next. A block's waveform is no longer drawn through audio that has not arrived: it waits, and is drawn as soon as its sample lands. Silence is only ever cached when silence is the real answer."
    },
    {
      "title": "No empty Default tab on an imported channel",
      "version": "2026.8.207",
      "date": "2026-09-17",
      "area": "Sequencer",
      "type": "Improvement",
      "userFacing": true,
      "details": "An imported channel's pattern bank kept a \"Default\" tab even after its loops had moved into their own pack tabs, so the first tab you clicked was the empty one and every tab with something in it sat behind it. Default is the automatic folder - what a channel gets when it has none, so that everything it holds is somewhere - and a channel with a tab per sub-pack does not need it. It goes when it is empty and real tabs remain, which is also what a channel added from a pack has always looked like. A channel whose pack is not installed still gets its Default, because it has nowhere else to put anything."
    },
    {
      "title": "An imported song names its loops, and drops the dead blocks",
      "version": "2026.8.206",
      "date": "2026-09-17",
      "area": "Sequencer",
      "type": "Improvement",
      "userFacing": true,
      "details": "Three things about opening an ezmuze 3 song. Blocks are now labelled with the loop they actually play - \"Bacon Deodorant\", \"The Womb of Gaia\" - instead of \"emhe_breaks 3\", which was the best the importer could do before anything knew which packs you have installed. Legacy songs also carry blocks that hold nothing at all: no loop, no trigger FX, not even a moved fader. There were 118 of them across the four test songs, one channel holding 28 and nothing else, and because a block with no loop has no sample to migrate, its whole channel stayed behind as a row of empty outlines that looked exactly like waveforms that had failed to load - and never would, because there was nothing coming. Those blocks are dropped. Anything that says something, even just a filter sweep, is kept and still reaches the channel. And the loops your song already uses now sit in their own pack's tab in the pattern bank rather than being held back in a separate \"Default\" drawer, so a tab is the sub-pack, whole, with the ones in play simply being the ones that already have patterns."
    },
    {
      "title": "An imported song looks like a song, not a transcription of one",
      "version": "2026.8.205",
      "date": "2026-09-16",
      "area": "Sequencer",
      "type": "Improvement",
      "userFacing": true,
      "details": "ezmuze 3 had no clip longer than a bar - across the whole test corpus every single one is exactly one bar - so a song that plays a four-bar loop from the top wrote that down as four separate bars, each reading a quarter further into the sample. Imported straight, that made a wall of hundreds of one-bar blocks: the same music, but unreadable and miserable to edit. Four bars that are just a loop playing now arrive as ONE four-bar clip holding one four-bar pattern, exactly what you would get dropping that loop in today, and any other run of identical bars becomes a single clip spanning them. Bars that carry TriggerFX keep their own clip, because that really is per-bar. Across the corpus this cuts one song from 492 clips to 177 and another from 567 to 304. Patterns are also named after the loop they play rather than the bar they happened to be built at, which was already a half-truth and became a plain lie once one pattern covered twenty bars. None of it changes a note: the golden-corpus parity harness reports 0.0% drift on all four songs."
    },
    {
      "title": "Previewing a loop draws it, and shows how far through it you are",
      "version": "2026.8.204",
      "date": "2026-09-16",
      "area": "UI",
      "type": "Improvement",
      "userFacing": true,
      "details": "Two things about auditioning a sample. A pack loop sitting in a channel's bank had a blank tile until you placed it - it names a loop and has no notes, so there was no rendered audio to draw a picture of - which made choosing between five of them a matter of reading their names. Clicking one to preview now draws it, because previewing is what fetches the audio, and the picture arrives with the sound. Nothing is fetched until you ask: a freshly opened pack template still decodes nothing at all. And every sample preview - a pattern tile, a wave bank slot, a loop in the browser - now shows a playhead crossing the waveform with the part you have not heard yet washed out, so you can see how much is left instead of guessing whether it is still playing."
    },
    {
      "title": "A song started from a pack gets the pack's channels",
      "version": "2026.8.203",
      "date": "2026-09-16",
      "area": "Sequencer",
      "type": "Bug fix",
      "userFacing": true,
      "details": "Starting a new song from a pack laid out channels that could not use that pack. They arrived as bare channels with no instrument at all, so the pattern bank was empty and dropping a pattern on one was refused outright with \"Patterns need a synth/module channel\" - while the very same channel, added by hand a minute later through Add channel > From Pack, came with the pack's loops in tabs, ready to preview and drag. Same pack, same channel type, two different channels. A template is a collection of a pack's channels and nothing else, so it now builds exactly the channel that menu does: one tab per sub-pack, every loop in it, and nothing fetched or decoded until you actually use one. The channels of a type share one set of patterns rather than each carrying a copy, as they already do on an imported song."
    },
    {
      "title": "Pack loops stretch to your song's tempo again",
      "version": "2026.8.202",
      "date": "2026-09-16",
      "area": "Sound engine",
      "type": "Bug fix",
      "userFacing": true,
      "details": "A loop from a pack is four bars, and dropping it into a song is supposed to stretch those four bars onto four bars of YOUR song - changing its pitch on the way, which is what the pitch-shift effect exists to undo. That is the whole premise of the format and it had stopped happening. The engine worked the loop's length out by measuring it, and measuring cannot tell a four-bar loop from a 172 BPM pack apart from a genuine two-and-a-half-bar loop, because in a 108 BPM song they are the same number of seconds. So it believed the loop was already the right length, left it alone, and played it at the pack's tempo inside your song: out of time, never pitched, and stopping early. Across the four golden-corpus songs, 80 of 93 pack loops were being played at the wrong size - one 77 BPM song was fitting its loops to one and three quarter bars, so a loop sat in a four-bar pattern and played for under half of it. A pack loop now states its four bars instead of having them guessed, and imported legacy songs play the way they did in ezmuze 3. Samples you drop in yourself are unchanged: a file from your disk says nothing about its musical size, so it is still measured."
    },
    {
      "title": "A pack loop dragged onto the timeline plays to its end",
      "version": "2026.8.201",
      "date": "2026-09-16",
      "area": "Sequencer",
      "type": "Bug fix",
      "userFacing": true,
      "details": "A loop dragged from a channel's pattern bank stopped partway through instead of playing out, and never changed pitch to fit the song. A pattern's real length is its sample's duration measured in THIS song's bars - a four-bar loop from a 90 BPM pack is over six bars at 140 BPM - and an imported pack pattern has no audio yet to measure, so it was created with the placeholder length of four bars and never asked again. Anything longer was cut off there. Placing one now fetches its audio first and measures it, so it arrives at its true length and fits the song properly."
    },
    {
      "title": "One unrenderable pattern no longer stops every waveform in the song",
      "version": "2026.8.201",
      "date": "2026-09-16",
      "area": "Sound engine",
      "type": "Bug fix",
      "userFacing": true,
      "details": "Two faults, one on top of the other. A pattern whose length is not a whole number of bars - which the fit law produces routinely, a 2.5-bar loop being the ordinary case - ran the waveform renderer off the end of its own buffer: the render advanced in batches clamped to the batch size and to the bar boundary, and those two between them only ever land exactly on the end of a WHOLE bar. The resulting error then killed the render pump outright, and because that pump runs unwatched in the background nothing restarted it and nothing reported it: every waveform in the song silently stopped drawing for the rest of the session, with a progress indicator frozen mid-count as the only sign anything was wrong. The renderer now stops at the pattern's real end, and the pump survives a pattern it cannot render - that one job is dropped, with the reason logged, and the rest of the song carries on."
    },
    {
      "title": "An imported song's channels come with their pack's loops",
      "version": "2026.8.200",
      "date": "2026-09-16",
      "area": "Sequencer",
      "type": "Improvement",
      "userFacing": true,
      "details": "A channel added from a pack arrives with that pack's loops in its bank, in tabs, ready to preview and drag. An imported legacy song's channels only ever held the handful of loops the song already played, so carrying on with an imported song meant leaving the channel and browsing for the pack it plainly came from - while an identical channel made a minute earlier had everything to hand. They now arrive the same way: one tab per sub-pack of the channel's own type, alongside the Default tab holding what the song already uses. Channels of the same type share one set of patterns rather than each carrying a copy, so an eleven-channel song pays for six palettes and not eleven. This happens when a legacy song is IMPORTED, not when a project is opened - reopening a song you have already got should never quietly rewrite it."
    },
    {
      "title": "Adding a channel from a pack no longer loads the whole pack",
      "version": "2026.8.199",
      "date": "2026-09-16",
      "area": "Sequencer",
      "type": "Performance",
      "userFacing": true,
      "details": "Adding one channel from a large pack fetched and decoded every loop of that channel type - 112 loops and 185 MB of audio, for a channel nothing had been played on yet - and put a wave-bank entry for each of them into the song. Now a pattern simply NAMES its pack loop, which costs a couple of ids, and the entry and the audio arrive when you first use it. Previewing a pattern fetches that one loop and plays it without changing the song; placing one adds its wave-bank entry, gives it its note, and fetches the audio, all as a single undoable step - so taking the placement back takes the entry with it. The same import now costs nothing measurable, and the wave bank holds what the song actually uses instead of a copy of the pack's catalogue. A pattern you have not touched shows its name and length rather than a waveform, because drawing one would mean fetching the audio it is there to avoid.",
      "tickets": [
        236
      ]
    },
    {
      "title": "A channel added From Pack can be heard",
      "version": "2026.8.198",
      "date": "2026-09-16",
      "area": "Sequencer",
      "type": "Bug fix",
      "userFacing": true,
      "details": "Adding a channel from a pack filled its bank with that pack's loops and then every one of them was blank and silent - blank tiles in the explorer, a blank clip on the timeline, nothing to hear. A bank entry naming a pack loop is only a reference, and the renderer, finding no audio behind it, plays silence without complaining; the import created the references and never fetched anything. The song loader already guards against exactly this when a song is OPENED, but these arrive in a song that is already open and nothing re-ran it. The import now fetches its own audio and then re-renders the faces that depend on it - in that order, because a waveform drawn before its audio exists is a flat line that then sits in the cache being wrong. A loop that cannot be fetched costs its own tile a picture and says so, rather than taking the rest with it.",
      "tickets": [
        235
      ]
    },
    {
      "title": "Editing a clip acts on the channel it is actually on",
      "version": "2026.8.197",
      "date": "2026-09-16",
      "area": "Sequencer",
      "type": "Bug fix",
      "userFacing": false,
      "details": "Deleting, resizing or opening a clip found its channel by counting rows - it took the row number the on-screen block was built with and used it as a position in the song's channel list. That is right until the channel list moves underneath it, and confidently wrong afterwards: still a valid position, just somebody else's channel. The edit was then aimed there, which either failed outright (\"clip not found on channel\") or, in principle, could have edited the neighbour's clip if it happened to share an id. Four of the five places doing this had no bounds check at all; a fifth, doing the identical lookup, had one - so the gap was already known in one spot and not the others. All five now ask the song which channel holds the clip, which cannot go stale, and fall back to the (checked) row only for the blocks that legitimately have no clip. Internal: nothing changes in normal use, which is the point - this is a crash and a mis-edit that can no longer happen.",
      "tickets": [
        234
      ]
    },
    {
      "title": "Two panels sharing a tabbed window no longer draw on top of each other",
      "version": "2026.8.196",
      "date": "2026-09-16",
      "area": "UI",
      "type": "Bug fix",
      "userFacing": true,
      "details": "Open the Stack and the pattern explorer together and they merge into one tabbed window, which is intended - but both tabs then drew at once, in the same rectangle, one view painted straight through the other. It looked like a mess and behaved worse: the two sets of controls sat exactly on top of each other, so clicks went to whichever was in front no matter which tab was selected, and the panel underneath could not be used at all. Clicking a pattern tab would open the Stack's preset browser. Switching tabs used to detach the one view it believed was showing, which is a guess that goes wrong as soon as a panel rebuilds its own shell or arrives by merging; it now simply makes the rule true - the active tab's content is in the window and no other tab's is.",
      "tickets": [
        233
      ]
    },
    {
      "title": "A channel keeps a bank of patterns, in tabs",
      "version": "2026.8.195",
      "date": "2026-09-16",
      "area": "Sequencer",
      "type": "New feature",
      "userFacing": true,
      "details": "A channel's patterns used to be whatever its clips happened to reference, which meant a pattern could only exist by being on the timeline. Now a channel HOLDS patterns and the timeline PLAYS them, and the two are different things. The pattern explorer grows a row of tabs for it: every channel starts with one called Default holding exactly what it showed before, the + at the end makes another, and you can drag a pattern onto a tab to file it there. Right-click a pattern for Rename, Duplicate, Move to, \"remove all its instances from this channel\" - which clears the row but keeps the pattern - and \"delete this pattern\", which does not, and warns you first if another channel is playing it. Right-click a tab to rename, reorder or delete it; deleting a tab moves its patterns to the one beside it rather than throwing them away. Adding a channel from a pack now fills its bank with that pack's loops, one tab per sub-folder of the pack, ready to drag and with nothing placed on the timeline until you say so. That menu is called From Pack now, because every row in it is one."
    },
    {
      "title": "No more ghost buttons above a tabbed window",
      "version": "2026.8.194",
      "date": "2026-09-16",
      "area": "UI",
      "type": "Bug fix",
      "userFacing": true,
      "details": "Merge two windows into tabs and a stray maximise and close appeared just above the tab strip, floating on the background behind the window; un-tab it and they vanished. They were the window's own title-bar buttons, which a tabbed window hides because each tab carries its own set -- except hiding them meant shrinking them to nothing, and shrinking a button does not shrink the icon inside it. Each icon was simply on screen twice. Underneath was something missing rather than something broken: an element had no way to say \"not visible\", so the only tools available were shrink-to-nothing, which does not hide anything that draws from its own position, or park it a long way off-screen, which draws and takes clicks as normal somewhere nobody looks. Both were in use in different places. There is a real hidden flag now -- nothing drawn, nothing clickable, no space held, the whole subtree with it -- and the places using either workaround say what they mean instead.",
      "tickets": [
        232
      ]
    },
    {
      "title": "Switching theme repaints the Stack instead of leaving it behind",
      "version": "2026.8.193",
      "date": "2026-09-16",
      "area": "UI",
      "type": "Bug fix",
      "userFacing": true,
      "details": "Change the theme with the Stack open and the Stack stayed on the old one -- header strip, device cards, knob labels, buttons -- while the sequencer, the menus and every dialog around it changed immediately. Closing and reopening it was the only cure. It read its colours from the theme correctly but only once, when it was built, and nothing rebuilds it afterwards: the sequencer stopped being torn down by a preference change some time ago, precisely so a change like this would stop taking the Stack, your explorer tab and any drag in progress with it. Every colour in the panel is now read live, the way the rest of the app already reads them, so it follows a switch on the next frame with nothing rebuilt and nothing closed. Three GustUI pieces needed a live path first and now have one for everyone: gradient fills, border colours, and the toggle switch -- whose defaults track the theme from here on rather than freezing it at construction.",
      "tickets": [
        231
      ]
    },
    {
      "title": "The Stack's header strip follows the light theme",
      "version": "2026.8.192",
      "date": "2026-09-15",
      "area": "UI",
      "type": "Bug fix",
      "userFacing": true,
      "details": "In the light theme the strip carrying the channel swatch, the Wires switch and the Key source stayed dark-mode near-black, while the text on it correctly went near-black too - so \"Wires\" was effectively invisible and \"Key: none\" was barely legible, with a light title bar directly above it and white device cards directly below. Its fill was two hard-coded colours that no theme lookup could reach, and so was the face of the Key button behind its label. Both take theme tokens now. The dark theme is within a few units of where it was, because the tokens are what those literals were hand-mixed to imitate in the first place. The neutral control colour this needed was in GustUI's palettes all along but only reachable as a ready-made gradient; it is exposed as a colour now, so the next small button that wants one does not have to invent it.",
      "tickets": [
        228
      ]
    },
    {
      "title": "Dropping a pack loop below the last channel no longer crashes the app",
      "version": "2026.8.192",
      "date": "2026-09-15",
      "area": "Sequencer",
      "type": "Bug fix",
      "userFacing": true,
      "details": "Dragging a loop out of a loop pack and dropping it in the empty strip under the last channel - the zone that offers to make a new channel for what you dropped - took the whole app down. That zone deferred its work to a path that knew about patterns, wave-bank entries and external results, and treated everything else as a legacy loop; a pack loop is none of those and carries no legacy loop at all, so it read a null and died. It was wrong before it crashed, too: it added a blank channel first, for something that was never going to land on one. A pack loop is placed by binding - it finds its pack's own group channel, or makes it - so it now leaves that path before any channel is added, exactly as it already did when dropped on a row. The drag badge stopped over-promising while it was there: it read \"- new channel\" over a pack loop, which was never where it was going.",
      "tickets": [
        230
      ]
    },
    {
      "title": "A loop dragged out of a pack plays",
      "version": "2026.8.192",
      "date": "2026-09-15",
      "area": "Core",
      "type": "Bug fix",
      "userFacing": true,
      "details": "A loop from a pack built on store instruments landed in the song silent, with a flat line where its waveform should be. Copying an instrument out of a pack is a save and a load, and a device that references a published module saves no graph - so the channel arrived as an empty shell. It is opened now, the way opening a song opens one, and the opening happens BEFORE the loop is added rather than after: adding it is what starts the waveform rendering, and a picture drawn through an empty shell is a flat line that then sits in the cache being wrong. Taking a pack update does the same for the instruments it swaps in."
    },
    {
      "title": "Loop packs built on store instruments are not silent any more",
      "version": "2026.8.192",
      "date": "2026-09-15",
      "area": "Core",
      "type": "Bug fix",
      "userFacing": true,
      "details": "A pack whose instruments are store modules opened as a set of empty shells: a device that references a published module saves no graph, because the graph belongs to the store and is rebuilt when a song is opened - and reading a pack's zip was a parse, not an open. Everything downstream rendered four bars of nothing: the waveforms on the loop tiles, the loops dragged into a song, and the preview in ezmuze central. The legacy twin builder had already been fixed for exactly this; it was one bug wearing three more hats. Opening an installed pack now rebuilds its instruments the way opening a song does."
    },
    {
      "title": "A pack group channel opens with the pack's loops in it",
      "version": "2026.8.192",
      "date": "2026-09-15",
      "area": "UI",
      "type": "Improvement",
      "userFacing": true,
      "details": "Add a channel from a pack's template and its Patterns tab now shows that pack's loops for that group, ready to drag onto the timeline - with the waveform rendered here from the loop's own instrument, since a pack ships no audio. It used to say \"has no patterns yet - paint a clip on its row first\", which was advice you could not follow: a group folder never carries clips, its children do, and it is the one channel whose contents were already known."
    },
    {
      "title": "A pack appears once in From Template, not twice",
      "version": "2026.8.192",
      "date": "2026-09-15",
      "area": "UI",
      "type": "Bug fix",
      "userFacing": true,
      "details": "A pack that ships in both forms was listed twice under + Add channel > From Template - the same name, the same cover, and nothing to say which row was the ezmuze 3 one. There is one row now, the studio pack's, with its groups under it and a \"Legacy\" entry beside them holding the ezmuze 3 channel types. Same fix as the browser's: the two are one product, so finding it should not depend on knowing which program it was authored in."
    },
    {
      "title": "A pack you own offers both forms before you have downloaded either",
      "version": "2026.8.192",
      "date": "2026-09-15",
      "area": "Services",
      "type": "Bug fix",
      "userFacing": true,
      "details": "The Studio half of a loop pack only appeared once the .ezlp was already on this machine - which it never was, because browsing into it is what fetches it, so a pack with both forms showed only its legacy side. ezmuze central is asked instead, and entering Studio downloads the pack then and there: it is kilobytes of manifest and pack song, against up to 144 MB for the ezmuze 3 half. Neither side is fetched to draw the choice any more either, so picking Studio no longer pulls a legacy pack you did not ask for."
    },
    {
      "title": "One place for loop packs",
      "version": "2026.8.192",
      "date": "2026-09-15",
      "area": "UI",
      "type": "Improvement",
      "userFacing": true,
      "details": "The pattern explorer's \"Loop packs\" tab is gone and ezmuze central covers both kinds of pack instead. Browse ezmuze central in the Samples tab and the packs installed on this machine are in there beside the ones on the shelf; where a pack exists in both forms there is one extra step, \"Studio\" or \"Legacy\", between the pack and its loops. A studio loop still drags onto the timeline as a pattern on the pack's own instrument, and its preview is rendered here, since a pack ships no audio. File lost Import loop pack and Install module package with it: importing a pack from a file is now a tile on the store's own shelf, next to the packs you could buy instead, and a sealed module package still installs by being dropped on the app."
    },
    {
      "title": "Store tiles say the price under the artwork",
      "version": "2026.8.192",
      "date": "2026-09-15",
      "area": "UI",
      "type": "Improvement",
      "userFacing": true,
      "details": "A pack's name has moved off its cover and into a strip underneath, set a size smaller, with the price - or Owned, or Installed - right-justified beside it. A store tile carries two facts, and stacking both of them on somebody's artwork in scrims made a shelf of covers read as a shelf of labels. Right-justified because a column of prices aligned on the right can be scanned down; after a name of any length it cannot. The welcome screen's cards are unchanged: they have nothing to say but the name, so it stays on the art."
    },
    {
      "title": "Every menu takes the arrow keys",
      "version": "2026.8.192",
      "date": "2026-09-15",
      "area": "UI",
      "type": "Improvement",
      "userFacing": true,
      "details": "Up and down move through a menu, right opens a submenu and left comes back out of it, Enter picks the row you are on and Escape closes the level you are in. The row you are on lights up exactly as it does under the pointer, because it is the same highlight rather than a second one drawn to look like it. In the Add channel menu, where the first row is a search box, left and right stay with the caret so you can still fix a typo - Enter opens a submenu there. The Add channel menu also lost its separators: four entries did not need dividing into groups, and every divider was a row the keyboard had to skip."
    },
    {
      "title": "Menus are a little smaller",
      "version": "2026.8.192",
      "date": "2026-09-15",
      "area": "UI",
      "type": "Improvement",
      "userFacing": true,
      "details": "The menu bar and every dropdown are set about a tenth smaller - the type, the icons beside it and the row height together. Menus are chrome: you read one at a glance and then want it out of the way of your own work, and at the old size a dropdown was the loudest thing on the screen. The widths are unchanged on purpose, so the same column now holds about a tenth more characters - which the Add channel search needs, since its rows say where each result came from."
    },
    {
      "title": "Add channel is a search box now",
      "version": "2026.8.192",
      "date": "2026-09-15",
      "area": "Sequencer",
      "type": "Improvement",
      "userFacing": true,
      "details": "The + at the bottom of the channel list opens with a focused search field. Type and the menu becomes results from everywhere in it at once - every module you own, every pack group, every template's channel types - each labelled with where it came from, so \"bass\" finds the Bass pack group, three bass modules and every pack template that has a bass channel in one list. Synth Channel, Wave Channel and Module Channel have gone: they were three shortcuts to three devices already in the list under different names, which is how somebody ends up choosing between \"Wave Channel\" and \"Wave bank\" without knowing they are the same thing. What they did that a plain device pick did not - arriving with an empty bar to write in - every generator does now. The box is drawn as a menu row rather than as a dialog field, and the menu keeps the size it opened at, so typing filters the list without the whole thing jumping under the pointer."
    },
    {
      "title": "The File menu is about files again",
      "version": "2026.8.192",
      "date": "2026-09-15",
      "area": "UI",
      "type": "Improvement",
      "userFacing": true,
      "details": "Seventeen items, most of which were not about the file you have open. Record a sample is in the wave bank, where the thing it makes lives. Import module is in the Add channel menu, with the list it adds to. Export module is in the module editor, beside the one that exports the module you are looking at. Import SoundFont has gone entirely: a content pack is how a font gets into the app, dropping an .sf2 on the sequencer still works for a one-off, and a third route to the same thing was the one nobody could find. The three content-pack items are one drawer."
    },
    {
      "title": "Build a content pack without leaving the app",
      "version": "2026.8.192",
      "date": "2026-09-15",
      "area": "Services",
      "type": "New feature",
      "userFacing": true,
      "details": "File > Content packs > Manage lists what is installed, lets you remove one, and builds a new one: name it, add SoundFonts, SFZ or wav files, or drop this song's whole wave bank in at once, and save it as a .zip - installed straight away if you want to try it. Until now a pack could only be made by a python script in the repo, which is fine for the two ezmuze ships and no use at all to somebody with a folder of their own samples. The item ids are minted once and kept, so building the same pack again with one more sound in it leaves every song that used the others still finding them."
    },
    {
      "title": "The ezmuze 3 pack contains the loops, not four bars of hiss",
      "version": "2026.8.192",
      "date": "2026-09-15",
      "area": "Sound engine",
      "type": "Bug fix",
      "userFacing": true,
      "details": "Two ways a twin could come out silent, and both of them did. A store-module instrument keeps its graph in the store, not in the song file - so the copy the twin renderer takes of your pack came back as empty shells and played nothing; it now rebuilds them from the installed package the way opening a song does. And mute and solo were being honoured: the first real pack was saved with one channel soloed, so every loop of the other group rendered to four bars of nothing. How you were listening is not part of the pack, and the render ignores it now. Neither failure said anything at the time, because a full-length run of zeroes is the right SHAPE for a loop - so a loop that renders to silence is a refusal now, with a line saying where to look, rather than something that ships."
    },
    {
      "title": "A dual pack installs in ezmuze 3 instead of crashing it",
      "version": "2026.8.192",
      "date": "2026-09-14",
      "area": "Services",
      "type": "Bug fix",
      "userFacing": true,
      "details": "The first loop pack published from the studio killed every ezmuze 3 that tried to install it. Three things were wrong and all three are ours. The pack file was written only to the folder the legacy API reads names out of, while a 3.0 install downloads the bytes from the CDN - two directories that had always been kept in step by hand, because until now no code had ever written a pack. It is written to both now. The paths inside the pack were stored without the leading slash every real pack has, which that client compares by raw string prefix, so the pack would have installed with its folders present and every one of them empty. And the channel folders carried the pack's whole title where every other pack on that shelf carries a short code, so a pack now gets one the same way they did: its initials. The already-published test pack has been rebuilt and replaced in place - same ids, because those come from the product id and the ordinals, not the names."
    },
    {
      "title": "The store shows validated loop packs by default",
      "version": "2026.8.192",
      "date": "2026-09-14",
      "area": "Services",
      "type": "New feature",
      "userFacing": true,
      "details": "Anybody can publish a loop pack from inside the studio, so the Loop packs shelf has a filter - \"Only show validated packs\" - that starts on. A pack arrives unvalidated and somebody at ezmuze stamps it; until then the shelf says how many it is holding back, and one press shows them. It means exactly what it says: your own packs and packs you own are hidden too, because a box that is ticked while an unvalidated pack sits on the shelf is making a claim that is false. The ezmuze 3 back catalogue is not filtered at all - validation is about what people upload. A pack's page says plainly when nobody has looked at it yet, whether you found it through the filter or with it off. Unvalidated is not an accusation and not a takedown: the pack is published, downloadable and perfectly usable by anyone with its link."
    },
    {
      "title": "Loop packs from the store install into the studio",
      "version": "2026.8.192",
      "date": "2026-09-14",
      "area": "Services",
      "type": "New feature",
      "userFacing": true,
      "details": "A pack published from ezmuze studio appears on the store's Loop packs shelf beside the ezmuze 3 ones - same shelf, because it is the same kind of product and which program it was written in is the author's business, not the shopper's. Its tile says how many loops are in it, at what tempo and in which groups, and whether it is free; its page says whether it plays in ezmuze 3 as well, and has an Install button that fetches the pack and puts its loops in the pattern explorer. Free means free to anyone signed in; anything priced is checked against the same purchase history every other product uses. Remove is there too, which is new for loop packs in general: until now a pack could be installed and never taken off - and removing one leaves every song that used it playing, because what a song copied out of a pack is the song's."
    },
    {
      "title": "A loop pack that moves on asks once, and never rewrites your track",
      "version": "2026.8.192",
      "date": "2026-09-14",
      "area": "Sequencer",
      "type": "New feature",
      "userFacing": true,
      "details": "Open a song whose loop pack has a newer version and one dialog says what changed - which instrument, which pattern, which loops are new, which the author withdrew - and offers to take it. Anything you edited is listed as kept, by name, before you press anything: a fader you moved is never moved back, and your clips, where they sit and the fold of every row stay yours whichever button you press. Not now records the decision, so that version never asks again and the next one asks once. Deciding what is yours needs the pack as your song had it, so installing an update now keeps the version it replaced (the last three of them); without one the prompt says so and takes nothing rather than guessing. The whole thing is one undoable edit per pack - Ctrl+Z puts the song back, version stamp included. A pack's own source song is never offered its own pack, and a pack now takes the next version number each time it is exported or published, which is what a buyer's song compares itself against."
    },
    {
      "title": "Loop packs publish straight to ezmuze central",
      "version": "2026.8.192",
      "date": "2026-09-14",
      "area": "Services",
      "type": "New feature",
      "userFacing": true,
      "details": "The publish wizard has a price field and a Publish to central button beside Export. It sends the same .ezlp the export writes, and for a dual pack the same rendered ezmuze 3 pack beside it - rendered here, by your studio, with your modules, rather than by a server that has none of them. The store works out which clients a pack serves from what actually arrived, so a listing cannot promise an ezmuze 3 download that is not there, and the twin lands in the same bundles folder the 3.0 client has always downloaded from. Publishing is what freezes a pack's loop ordinals, so the wizard writes them back before the upload starts: an upload that succeeded while the song still thought nothing was frozen would mint them again next time and repoint every loop in every song that bought the pack."
    },
    {
      "title": "A loop pack is authored by whoever is signed in",
      "version": "2026.8.192",
      "date": "2026-09-14",
      "area": "UI",
      "type": "Improvement",
      "userFacing": true,
      "details": "The publish wizard asked you to type an author name, which meant a pack could go out under a name that is not yours - and the author is the one thing about a pack that ezmuze central already knows for certain, so typing it again could only introduce a disagreement. It shows your central account name instead. Signed out it says so, which is also where you find out that a pack will still build and still export to a file but cannot be uploaded."
    },
    {
      "title": "The system mouse pointer comes back when the window loses focus",
      "version": "2026.8.192",
      "date": "2026-09-14",
      "area": "UI",
      "type": "Bug fix",
      "userFacing": true,
      "details": "The app draws its own pointer while it has focus and hands the job back to the system when it does not. Which of the two it is was decided from the framework's own focus flag, which starts true and only moves when the framework itself sees a focus event - so whenever the window lost focus in a way it did not see, the app went on hiding the system pointer and drawing its own, frozen at the last mouse position it trusted. It now asks the same question the app already asks to decide whether keyboard and mouse input belong to it, which is the one that actually checks with the operating system. The pointer is ours exactly when the input is. The remote-control /cursor endpoint reports the system pointer state too, since a hardware cursor is not in a screenshot and there was otherwise no way to check it except by looking at the screen."
    },
    {
      "title": "+ Add lane is about automation lanes again",
      "version": "2026.8.192",
      "date": "2026-09-14",
      "area": "Sequencer",
      "type": "Improvement",
      "userFacing": true,
      "details": "The three trigger FX placements used to sit in a section at the bottom of the + Add lane menu, because until today there was nowhere else to put them. Trigger FX have their own button and their own + Add TFX row now, so that section was a second route to the same three items from a menu about something else. It is gone. A side effect worth having: the menu could never be empty while those items were in it, so the \"no wireable params on this channel's devices yet\" message could not actually appear - a channel with nothing to automate opened a menu offering only trigger FX. It says what it means now."
    },
    {
      "title": "+ Add TFX, under the trigger FX lanes",
      "version": "2026.8.192",
      "date": "2026-09-14",
      "area": "Sequencer",
      "type": "Improvement",
      "userFacing": true,
      "details": "Adding a second trigger FX lane briefly needed a right-click on the channel's TFX button - a gesture nobody is told about, which is not a way in. There is a + Add TFX row under the lanes instead, exactly where + Add lane sits under the automation ones and doing the same job. It goes away once a channel has all three placements, because there is nothing left to add and a row that can only tell you no is worse than no row. The button keeps its two jobs: show and hide the lanes, or offer the first one on a channel that has none."
    },
    {
      "title": "Trigger FX lanes no longer leave a hole behind them",
      "version": "2026.8.192",
      "date": "2026-09-14",
      "area": "Sequencer",
      "type": "Bug fix",
      "userFacing": true,
      "details": "Adding a trigger FX lane, hiding it, then opening the automation lanes left an empty band under the channel where the hidden lane used to be, and pushed the + Add lane row down on top of the + Add channel row below it. The pass that repositions lane rows every frame - which gets the last word over the one that builds them - was still written for a single toggle: it stepped over every trigger FX lane whether or not the lane was being shown, and it only ran at all when the automation lanes were open. It follows the same two switches as the build now. And once a channel had one trigger FX lane, there was no way to add a second from its button, since a left click had become show/hide: right-clicking the button opens the placements, whichever ones you already have, and the tooltip says so."
    },
    {
      "title": "Trigger FX lanes get their own switch",
      "version": "2026.8.192",
      "date": "2026-09-14",
      "area": "Sequencer",
      "type": "Improvement",
      "userFacing": true,
      "details": "One button used to open both a channel's trigger FX lanes and its automation lanes, so wanting to draw a filter curve got you a stack of trigger-FX rows above it whether or not you cared - and a channel with several of them pushed the automation off the screen. There are two buttons now, side by side on the channel header, in the order the lanes appear below: trigger FX on the left, automation on the right. Each opens only its own, so you can have trigger FX showing with the automation closed. The trigger FX button is on EVERY channel, because it is how you find trigger FX: on a channel that has none, pressing it offers to add one rather than doing nothing. The three placements were previously reachable only from a section at the bottom of the + Add lane popup, which is itself behind the automation toggle - so you had to already know they existed to find them."
    },
    {
      "title": "Double-click a channel header to open its instrument",
      "version": "2026.8.192",
      "date": "2026-09-14",
      "area": "Sequencer",
      "type": "New feature",
      "userFacing": true,
      "details": "A single click selects the channel and shows its Stack - the list of what is on it. Double-clicking now opens the thing itself: the panel of the first device on its chain. First rather than selected, because a chain reads top-to-bottom as signal flow, so the thing at the top is what makes the sound and everything after it is treatment. A channel with nothing on its chain does nothing rather than opening an empty window."
    },
    {
      "title": "The fold arrow moved inside the channel header",
      "version": "2026.8.192",
      "date": "2026-09-14",
      "area": "Sequencer",
      "type": "Improvement",
      "userFacing": true,
      "details": "It used to sit in a column of its own outside the header, which cost every channel fourteen pixels of width whether or not it had anything to fold - on a strip this narrow that is most of a word. It is at the top left of the header now, on the name's line, pushing the name right only on the channels that actually have children. Everything else in the header gains the room."
    },
    {
      "title": "A new channel lands under the master, not beside it",
      "version": "2026.8.192",
      "date": "2026-09-14",
      "area": "Sequencer",
      "type": "Bug fix",
      "userFacing": true,
      "details": "Everything goes into the master, so everything should hang off it - but a newly added channel arrived with no parent at all, which put it at the same level as the master and drew it at the master's own indent. It only ever looked right by accident: opening a saved song repairs the tree, so saving and reopening quietly adopted it and the wrong state was visible only in between. Every way of adding a channel goes through one place, and that place now does the adopting; a channel that already knows where it belongs, like an instrument arriving under a pack group, keeps its own parent."
    },
    {
      "title": "A clip dragged past the end of the song stays visible",
      "version": "2026.8.192",
      "date": "2026-09-14",
      "area": "Sequencer",
      "type": "Bug fix",
      "userFacing": true,
      "details": "Dragging a clip to the right past the last bar of the song made it vanish under the pointer - it came back if you dragged left again, and landed correctly if you let go, but you were dropping it blind. The timeline worked out its last visible bar as the smaller of the edge of the window and the end of the song, on the reasoning that nothing exists past the end of the song. True of the song, false of the screen: a clip being dragged is drawn where the pointer is long before the drop moves anything, so it fell outside the range that gets drawn. The last visible bar is now just that - the edge of the window - which is also what the grid lines had been quietly correcting for on their own."
    },
    {
      "title": "Right-click a clip",
      "version": "2026.8.192",
      "date": "2026-09-14",
      "area": "Sequencer",
      "type": "New feature",
      "userFacing": true,
      "details": "Cut, copy, paste, duplicate and delete, on the clip under the pointer, plus two that only make sense there. MAKE UNIQUE gives this clip a copy of its pattern under a new name, so you can change bar four without changing the other three that play the same thing - it says how many clips share the pattern, and greys out when nobody else does. RENAME renames the pattern, and says how many clips that will be, because a clip has no name of its own and pretending otherwise would be a lie about what it is. Cut and copy go through the same clipboard the marquee uses, so a clip copied here pastes into a selection and the other way round. A clip you right-click wins over a selection that is only that clip - otherwise copying a clip changed which menu its next right-click gave you - while a marquee you built across several clips still gets the area menu."
    },
    {
      "title": "The song ends where the music does, and you can write past it",
      "version": "2026.8.192",
      "date": "2026-09-14",
      "area": "Sequencer",
      "type": "Improvement",
      "userFacing": true,
      "details": "Two halves of one problem. A song is now exactly its arrangement - the end of the furthest clip - rather than that or a length field no dialog has ever shown you, which a template quietly set to sixteen bars. So a four-bar sketch is four bars: it bounces as four, and the playhead stops where the music does. And because playing only as far as you have written makes it impossible to write any further, the transport now runs to the editor's own paper instead, which always extends past the right of the window. Seek out into the empty bars and the playhead goes there and stays; loop a selection out there and it loops. Bouncing keeps the last note's decay - it rolls on past the end for the tail and then trims back whatever of that was silence, so a song that ends dry gains nothing and one that ends ringing is not clipped."
    },
    {
      "title": "The selected channel looks selected",
      "version": "2026.8.192",
      "date": "2026-09-14",
      "area": "Sequencer",
      "type": "Bug fix",
      "userFacing": true,
      "details": "The highlight on the selected channel's header was decided once, when the row was built, so it never moved off whichever channel happened to be selected at the time - and a comment elsewhere claimed the opposite, which is how it survived. It follows the selection now. Its lane in the timeline is a shade lighter too: the header is a few hundred pixels of a window that is mostly timeline, so when you are working out at bar forty the clip you are dragging and the header that would tell you which channel it is on are not in the same glance."
    },
    {
      "title": "A loop pack can carry a demo song",
      "version": "2026.8.192",
      "date": "2026-09-13",
      "area": "UI",
      "type": "New feature",
      "userFacing": true,
      "details": "A pack ships no arrangement, but you can attach one deliberately. Write it the way a buyer would - start a song from your own pack's template and use it - then Attach demo in the publish wizard. It is checked as you pick it, not at publish: every channel that plays anything has to belong to the pack, because a demo that quietly uses a synth the buyer will not have is a demo that lies, and being told at the moment of picking is what lets you fix it. It rides in the .ezlp beside the pack song, keeping its arrangement where the pack song has its stripped. On the other side it appears in the template list as a second entry - the pack, and the pack's demo - drawn as a song rather than a template. Opening it gives you a new untitled song, so there is nothing to save over and saving it is your own copy. A demo is optional and a broken one costs the pack its preview and nothing else: the pack still publishes, and says why it has no demo."
    },
    {
      "title": "A pack channel playing two things at once now says so",
      "version": "2026.8.192",
      "date": "2026-09-13",
      "area": "Sequencer",
      "type": "New feature",
      "userFacing": true,
      "details": "A pack group is one lane - one clip sounding at a time, because that is what the channel is and what an ezmuze 3 channel can hold. Placing a new clip into an occupied stretch was already refused, but dragging a channel you had already written into a group could bring an arrangement that overlaps, and refusing that drop would be the app arguing with a finished track. So the drop still succeeds and the merged row now hatches the stretch where two clips sound at once - diagonal amber stripes, clips still readable underneath - and the publish wizard lists it with the fix: move one of them to a second group of the same type, which is why the templates lay out two of each. An error for a pack that builds an ezmuze 3 twin, since a legacy channel genuinely cannot hold two; a warning otherwise. One line per group however many overlaps there are. Turning hoisting off on the folder removes the rule entirely, as it always did. Also fixes a crash: opening a song with a collapsed pack group could fail outright with \"the file couldn't be read as a song\" - a group ghost with no rendered waveform yet was read as an ordinary clip."
    },
    {
      "title": "A wizard for publishing a loop pack",
      "version": "2026.8.192",
      "date": "2026-09-13",
      "area": "UI",
      "type": "New feature",
      "userFacing": true,
      "details": "File > Loop pack opens the publish wizard: the name, the author, the description, whether an ezmuze 3 pack is built alongside, whether buyers can take your samples, and what they may do with the instruments your loops bring with them. Beside it, every pattern that would be a loop, grouped and in frozen ordinal order, each with a switch - so the fills and the dead ends come out of the pack without being deleted from the song. Ticking one changes the summary and the checks under it as you do it, and the checks are the same ones the build runs: a pattern with no name says so, by name and bar, and dims Export until it is fixed or ticked out. Settings are kept as you change them rather than on a press, because they belong to the song and are saved with it. Export is a button in the wizard rather than a menu item beside it, since pressing export without having seen what is in the pack is how a pack ships with somebody's fills in it. Three new remote-control actions come with it - loop_pack_wizard, pack_loops and pack_exclude - and export_loop_pack now writes the ezmuze 3 pack too."
    },
    {
      "title": "A pack knows the legacy product id its loops are derived from",
      "version": "2026.8.192",
      "date": "2026-09-13",
      "area": "Core",
      "type": "Bug fix",
      "userFacing": false,
      "details": "LoopPackBuild never filled in the manifest's legacy product id, so every twin build refused with \"this pack has no legacy product id\" - and neither side's tests noticed, because neither side owned that field. PackSource carries it now, and the pack's own id stands in until central issues a real one, which is safe because GuidBuilder only ever replaces the tail of what it is given and a pack id does not change between builds. It must never change once a pack has shipped: every loop id is derived POSITIONALLY from it, so a new product id silently repoints every loop in every ezmuze 3 song that used the pack. Found by writing the end-to-end test - song to installable pack, read back with the real ezmuze 3 parser - which is now in the suite. Also caught up four tripwire tests still asserting song format v29 after the loop-pack work took it to v32."
    },
    {
      "title": "Exporting a loop pack now writes its ezmuze 3 pack too",
      "version": "2026.8.192",
      "date": "2026-09-13",
      "area": "Sound engine",
      "type": "New feature",
      "userFacing": true,
      "details": "File > Export loop pack now produces both halves: the .ezlp for the studio, and beside it the .toc and .csm an ezmuze 3 client installs. Each loop is rendered through the whole song rather than through the instrument, so the channel strip, any bus and the master are all in what ships. Four bars, with four more rendered past the end and folded back over the start - a reverb or a release then loops seamlessly instead of gating on the bar line, which is exactly what the client does to the file anyway. One gain for the whole pack at -0.3 dBFS, so the loudest loop sets the level and every other loop keeps its place under it; normalising each loop on its own would flatten the balance you mixed into mush. Then 32-sample edge fades to kill the seam click, and TPDF dither on the way to 16 bits, seeded so a rebuilt pack hashes the same. The render is minutes of CPU for a large pack and runs off the UI thread, so the studio stays usable while it works; the .ezlp is saved and reported first and the ezmuze 3 pack arrives when it is ready. A loop that will not render, or that is not four bars, is reported and skipped rather than sinking the whole pack."
    },
    {
      "title": "A pack's legacy twin, and the hybrid encoder it needs",
      "version": "2026.8.192",
      "date": "2026-09-13",
      "area": "Sound engine",
      "type": "New feature",
      "userFacing": false,
      "details": "A user pack's ezmuze 3 twin, assembled: manifest in, .toc plus its .csm out, laid out so LegacyPackLibrary derives exactly the ids the manifest promises. The container was already there for test fixtures; the new part is LegacyPackNaming, because a legacy pack stores no ids at all and the reader recovers them by splitting file names on characters that also turn up in ordinary words - a hyphen anywhere in a loop file name throws its ordinal away, and int.TryParse failing writes ZERO, so every loop in the folder would derive the same id rather than erroring. Ten round-trip tests through the real parser cover that, a tombstoned loop leaving the ones after it alone, and a pack of names made entirely of the characters that break it. And the encoder learned the mode a twin actually needs: the shipped packs are all HYBRID | HYBRID_BITRATE at stream 0x405, around 200 kbps, so lossless twins would have been three times the size for a difference nobody hears on a four-bar loop. Hybrid narrows each residual's band by binary search until it is inside a level-dependent error limit and stores the midpoint, feeding the quantised value back into the predictor - which is why decorrelation and word coding now run in one loop and the block CRC is taken over the reconstructed samples. Loop-shaped material lands at 16% of PCM (221 kbps) for a busy percussive loop against 68% lossless, at 36 dB SNR. The entropy coder was rewritten to mirror the reference's send_word/flush_word exactly, pending bits and all, which replaced the lookahead the lossless-only version used."
    },
    {
      "title": "The WavPack encoder learns to predict, and gets small",
      "version": "2026.8.192",
      "date": "2026-09-13",
      "area": "Sound engine",
      "type": "Performance",
      "userFacing": false,
      "details": "Yesterday's encoder was the entropy layer alone and landed at 84-90% of raw PCM. This adds the half where the saving actually lives: the decorrelation cascade, run forward against the same filter our decoder runs backward. The reference encoder's default term string {18, 18, 2, 17, 3} at delta 2 — two slope extrapolators, then lags of 2 and 3 to catch periodicity a one-sample predictor walks past — plus a mid/side transform taken per block, but only when the difference channel measures cheaper than the right channel, so hard-panned material is left alone. Loop-shaped material now encodes at 68% of PCM for a busy percussive loop (was 90%), 29% for a tonal one (was 84%) and 14% for a half-silent one (was 42%). Still lossless and still verified the only way an encoder can be, by decoding what it wrote with our own decoder and comparing sample for sample; four new shapes cover the new paths — hard-panned and anti-phase stereo either side of the mid/side decision, and a linear ramp, which is the one signal the extrapolators predict exactly. One wrinkle worth recording: our decoder port keeps a decorrelation weight in a short where the C original uses an int, so a block whose weights would leave that range is re-encoded flat rather than shipped as something that decodes to different numbers."
    },
    {
      "title": "A WavPack encoder, written against our own decoder",
      "version": "2026.8.192",
      "date": "2026-09-13",
      "area": "Sound engine",
      "type": "Internal",
      "userFacing": false,
      "details": "The repo has carried a WavPack decoder for years and no encoder, which is the one thing standing between a loop pack and its ezmuze 3 twin. This is the encoder, written against that decoder rather than against the format spec — every median update, every code and the block header are mirrored from WordsUtils.get_words, read_code and read_next_header, because an ezmuze 3 client's decoder is a sibling port of the same Java original, so what our decoder accepts is a better specification than what the format allows. Tested the only way an encoder honestly can be: encode, decode with the real decoder, compare sample for sample — silence, sine, noise, full-scale extremes, the transition in and out of a zero run, mono, and across block boundaries. Lossless and bit-exact. NOT YET COMPETITIVE ON SIZE: decorrelation is not implemented, so this is the entropy layer alone and lands at 84-90% of raw PCM on busy material (42% on half-silent). Correct first, small second; decorrelation is a pure size optimisation that changes nothing about what a decoder does with these files."
    },
    {
      "title": "Start a song from a loop pack, the way you can from a sample pack",
      "version": "2026.8.192",
      "date": "2026-09-13",
      "area": "UI",
      "type": "New feature",
      "userFacing": true,
      "details": "Loop packs now appear in the welcome screen's Start from a pack list beside the sample packs, and picking one lays out two channels per group at the pack's own tempo — locked and folded, ready for its loops. Two of each rather than one for the same reason the legacy templates do it, and more so: a pack channel is one lane, so a second one is how you layer two bass parts. + Add channel > From Template offers the same groups one at a time when you only want another Bass. The channel a template lays out and the channel a dropped loop creates come from the same place, so they are the identical row rather than two things that look alike."
    },
    {
      "title": "Loop pack tiles draw the loop, rendered from its own instrument",
      "version": "2026.8.192",
      "date": "2026-09-13",
      "area": "UI",
      "type": "Improvement",
      "userFacing": true,
      "details": "A loop pack ships no audio, so there is nothing to decode for a preview — the waveform on each tile in the Loop packs tab is now RENDERED, here, by running that loop's pattern through that loop's own instrument. What you see is therefore the instrument as it is on your machine, including your version of a store module and any edit you have made, rather than a picture of what it sounded like on the author's. Two at a time and only when you look at them, so opening a pack of forty loops fills the row in rather than stopping to render all of it; a tile shows its name until its picture arrives, and a loop whose instrument will not build stays a tile with no picture instead of taking anything down. None of it touches the song you are working on — not its render queue, not its cache."
    },
    {
      "title": "Make a loop pack out of a song you already wrote",
      "version": "2026.8.192",
      "date": "2026-09-13",
      "area": "Sequencer",
      "type": "New feature",
      "userFacing": true,
      "details": "There is no pack editor, and there was never going to be one. Write a track the way you already do; add the standard pack groups to it (+ Add channel > Pack group > Bass, Lead, Breaks...); drag your own channels onto them, which is the ordinary channel drag; then File > Export loop pack... Your folders stay yours — the groups are ADDED alongside them, because a finished track is organised the way you mix and a pack is organised the way buyers browse, and nobody should have to re-organise a track before they can sell loops from it. Every named pattern on a channel inside a group is a loop; the fills you do not want are ticked out. The export refuses rather than shipping something broken, and says which channel and which bar: an unnamed loop, two loops with one name, a group of a type that does not exist. If the pack builds an ezmuze 3 twin it also checks what ezmuze 3 cannot express — a loop must be exactly 4 bars, the song must be 4/4, and names cannot carry the characters the old pack reader splits on. Once exported, a loop's position in its pack is frozen forever, so a later version can add loops without moving the ones somebody's finished track already points at."
    },
    {
      "title": "Drag a loop out of a loop pack onto the timeline",
      "version": "2026.8.192",
      "date": "2026-09-13",
      "area": "UI",
      "type": "New feature",
      "userFacing": true,
      "details": "The pattern explorer gains a Loop packs tab listing every loop in every pack you have installed, with the group, the pack and the length on each tile. Drag one onto the timeline and it goes where it belongs rather than where you dropped it: the pack's own channel is found by its binding, or made — locked and folded — along with the one instrument that loop plays on, and the clip lands on the instrument. The row under the pointer decides nothing but the bar, deliberately: dropping a bass loop means put this in the song, and which of your rows the pointer was over is not an answer to anything. Flat rather than drilled, because a pack has at most seven groups and a browser you have to open two columns to use is one you stop opening. No waveform on the tiles yet — a loop's face renders locally from its own instrument, which needs the pack song wired into the render pipeline; until then a tile shows what it knows instead of an empty box."
    },
    {
      "title": "Loop packs install, and placing one builds its channel for you",
      "version": "2026.8.192",
      "date": "2026-09-13",
      "area": "Sequencer",
      "type": "New feature",
      "userFacing": true,
      "details": "File > Import loop pack... takes an .ezlp and installs it, pinned, the way a content pack is. Placing a loop from one then does the whole job in a single undoable step: the first loop of a group creates the locked, folded channel and the one instrument that loop plays on; a second loop on that instrument adds a clip and nothing else; a second instrument joins the same group. You get the ezmuze 3 view — one row called Bass with your loops on it — with the real patterns and real synths a fold away underneath. A pack is a library, not a template, so nothing arrives that you did not ask for. Placing is reachable from the remote control API today (import_loop_pack, loop_packs, place_pack_loop); the browser panel to drag from is next."
    },
    {
      "title": "Changing a channel's pack binding or its folding redraws the row",
      "version": "2026.8.192",
      "date": "2026-09-13",
      "area": "Sequencer",
      "type": "Bug fix",
      "userFacing": true,
      "details": "A channel row card is built once, so an edit that changes what the CARD is — rather than what is inside it — has to say so. Locking a channel, or telling a group to fold its children onto its row, did neither: the model changed, the song saved correctly, and nothing on screen moved until the view was rebuilt for some other reason. Both now take the same path adding or reordering a channel already takes, along with placing a loop from a pack, which adds channels. Found by driving the app: the earlier check used files that already had the flags set, where the difference cannot show."
    },
    {
      "title": "The loop pack format, and what dropping a loop out of one does",
      "version": "2026.8.192",
      "date": "2026-09-13",
      "area": "Core",
      "type": "Internal",
      "userFacing": false,
      "details": "Stage 4a of docs/loop-packs-design.md: the .ezlp container (a zip holding a manifest, the pack song and art — and no rendered audio, because every waveform and preview renders locally from the real instrument), and LoopPackImport, which plans the drop as one undoable edit. Only what you drag is imported: the first loop creates the locked, folded group and exactly one instrument under it; a second loop on the same instrument costs one clip; a second instrument joins the same group. Everything copied keeps its id, which is what makes that dedupe work and what a later pack update will match on, and everything is deep-copied through the song format's own rules so the imported channel cannot reach back into the pack the browser is still holding. A copied definition carries the pack's licence with it. Building a pack strips the author's arrangement — a finished track is not part of what they sold and shipping it by accident should not be possible. 24 tests, including reading a pack back off disk and placing a loop from it. Not yet reachable from the UI, which is why the song format stays at 31."
    },
    {
      "title": "Lock a channel to one pack, so it only offers that pack's loops",
      "version": "2026.8.192",
      "date": "2026-09-13",
      "area": "Sequencer",
      "type": "New feature",
      "userFacing": true,
      "details": "A loop channel accepts any loop of its type from any pack you own, which is useful until you are working inside one pack and want the browser to stop showing you everything else. Its menu now offers Lock to <pack> wherever the channel's own clips agree on one, and from then on only that pack's loops of that type can be dropped on it — anything else is refused by name rather than silently ignored. A padlock on the channel header says so and names the pack on hover. Unlock channel is one click and gives everything back: the lock is a filter over material the channel could already play, never a cage, and unlocking keeps the record of where the channel came from. Nothing changes for a channel you do not lock. Groundwork for loop packs (docs/loop-packs-design.md §5); song format 31."
    },
    {
      "title": "The remote control can close the app",
      "version": "2026.8.192",
      "date": "2026-09-13",
      "area": "Build",
      "type": "Internal",
      "userFacing": false,
      "details": "POST /action {\"name\":\"quit\"} answers {ok, quitting} and then ends the run loop. A running instance locks its own binaries, so rebuilding while it is up fails the copy to bin/ as MSB3026 — a WARNING, so the build still exits 0 and everything looked at afterwards is the old code; this cost real time today, twice. Killing the process from outside works but skips Game.Exiting, which is what clears the crash-recovery copy, so the next launch offers to recover a session that ended on purpose. Quitting through here does neither. It answers before stopping the loop so the caller gets an acknowledgement rather than a dropped connection, and it is a CLEAN quit: unsaved changes are discarded. docs/remote-control-api.md."
    },
    {
      "title": "Fold a group of channels onto one row, loops and all",
      "version": "2026.8.192",
      "date": "2026-09-13",
      "area": "Sequencer",
      "type": "New feature",
      "userFacing": true,
      "details": "A channel with children can now be told to Fold children onto this row (its own menu). Collapsed, that row shows its hidden children's loops — named, full length, with their own waveforms — instead of hiding them, and every gesture still edits the real clip on the real channel: drag one and it moves on the instrument that plays it, double-click and you get that instrument's piano roll. Unfold and the clips are exactly where they always were, because nothing moved: a fold is a view, so the song sounds identical either way. A folded group is one lane — at most one of its clips plays at a time, so the merged row can never draw two things in the same place; a block's outline is the colour of the instrument playing it, so you can tell which is which without unfolding. An existing arrangement dragged into a group is never refused, only reported. Groundwork for loop packs (docs/loop-packs-design.md §4); song format 30."
    },
    {
      "title": "Design: user-authored loop packs, hoisted patterns and locked pack channels",
      "version": "2026.8.192",
      "date": "2026-09-13",
      "area": "Sequencer",
      "type": "Internal",
      "userFacing": false,
      "details": "docs/loop-packs-design.md — the normative spec for loop packs anyone can make, with all 26 decisions settled. One authoring pass over an ordinary song produces one store product with two downloads: an .ezlp for the studio, whose loops are live patterns on real instrument channels, and (opt-out) a legacy ezmuze 3.0 pack built from the same source. Covers the model (Channel.Pack + HoistsDescendantClips, Song.PackSource, ModuleDefinition.PackOrigin, FormatVersion 30), hoisting as a pure row-merge over the v21 folder channels so the renderer never learns about it, the one-lane rule enforced on placement and reported on reparent, locking including the opt-in retrofit onto existing legacy loop channels, Channel > Add pack groups as the authoring tool with the existing reparent drag filling them, an .ezlp that carries no rendered audio and renders every waveform locally, frozen append-only ordinals with tombstones because legacy loop ids are positional, pack updates as one prompt per song that never overwrites an edit, and the round trip in both directions. Design only; nothing is built."
    },
    {
      "title": "A device panel opens at the size its designer drew it",
      "version": "2026.8.192",
      "date": "2026-09-13",
      "area": "UI",
      "type": "Improvement",
      "userFacing": true,
      "details": "A module UI carries a fixed size — it is a panel somebody laid out, the way a plug-in window is — and the window that opened it used to be a share of your screen instead, so a panel arrived either stranded in the middle of a window far too big for it or squeezed down inside one too small. It now opens at exactly the size that shows the panel at 1:1. Alongside that, MOD SOURCES is closed by default rather than appearing by itself, its toolbar button lights up while the rail is showing so you can tell at a glance, and turning it on grows the window by the height of the rail instead of taking that room away from the panel — turning it off gives the height back. A rail with more sources than fit no longer has its scrollbar drawn across the bottom of the cards."
    },
    {
      "title": "Korben's card is the four splits and the ten mute/solo switches",
      "version": "2026.8.192",
      "date": "2026-09-13",
      "area": "Modules",
      "type": "Improvement",
      "userFacing": true,
      "details": "Korben is 38 components and its Stack card was every unwired control input of all of them, in declaration order — a wall of near-identical knobs you navigated by counting. It now ships a chosen face: the four crossover frequencies, which are where the bands meet and therefore what a multiband device IS, and the ten mute and solo switches, which are what you are listening through while you set them. Nothing is gone and nothing has changed about the sound: the band gains, the input amps, the all-pass match frequencies and the output stage are all still exposed, still automatable, and one click away under \"Advanced view\" in the card's own right-click menu."
    },
    {
      "title": "A module's panel face reaches songs that already hold it",
      "version": "2026.8.192",
      "date": "2026-09-13",
      "area": "Modules",
      "type": "New feature",
      "userFacing": true,
      "details": "A device carries a private copy of its module's graph, taken when it was added — which is why changing what a module puts on its panel used to help only the next person to add one, and every project already containing it kept the old card forever. A device that nobody has chosen a face for now inherits its module's, matched across the copy by component slot (the same address presets already use to reach a component in a device they were not saved from). The moment you toggle a control's \"Show in the panel\" the device's face becomes its own business and stops inheriting — including if you clear it, which is how you say \"show me everything\" and have it stick. The first toggle writes the inherited set down first, so adding one control to Korben's face adds a fifteenth rather than replacing fourteen with one."
    },
    {
      "title": "The module editor's nodes are the size of what is in them",
      "version": "2026.8.192",
      "date": "2026-09-13",
      "area": "UI",
      "type": "Bug fix",
      "userFacing": true,
      "details": "Every node in the graph editor was drawn at one fixed width with fixed-width columns for its port names, which is a guess, and the fonts are proportional - so it was right for \"Gain\" and wrong for \"Unison detune (cents)\", which ran straight through the slider beside it. Worse for an OUTPUT name, because those are right-aligned: a name wider than its column gets a negative alignment offset and draws LEFTWARD out of the node, across its own controls and - if the node was near the edge - out through the canvas clip entirely, which is where \"ports with no text\" came from. The text was drawn; it was somewhere else. Nodes now measure every label and size themselves to their columns, anything still too long is cut with an ellipsis instead of overflowing, and a port whose module forgot to name it shows its id rather than nothing. Two things came back with the space: a control's value is now shown on every row rather than being dropped whenever the row also had an output, and the editor reads values through the same formatter the device panels use, so a dial declaring itself in semitones finally says so on the node too."
    },
    {
      "title": "Clean Up settles, and stops depending on the size of your window",
      "version": "2026.8.192",
      "date": "2026-09-13",
      "area": "UI",
      "type": "Improvement",
      "userFacing": true,
      "details": "Clean Up sorted each column of the graph by the average position of each component's neighbours - measured against the positions you had just asked it to throw away, in a single pass that could not settle, and then spread the columns across the whole canvas so the same graph came out a different shape in a maximised window and a small one. It is now the standard layered drawing: the depth layering it always had, then a median ordering swept down the columns and back up until it stops changing, then columns packed at their own widest node and rows at each node's own height. It also knows how big each node really is, where it used to lay every component out on the tallest one in the graph - which is most of why a cleaned-up graph still looked untidy. Cleaning up twice now changes nothing the second time, and the view fits itself to the result."
    },
    {
      "title": "Right-click a port to unplug it, by name",
      "version": "2026.8.192",
      "date": "2026-09-13",
      "area": "UI",
      "type": "New feature",
      "userFacing": true,
      "details": "Every wire attached to the port, each named by what is on the other end - \"Disconnect from Band 1 phase 1 - In L\" - plus \"Disconnect all\" when there is more than one. Unplugging used to mean finding the cable itself: clicking the small affordance halfway along it to select it, then pressing Delete. Which cable that is, on a node with eight of them leaving the same edge, is a question you answered by tracing. The port already knows what is attached to it."
    },
    {
      "title": "A cable that carries a file only fits a file port",
      "version": "2026.8.192",
      "date": "2026-09-13",
      "area": "Modules",
      "type": "Bug fix",
      "userFacing": true,
      "details": "Nothing stopped a pack item or an embedded file being wired into an ordinary control. The dashed cable carries a HANDLE - which item, not how loud - so the far end received a small integer and used it as a number: \"3\" into a filter cutoff is a cutoff of 3 Hz and a patch that is quietly wrong. The other direction is worse, an LFO on a sampler's file port renaming the file forty times a second. A Content port now takes Content and nothing else, in both directions, with the reason on the status bar; the Stack's wiring overlay refuses the same pairs. Deliberately the only rule added - control into audio is a real technique, and an editor that refuses everything it cannot see the point of is one nobody can experiment in."
    },
    {
      "title": "A module can choose which controls its panel shows",
      "version": "2026.8.192",
      "date": "2026-09-13",
      "area": "Modules",
      "type": "New feature",
      "userFacing": true,
      "details": "Right-click any control in the graph editor and \"Show in the panel\" marks it as one of the ones a player reaches for. A device card otherwise shows every unwired control input the graph happens to have, in whatever order they were declared - a filter's cutoff beside the shaping constant somebody put behind it, with nothing saying which is which. As soon as one control is marked the card shows only marked controls, because a half-marked module is one whose author was mid-decision and marked-plus-everything-else is exactly the face they were getting away from. Nothing is hidden for good: a device whose graph you can open offers \"Advanced view\" in its right-click menu, which shows the full set. Marked controls wear the accent colour in the graph, so the editor says which of its rows a player will ever see."
    },
    {
      "title": "Say how a control should read, without writing any code",
      "version": "2026.8.192",
      "date": "2026-09-13",
      "area": "Modules",
      "type": "New feature",
      "userFacing": true,
      "details": "Right-click a control in the graph editor and Readout opens the display metadata: a range to map the control's position onto, a unit, a format like \"x{v}\", decimals, a leading + for anything bipolar, or a list of names that stands in for the number entirely and quantises the control to them. All of this has existed since the faceplates did and could only ever be set from C# - fine for the modules that ship with the app, no use at all to somebody building one, whose transpose dial could only ever read \"0.5\" where a player expects \"0 st\". Because it edits the component rather than a panel, one dialog changes how it reads on the Stack card, on the node in the graph and on every custom face at once. The same menu also resets a control to its default, naming the value it will go back to."
    },
    {
      "title": "A module's embedded file can be replaced or removed",
      "version": "2026.8.192",
      "date": "2026-09-13",
      "area": "Modules",
      "type": "Bug fix",
      "userFacing": true,
      "details": "The embedded-file row could only ever ADD. Once a file was in, changing which one meant deleting the component and wiring a new one up, and a file put there by mistake travelled inside the module for good - it ships with it. The row now has a right-click menu: Replace, and Remove, which points the component at nothing and leaves the bytes in the module, since another component may share them and publishing is what prunes what nothing references."
    },
    {
      "title": "The delete button on a graph node is a bin",
      "version": "2026.8.192",
      "date": "2026-09-13",
      "area": "UI",
      "type": "Improvement",
      "userFacing": true,
      "details": "It was an x in the corner of the title bar, which everywhere else in computing means \"close this\". It deletes the component and every wire touching it."
    },
    {
      "title": "A file picker can be driven from the control API",
      "version": "2026.8.192",
      "date": "2026-09-13",
      "area": "Desktop",
      "type": "Internal",
      "userFacing": false,
      "details": "POST /action {\"name\":\"pick_file\",\"path\":...} queues a file for the NEXT picker the app opens, which takes it silently instead of showing the OS dialog. A native dialog blocks the thread the frame loop runs on until somebody clicks it, so anything that reaches one is unreachable from the control API and stays unreachable until it gets its own by-path action - which is exactly what import_module and import_pack each are. A module's embedded file was one of those: the row worked and could be exercised by nothing. One seam instead of an action per button. One shot, consumed whether or not the caller liked what it got, so a stale queue cannot hijack a real click later; desktop only, since a browser tab has no filesystem to read and its picker never blocked anything."
    },
    {
      "title": "Text can be truncated to fit its box",
      "version": "2026.8.192",
      "date": "2026-09-13",
      "area": "UI",
      "type": "Internal",
      "userFacing": false,
      "details": "TextElement.Truncate (GustUI): a string shortened with an ellipsis until it really fits a width, measured including the ellipsis. An unwrapped TextElement does not clip - it draws straight out through its own box and over whatever is beside it, and a right-aligned one draws leftward out of the box. The two usual answers are a per-label ClipChildren wrapper, which costs a GPU scissor push and a flush each way, and letting it overflow. This is the third: shorten the string once, at build time."
    },
    {
      "title": "HALL 9000, an algorithmic reverb, free in the store",
      "version": "2026.8.190",
      "date": "2026-09-13",
      "area": "Modules",
      "type": "New feature",
      "userFacing": true,
      "details": "Twelve delay lines and a figure-of-eight tank built on Jon Dattorro's 1997 plate topology, running at full rate with its allpasses under constant modulation - because a reverb tail that does not breathe is one that rings. Space takes it from a vocal booth to a cathedral and Decay asks for a time rather than a feedback amount, so the two are independent: changing the room no longer changes how long it rings. Everything a reverb is judged on and the app has never had: pre-delay, a low cut that keeps the bass out of the tail, tone and width after the tank, ducking keyed from your dry signal so the wash blooms in the gaps, a gate for the plate sound, an octave-up shimmer that feeds the tank its own tail, and one enormous Freeze. Fifty-one presets across rooms, plates, halls, chambers, ambiences, drum spaces, vocal spaces and the strange ones, with a test holding every pair apart. The stock Reverb is untouched and every song using it sounds exactly as it did. Free in the store as `hall`; it is not built into the app, so nobody gets it without getting it."
    },
    {
      "title": "A reverb tank, as an engine component",
      "version": "2026.8.190",
      "date": "2026-09-13",
      "area": "Sound engine",
      "type": "New feature",
      "userFacing": false,
      "details": "fx.plate: Dattorro's plate tank as a module-builder primitive, so anyone building a module can have a real reverb in it rather than the three-knob one. It had to be written rather than composed and the reasons are arithmetic - fx.comb's delay maxes out at 20 ms where a reverb comb is 25-60, fx.allpass is a biquad phase section rather than a delay-based diffuser, and util.buffer delays by a whole tick where the tank needs twelve delays specified to the sample. Decay solves for an RT60 instead of setting a per-pass loss, which is what makes it independent of size; the modulation excursion scales with size, without which a small tank's allpass stopped being an allpass and the tank stopped being bounded; and the wet is divided by the square root of the tank's own build-up, so Mix means the same thing in a cupboard and a cathedral."
    },
    {
      "title": "An effect's store preview plays a voice, a piano and a beat",
      "version": "2026.8.190",
      "date": "2026-09-12",
      "area": "Modules",
      "type": "Improvement",
      "userFacing": true,
      "details": "An effect's preview used to be the stock basic synth playing one melodic phrase through it, three times. That is enough to prove a reverb is a reverb and almost nothing else - a vocoder had no voice to work on, a transient shaper had no transients, a rhythmic gate had no rhythm to sit against. Every effect preview now runs over three real sources instead: a dry spoken voice, two bars of piano at 120, and two bars of a breakbeat. Each preset is heard over each source - nine segments rather than three - so the preview is forty-five seconds for an effect and the unchanged fifteen for an instrument. The break is at 160.79 BPM and is NOT stretched to fit: its segment is compiled at its own tempo, so a tempo-synced delay inside the effect lands on the break's grid instead of a grid it never had. All three sources are CC0 from Freesound (339163 girlhurl, 521387 BrickDeveloper171, 697847 Flavorwave64) and ship inside the app; a fourth candidate was dropped for being CC BY, which would have meant a credits surface that has to survive every future build, for one sample."
    },
    {
      "title": "A bought module got its samples back",
      "version": "2026.8.187",
      "date": "2026-09-12",
      "area": "Modules",
      "type": "Bug fix",
      "userFacing": true,
      "details": "A sealed package stores every sample and every SoundFont once, content-addressed, with the graph carrying only the hash - and the reader that opens a package you bought never put them back. The items arrived listed, named and empty. An installed Trap Kit had nine samples' worth of nothing and played silence, which looks exactly like a kit whose wave bank is missing, so nothing anywhere complained. Importing the same module from a file always worked, which is why it took this long to notice."
    },
    {
      "title": "A drum kit's store preview plays a beat",
      "version": "2026.8.187",
      "date": "2026-09-12",
      "area": "Modules",
      "type": "Bug fix",
      "userFacing": true,
      "details": "The preview a store tile plays was one fixed melodic phrase starting at C3. A kit answers to notes 36 upward, one pad per note, so the phrase landed above the last pad and a kit previewed as fifteen seconds of silence. Kits get a beat now - kick, snare, hats and a clap, at velocities that leave room for four pads inside a second - and every preview is scaled to a decibel below full scale, so a loud module cannot shout over the others in the shelf."
    },
    {
      "title": "A preview can be added after a module is published",
      "version": "2026.8.187",
      "date": "2026-09-12",
      "area": "Desktop",
      "type": "Improvement",
      "userFacing": false,
      "details": "render_preview renders the fifteen seconds from the installed package and uploads it, for modules and preset packs alike. The wizard's Render preview button is one somebody has to remember to press, and three modules went up on 2026-09-12 without it; the only fix before this was publishing a new version, which is a poor reason to bump one. The preview also resolves EMBEDDED content now - Content was null outright, which is right for a pack pointer a buyer may not have and wrong for files the module carries itself."
    },
    {
      "title": "Grrowl - fifty DnB basses for Bifrost",
      "version": "2026.8.187",
      "date": "2026-09-12",
      "area": "Modules",
      "type": "New feature",
      "userFacing": true,
      "details": "A free preset pack for Bifrost, and the first preset pack in the store. Fifty basses in seven folders - reeses, growlers, foghorns, neuro, subs and rollers, hoovers and screech, and talkers - every one of them cut for drum and bass at 172 BPM. The tempo is the promise and it is arithmetic, not a claim: every LFO rate and delay time in the pack is a note value at 172, so an eighth is 5.733 Hz rather than a round six, and a wobble locks to the track instead of needing a nudge in every project. A test holds each rate to that grid, and another holds the bank to the house standard that no two presets sound alike."
    },
    {
      "title": "No preset pack could ever reach the store",
      "version": "2026.8.187",
      "date": "2026-09-12",
      "area": "Services",
      "type": "Bug fix",
      "userFacing": true,
      "details": "Uploading a preset pack answers with the pack's four-letter code inside the pack row; the app looked for one at the top level, found none, and read that as a refusal - so it said \"ezmuze central refused the pack\" about a pack it had just created, and stopped before publishing it. The pack was on the server all along, as a draft nothing could address. Preset packs have been in the app since they were built and not one had ever been published. The reader takes the code from wherever the server put it now, and a draft left behind by the old behaviour can be finished rather than uploaded again."
    },
    {
      "title": "Preset packs show their box art, and say when they are yours",
      "version": "2026.8.187",
      "date": "2026-09-12",
      "area": "UI",
      "type": "Bug fix",
      "userFacing": true,
      "details": "A preset pack's tile in the store drew the generated placeholder however good the art was, because a card could only ask for art by product id and a pack's art is filed under the image's own. Cards take a URL now. The same shelf also called every pack unowned - the catalogue row carries no ownership flag, unlike the module shelf's - so it told you \"0 of them yours\" about a pack you wrote. It asks what is installed, which is what the rest of the app already does."
    },
    {
      "title": "A preset bank in the repo can be published",
      "version": "2026.8.187",
      "date": "2026-09-12",
      "area": "Desktop",
      "type": "Improvement",
      "userFacing": false,
      "details": "publish_preset_pack opens the wizard on a bank from PublishablePresetBanks, and publish_pack_draft finishes one already on the server. The browser's route needs the presets saved in the library first and nothing seeds a pack, so an authored bank of fifty had no way in at all."
    },
    {
      "title": "The shared voice-part tests had been emptying themselves",
      "version": "2026.8.187",
      "date": "2026-09-12",
      "area": "Modules",
      "type": "Bug fix",
      "userFacing": false,
      "details": "SynthVoicePartsTests swept InstrumentCatalog, so every synth that left for the store dropped out of the sweep silently - velocity, glide, LFO shape, warp, click, noise and filter slope lost one instrument's worth of cover at a time over three days, with nothing turning red. Publishing the last catalogue synth with an LFO shape control emptied six theory data sources outright, which xUnit reports as an error rather than a pass. They sweep a new SynthRoster instead: every synth the repo builds, shelf or store. 18 cases became 153."
    },
    {
      "title": "Bueller is in the store, and out of the app",
      "version": "2026.8.187",
      "date": "2026-09-12",
      "area": "Modules",
      "type": "Improvement",
      "userFacing": true,
      "details": "The string machine is published free as buel, with its box art and channel art, and no longer ships inside the app - the same move the other twelve instruments made. It was the last one still built in, and only because it had no name. Its 52 presets, its panel and its ensemble go with it."
    },
    {
      "title": "The string machine is called Bueller",
      "version": "2026.8.187",
      "date": "2026-09-12",
      "area": "Modules",
      "type": "Improvement",
      "userFacing": true,
      "details": "The Solina-line string machine shipped in the betas under the working name Norns while it was being built. It is Bueller now - a divide-down organ is present in every octave, whether or not you called the register. Same module, same id, same presets: a song that already has one keeps playing and simply shows the new name."
    },
    {
      "title": "Channel art can be set without the file dialog",
      "version": "2026.8.187",
      "date": "2026-09-12",
      "area": "Desktop",
      "type": "Improvement",
      "userFacing": false,
      "details": "A module_art action puts channel art on one of your modules from a path. Module properties' Choose image button opens a native file dialog on the game thread, which is the same wall update_listing's art path went through: the one thing a published module is recognised by was the one thing no automation could set."
    },
    {
      "title": "YDP Grand is retired - Bank Manager is the piano now",
      "version": "2026.8.184",
      "date": "2026-09-12",
      "area": "Modules",
      "type": "Improvement",
      "userFacing": true,
      "details": "The YDP Grand module no longer ships. Bank Manager already does everything it did and more: all four of its layers default to the same FreePats grand, and each one has a pack picker under it, so the app was carrying a second piano that could only ever play one sound. The font itself has not moved - it is still the free YDP Grand Piano pack, with the same ids - and a song that already has a YDP Grand channel carries the module with it and plays exactly as it did. The licence credit moved to the Credits window, which already printed the same text and does not need an instrument loaded to be read."
    },
    {
      "title": "The instrument preview tool builds again",
      "version": "2026.8.184",
      "date": "2026-09-12",
      "area": "Build",
      "type": "Bug fix",
      "userFacing": false,
      "details": "tools/InstrumentPreview still called DrumKitWaveBanks.AppendAll to fill a throwaway song's wave bank for the two baked sample kits; that table left with the kits, so the tool no longer compiled. It renders the catalogue against an empty bank now, which is the right answer for what is left: Capacitor picks its slots by dial and the SoundFont Player waits for a font, so silence is the correct preview for both. Four doc comments that described the kits as absolute-slot starter content were rewritten to say where those kits went instead."
    },
    {
      "title": "An embedded file says what licence it is under",
      "version": "2026.8.183",
      "date": "2026-09-12",
      "area": "Modules",
      "type": "New feature",
      "userFacing": true,
      "details": "A file a module carries now records its licence, its author and its credit line, and the bundle writer turns each one into the credit the store checks. Publishing a module whose sounds do not say where they came from is refused, which is the rule that keeps somebody from redistributing a sample they do not have the right to. Also fixed: the writer was dropping those fields on the way into a bundle, so a module that credited everything properly arrived at the store crediting nothing."
    },
    {
      "title": "Box art can be set without the file dialog",
      "version": "2026.8.183",
      "date": "2026-09-12",
      "area": "Desktop",
      "type": "Improvement",
      "userFacing": false,
      "details": "update_listing takes an art path. The wizard's Choose image button opens a native file dialog that blocks the game thread, so art was the one part of a listing no automation could set - two modules published with a generated tile while their artwork sat on disk."
    },
    {
      "title": "The release build counts the demos instead of assuming eight",
      "version": "2026.8.182",
      "date": "2026-09-12",
      "area": "Build",
      "type": "Bug fix",
      "userFacing": false,
      "details": "Three release checks asserted a literal count of demo files, to catch the MSBuild glob that once failed silently on Linux and shipped a build that crashed on launch. Deleting four demos on 2026-09-11 therefore failed every beta for a day, and the message pointed at the wrong thing. They compare what shipped against what the repo has now, which catches the original failure and stops being tripped by the ordinary act of adding or removing a demo."
    },
    {
      "title": "Trap Kit and Vintage Kit carry their own samples",
      "version": "2026.8.182",
      "date": "2026-09-12",
      "area": "Modules",
      "type": "Improvement",
      "userFacing": true,
      "details": "Both kits are rebuilt on embedded packs, so each one holds its nine one-shots inside it. They used to read absolute positions in the song's wave bank, which meant they only made a sound in a song built for them - drop one into your own song and you got silence. Now they play anywhere. They are leaving the built-in instruments to become free downloads, which takes 2.6 MB of sample data out of every install."
    },
    {
      "title": "The publish wizard no longer warns about samples a module actually carries",
      "version": "2026.8.182",
      "date": "2026-09-12",
      "area": "Modules",
      "type": "Bug fix",
      "userFacing": true,
      "details": "\"These will be silent for a buyer\" is the right warning for a player reading the song's wave bank and the wrong one for a player fed by an embedded pack, whose samples are in the bundle. The check now follows the wiring back from each player, so it fired on exactly the modules that had fixed the problem it describes - it blocked both drum kits the day they were rebuilt."
    },
    {
      "title": "A module can carry its own samples",
      "version": "2026.8.182",
      "date": "2026-09-12",
      "area": "Modules",
      "type": "New feature",
      "userFacing": true,
      "details": "An embedded pack is content that belongs to an instrument rather than to a song or to the store: a new Embedded file component points at a file kept inside the module itself. Install the module and it plays, in any song, with no pack to find and nothing to load into a wave bank first. The files can only be changed in the module editor, they travel with the module wherever it goes, and no other module can use them. This is what the factory drum kits now run on."
    },
    {
      "title": "Song format 29",
      "version": "2026.8.182",
      "date": "2026-09-12",
      "area": "Core",
      "type": "Internal",
      "userFacing": false,
      "details": "ModuleDefinition.EmbeddedContent - the files a module carries. Additive in shape and not in effect, which is why it takes a version: an older build reads the graph, the wires and the components perfectly and drops the bytes, so the module opens looking entirely correct and plays silence. The four shipped demos were re-saved. Also fixed while here: exporting a module that carried embedded bytes silently dropped its polyphony, category, description and channel art, so a published polysynth came back mono."
    },
    {
      "title": "The app can list every component a module can be built from",
      "version": "2026.8.182",
      "date": "2026-09-12",
      "area": "Modules",
      "type": "New feature",
      "userFacing": false,
      "details": "POST /action primitives returns every graph component with its exact ports, ranges, defaults and choice names. There was no machine-readable list, so anyone writing a module definition outside the editor read the registry source or a doc table that had been stale for months - and a wire to a port that does not exist is silent, so a wrong id gives you a control that does nothing and no error. Writing the first test over it found the compressor's Detect switch showing 0 and 1 instead of Peak and RMS, for as long as the port has existed: its choice names were declared below the descriptor that used them, so the initialiser ran too late."
    },
    {
      "title": "A second instance can run out of its own folder",
      "version": "2026.8.182",
      "date": "2026-09-12",
      "area": "Desktop",
      "type": "New feature",
      "userFacing": false,
      "details": "--data-dir points an instance at a folder of its own instead of the shared AppData folder. Two instances over one store fight: both hold the libraries and the installed-module index in memory and the last one to write wins, so an automated copy could quietly undo work done in the one somebody is using. This is what lets a module-building agent drive its own ezmuze safely."
    },
    {
      "title": "Preferences now lists what is installed, and lets you remove it",
      "version": "2026.8.182",
      "date": "2026-09-12",
      "area": "UI",
      "type": "New feature",
      "userFacing": true,
      "details": "There was no way to remove a module or a content pack. The store could put one on your machine and nothing but a revoked licence ever took one off again. Preferences now has an Installed tab: every module, preset pack and content pack, biggest first, with what it holds in disk and a Remove button. The confirm names what stops working and reminds you that the licence is yours either way - the store will install it again for nothing. Anything the song open in front of you is using is marked, but removing it is still your call."
    },
    {
      "title": "Something you remove stays removed",
      "version": "2026.8.182",
      "date": "2026-09-12",
      "area": "Services",
      "type": "Bug fix",
      "userFacing": true,
      "details": "The launch sync downloads anything you own that is not on the machine, which would have put back whatever you had just removed - by the next morning, with no explanation. A licence says you may have something, not that you must, so a deliberate removal is remembered and the sync leaves it alone. Installing it again from the store clears that, which is the one press that means you changed your mind."
    },
    {
      "title": "The control API can list installed content packs and prove their files load",
      "version": "2026.8.182",
      "date": "2026-09-11",
      "area": "Desktop",
      "type": "Internal",
      "userFacing": false,
      "details": "POST /action content_packs returns every installed pack, its items, and whether each item's bytes actually read back, plus whether the factory piano found its font. A pack whose index entry is intact but whose blob is gone renders silence and says nothing, so it is the one failure a screenshot cannot show."
    },
    {
      "title": "The grand piano is a free download instead of 13 MB of every install",
      "version": "2026.8.182",
      "date": "2026-09-11",
      "area": "Sound engine",
      "type": "Improvement",
      "userFacing": true,
      "details": "The YDP Grand SoundFont used to ship inside the app: 13.3 MB that everybody downloaded whether or not they ever opened a piano. It is a free content pack on the store now. Get it once and Grand Piano, SoundFont Player and Bank Manager all play exactly what they played before - the sound is the same file. Until you do, they keep their panels and presets and make no sound, and installing the pack while the app is open is enough. Songs that already use the piano need no changes: the pack item carries the same id the song was written against."
    },
    {
      "title": "A pack can carry a compressed SoundFont, and sf2 consumers read it",
      "version": "2026.8.182",
      "date": "2026-09-11",
      "area": "Core",
      "type": "New feature",
      "userFacing": false,
      "details": "ezsf - an sf2 whose sample chunk is WavPack-compressed - is a registered content-pack format, and a subformat of sf2: anything that declares it reads SoundFonts gets both spellings, and the store's sf2 filter returns both. Its own id rather than calling it sf2, because a manifest is read by things other than this app. This is what lets the factory piano ship as a 13 MB pack rather than the 118 MB original or a 56 MB decode."
    },
    {
      "title": "The store says Installed, or Owned with an Install button",
      "version": "2026.8.182",
      "date": "2026-09-11",
      "area": "UI",
      "type": "Improvement",
      "userFacing": true,
      "details": "Owning something and having it on this machine are two different facts, and a store tile now says both. A module or content pack that is here reads Installed. One you already hold but have not downloaded - a new machine, or a pack you removed - reads Owned beside an Install button, instead of quoting you the price of a thing you already paid for."
    },
    {
      "title": "The app is 33 MB smaller, and a demo is now its file rather than build output",
      "version": "2026.8.182",
      "date": "2026-09-11",
      "area": "Build",
      "type": "Internal",
      "userFacing": false,
      "details": "Elevation's and The Dark's sample banks were embedded resources in Ezmuze.Audio.dll - 11 MB in every install, feeding C# builders that wrote the shipped .ezproj demo files. The demos already carry that audio inside themselves, so the builders and the banks are deleted: the .ezproj IS the demo now, edited in the studio and saved back, and a demo that needs fixing gets fixed in the file. synth.ezproj stays generated because the app builds Three Blind Mice in process at boot and the two must not drift. With the retired instruments' PCM gone in the same pass, Ezmuze.Audio.dll is 58 MB down to 24.5 MB and the desktop build 108 MB down to 75 MB. Tests that wanted a real song now read one of the shipped files, which is what a user opens anyway."
    },
    {
      "title": "The five retired instruments are gone, and four demos with them",
      "version": "2026.8.182",
      "date": "2026-09-11",
      "area": "Modules",
      "type": "Internal",
      "userFacing": true,
      "details": "Cerebrum, Nova, OpStack, GenesisFm and ChorusPoly were retired on 2026-09-09 - still loading, still playing, but filed in a graveyard at the bottom of the device picker - and are now deleted. Each was retired for a fault no preset bank could fix (one fixed signal path, or one fixed FM algorithm), each already had a successor doing the job properly (Bifrost, Modyssey, PXL4, Freyja, Kenobi), and none of them used any sound-making part the remaining instruments do not. The four demos that played them go too: Cerebrum, Cerebrum Liquid, Adagio DnB and Butterfly. A song that names one of these instruments will no longer open - that is the cost of removing them rather than parking them, and it is why the graveyard existed. The app is about 10 MB smaller."
    },
    {
      "title": "Content packs get their own tab in the store, instead of sitting among the modules",
      "version": "2026.8.181",
      "date": "2026-09-11",
      "area": "UI",
      "type": "Improvement",
      "userFacing": true,
      "details": "The store's shelves are Loop packs, Preset packs, Modules and now Content packs. A content pack was appearing on the Modules shelf behind a filter chip, which put a library of SoundFonts among the instruments: a pack is not a device, and somebody shopping for a synth should not have to step over libraries to find one. The chip is gone and the Modules shelf is devices again; the new shelf says what a pack is for - they turn up in the pack browser, where a module's Font or File input picks one.",
      "tickets": [
        227
      ]
    },
    {
      "title": "ezmuze studio opens maximised",
      "version": "2026.8.180",
      "date": "2026-09-11",
      "area": "UI",
      "type": "Improvement",
      "userFacing": true,
      "details": "The window fills the screen when the app starts, instead of opening at 1280x800 and waiting to be dragged bigger. A sequencer is a wide instrument - the arrange grid, the channel rack and a module panel side by side all want the width - and every display this runs on is wider than the old default. Preferences > Display > \"Open maximised\" turns it off for anyone who prefers a small window, and the choice is remembered. Nothing moves the window while you are using it: the preference is read once, at launch."
    },
    {
      "title": "The wave player can load a wave from a content pack, not just from the song's own bank",
      "version": "2026.8.179",
      "date": "2026-09-11",
      "area": "Modules",
      "type": "New feature",
      "userFacing": true,
      "details": "gen.wavebank gained a File input of the same Content kind the SoundFont player's Font input has: wire a Pack item to it and the voice plays that wave instead of a slot in the song's wave bank. That makes content packs useful for wave libraries as well as SoundFonts - one pack of .wav files plays in any module that reads wav, which is what keying packs by FORMAT rather than by module is for. Nothing is wired by default, so every existing device sounds exactly as it did, and an item that will not decode falls back to the bank slot rather than going silent. Decoded waves are cached per item, so a note does not pay to read a file.",
      "tickets": [
        227
      ]
    },
    {
      "title": "Content packs are in the store: publish one from File, buy or get one from the Modules shelf",
      "version": "2026.8.178",
      "date": "2026-09-11",
      "area": "Modules",
      "type": "New feature",
      "userFacing": true,
      "details": "Stage 2 of content packs (bug board #227). File > Publish content pack... takes a .zip with a manifest (what the pack builder writes, or what a pack you imported carries), asks for a name, a description and a price, and puts it in the store as a product of its own kind - the store checks the manifest before it takes it: every item needs an id, a registered format and a credit. Packs appear on the store's Modules shelf with a Content packs chip and a line saying how many items in which formats; Get or Buy downloads the pack sealed to your account and installs it into the pack browser, pinned per item, exactly as an imported zip would be - and a takedown or a refund removes it the way it removes a module. Only a pack's current version ships, because a song references items by id and a new version keeps them. The control API gained publish_pack for the same flow without the dialog.",
      "tickets": [
        227
      ]
    },
    {
      "title": "Content packs carry artwork: a cover for the pack and a picture per item, shown in the browser",
      "version": "2026.8.177",
      "date": "2026-09-11",
      "area": "Modules",
      "type": "New feature",
      "userFacing": true,
      "details": "A pack manifest can name a cover (png or jpeg inside the zip) and each item a picture of its own - the instrument that was sampled, say. The content pack browser draws the cover as a tile on the pack's row and the picture as a thumbnail beside the item's name; a pack without a cover gets a tile in a colour of its own with its initial, so nothing looks missing. Importing a bare zip picks the pictures up by convention: an image with the same name beside a file, or the single picture in a file's folder (how FreePats ships), and a root-level cover/art/front image for the pack. Art is capped at 2 MB per picture and pinned beside the sounds. The Creative Commons SoundFonts pack now ships with a cover and the FreePats photographs of the ocarina and the Francis Bacon piano.",
      "tickets": [
        227
      ]
    },
    {
      "title": "Control API: update_listing rewrites a published module's store description without a new version",
      "version": "2026.8.176",
      "date": "2026-09-11",
      "area": "Desktop",
      "type": "Internal",
      "userFacing": false,
      "details": "POST /action {\"name\":\"update_listing\",\"module\":...,\"description\":...,\"title\":...} rewrites the store listing of a module of yours that is already published, looked up in the published registry so it works for a store module whose source has left the build. The wizard only writes the listing as part of a publish, and a typo in the description is not a version. The listing's name is \"title\" in the body because \"name\" is the action's own field."
    },
    {
      "title": "A picker opened from a floating module panel floats above it instead of hiding behind it",
      "version": "2026.8.176",
      "date": "2026-09-11",
      "area": "UI",
      "type": "Bug fix",
      "userFacing": true,
      "details": "The content pack browser and the wave picker, when opened from a module panel that floats over the sequencer, used to open as a sequencer tab - underneath the panel that asked for them. The panel then swallowed every click meant for the picker, which looked as if choosing did nothing. They now open as a floating window above the panel (and stay above it through title-bar drags). A panel docked as a tab still gets its picker as a tab beside it, as before.",
      "tickets": [
        227
      ]
    },
    {
      "title": "Bank Manager v3: each layer loads its SoundFont from an installed content pack",
      "version": "2026.8.176",
      "date": "2026-09-11",
      "area": "Modules",
      "type": "New feature",
      "userFacing": true,
      "details": "Bank Manager - the four-layer rompler in the store - now takes each layer's font from a content pack: a Font picker at the foot of every layer column opens the pack browser filtered to sf2, and the layer's sampler reads whichever font is chosen. Four layers, four fonts, from any packs you have installed - a pack's bass under a pack's piano, which is what a romper is for. A layer with no font chosen still plays the embedded grand, so every existing preset sounds as it did and the instrument works before a pack is installed. Under the hood each layer gained a Pack item component wired into its sampler's Font input; presets are untouched because the choice lives in the song, not the preset. Published to the store as version 3 (v2 was the channel art). The Creative Commons SoundFonts pack (tools/content-packs) is the first library to load into it.",
      "tickets": [
        227
      ]
    },
    {
      "title": "Content packs carry credits: a pack's licence and description, and each item's attribution, shown in the browser",
      "version": "2026.8.175",
      "date": "2026-09-10",
      "area": "Modules",
      "type": "Improvement",
      "userFacing": true,
      "details": "A pack manifest can now say what the pack is (description) and what it is offered under (licence), and every item can carry its own attribution and, where it differs, its own licence - so a pack of Creative Commons SoundFonts credits each font's makers the way CC BY asks. Importing a bare zip of .sf2 files keeps what the fonts themselves carry: the importer reads each font's INAM name and ICOP/ICMT notice into the item when the manifest says nothing. The content pack browser shows the pack line (name, author, licence), its description, and the chosen item's credit in a strip under the two columns instead of a hover tooltip. tools/content-packs/build-cc-soundfonts.py builds the first such pack from seven FreePats CC0 fonts (honky-tonk piano, DX-style FM piano, finger bass, tubular bells, ukulele, ocarina, synth strings) with a full manifest.",
      "tickets": [
        227
      ]
    },
    {
      "title": "Content packs: load a SoundFont from an installed pack instead of embedding it in the song",
      "version": "2026.8.174",
      "date": "2026-09-10",
      "area": "Modules",
      "type": "New feature",
      "userFacing": true,
      "details": "The first stage of content packs (bug board #227): an archive of files a module can load, keyed by FORMAT rather than by module, so one library plays in any module that reads that format. File > Import content pack... installs a .zip of sf2, sfz or wav files (with or without a manifest) as pinned, per-item blobs; installing loads nothing. In the module editor a new Pack item component points at one item of an installed pack and emits a HANDLE on a Content port - drawn as a dashed, ivory wire, because it carries a pointer to a file rather than a signal. The SoundFont player has a matching Font input that takes precedence over its own embedded font when wired. The pack browser opened from the node (or from a panel PackPicker element) shows only the packs whose items the wire's consumer can use. A song stores references by item id, never by position in the pack, and never the bytes; a missing pack renders that item silent and can still name it. The store product kind for selling packs is stage 2."
    },
    {
      "title": "The stage check looks for the Linux AppImage, not a tarball",
      "version": "2026.8.173",
      "date": "2026-09-10",
      "area": "Build",
      "type": "Bug fix",
      "userFacing": false,
      "details": "The Linux head switched from a .tar.gz to an AppImage on 2026-09-09 and finalize.sh was never told, so it asserted a file that no longer gets built. Nothing reported it because no build ever got that far: the macOS head failed first, every time, for the whole two days. Two stale assumptions stacked in a fail-fast pipeline, and only the first one is ever visible - which is the argument for fixing the loud failure and then looking again rather than assuming one cause."
    },
    {
      "title": "The macOS build is signed, notarized and stapled, and beta deploys again",
      "version": "2026.8.173",
      "date": "2026-09-10",
      "area": "Build",
      "type": "Bug fix",
      "userFacing": true,
      "details": "Notarization had never once succeeded since it was added on 2026-09-09, and because the macOS head shares a fail-fast matrix with the other four, its failure skipped the beta deploy entirely: thirteen pushes in a row built nothing anybody could download. Apple rejected the app with \"the signature of the binary is invalid\", pointing at the main executable, where nothing was wrong. The real fault was the layout. Apple reserves Contents/MacOS for executable code, and the signing tool will not seal a file in there that is not a Mach-O binary - it leaves it out of the seal and carries on, so the bundle ends up describing something other than itself. A self-contained .NET publish puts about 220 managed assemblies in that directory, and one of them is enough to fail the whole thing. The macOS app now publishes as a single file with its content, data and service plugins in Contents/Resources, which is where Apple expects them, and the build fails loudly if anything unsignable turns up in Contents/MacOS again."
    },
    {
      "title": "The remote-control docs say what a scroll delta actually is",
      "version": "2026.8.173",
      "date": "2026-09-10",
      "area": "Services",
      "type": "Internal",
      "userFacing": false,
      "details": "/scroll's delta is a raw wheel value where one notch is 120, and GustUI scales movement by delta/120 — so a delta of 3 moves a list under two pixels and looks exactly like an element that cannot scroll. That misreading shipped two store modules without their channel art: the image picker scrolls its overflow, the wanted image was below the fold, the scroll-into-view loop did nothing, and the click landed on empty space. Written down next to the endpoint."
    },
    {
      "title": "Rhodine is free in the module store, and no longer built in",
      "version": "2026.8.173",
      "date": "2026-09-10",
      "area": "Modules",
      "type": "Improvement",
      "userFacing": true,
      "details": "Rhodine follows the rest of the rebuilt roster into the store, free, as rhod — with its box art and its channel art. It is no longer shipped inside the app, so it arrives the same way every other module does: you get it because you went and got it. Songs that already name Rhodine keep playing either way, because the demos that use it embed the definition in their own files."
    },
    {
      "title": "Rhodine, rebuilt: an electric piano where how hard you play changes the tone",
      "version": "2026.8.173",
      "date": "2026-09-10",
      "area": "Sound engine",
      "type": "New feature",
      "userFacing": true,
      "details": "Rhodine was retired a day earlier for being a two-operator patch that could only ever make one sound. It is back as a real instrument. Velocity is wired to the modulation index rather than to the volume, so playing harder makes it BARK instead of just making it louder - the one thing that separates an electric piano from a sine with an envelope on it. The strike is now a second operator with its own fast envelope, so the metallic knock at the front of the note is genuinely separate from the body behind it, and a preset can have one without the other. Key scaling takes index away as you go up the keyboard, anchored at C2 so the bass - the richest part of a real one - is left alone. Behind all that sits a tone stack with the pickup's 1.1 kHz bark, a stereo tremolo that pans like a suitcase or wobbles like a stage amp, a phaser, a chorus and an amp with drive. 55 presets across nine folders. Songs that already name Rhodine keep playing: it was rewritten in place rather than replaced."
    },
    {
      "title": "Freyja: the Juno, rebuilt around a chorus that is the real circuit",
      "version": "2026.8.173",
      "date": "2026-09-10",
      "area": "Sound engine",
      "type": "New feature",
      "userFacing": true,
      "details": "A six-voice Juno-106: one DCO whose saw, pulse, sub and noise each have their own level (the old ChorusPoly could only switch between saw and pulse, which put half the patches ever written on one of these out of reach), a four-position high-pass, a 24 dB low-pass whose envelope amount runs BOTH ways so the filter can close on the attack as well as open, one shared envelope, an LFO with the delay that makes vibrato arrive after the note, and per-voice drift on the filter and the VCA - never the pitch, because a DCO does not drift and that is the D. The chorus is the reason it exists: three bucket-brigade delay lines, two LFOs, no dry path, in the hardware's own Off / I / II. 55 presets in nine folders, free in the store (frey) rather than shipped in the app. Adagio DnB and Butterfly now use it for the pad and the string lead they used to get from ChorusPoly, which stays retired for anything else that names it."
    },
    {
      "title": "Sliders can stand up",
      "version": "2026.8.173",
      "date": "2026-09-10",
      "area": "UI",
      "type": "New feature",
      "userFacing": true,
      "details": "A module panel can ask for a VERTICAL slider - bottom 0, top 1, the way every fader ever built reads. Freyja's front panel is a row of twenty-one of them because that is what a Juno is; before this the toolkit only had horizontal ones, and a stack of those is a mixing desk seen from the wrong angle. Panels can also mark a slider BIPOLAR, so a control whose centre means nothing-happening fills from the middle out and shows where the middle is."
    },
    {
      "title": "LadderOne is free in the store",
      "version": "2026.8.173",
      "date": "2026-09-10",
      "area": "Modules",
      "type": "Improvement",
      "userFacing": true,
      "details": "The Moog-style mono bass and lead - the one factory synth the rebuild kept - now has its own box art and channel art, so it joins the rest in the store: free, with its 14 presets, its panel and a preview, and no longer shipped inside the app. Get it the way you would anything else. The three demo songs that use it (Adagio DnB, Butterfly, Elevation) carry their own copy and play as they always did, owned or not."
    },
    {
      "title": "A channel added \"With Module\" is wired up, and no longer goes silent when you touch a knob",
      "version": "2026.8.173",
      "date": "2026-09-10",
      "area": "Sequencer",
      "type": "Bug fix",
      "userFacing": true,
      "details": "Add channel > With Module put the module on the channel without the notes-in and output wires every other way of adding a device makes. It still played, because the pattern cache renders the instrument on its own with a default flow - but the moment you moved a dial, playback switched to the live graph, which is built from the channel's wires, and there were none: silence. Channels added that way are wired now, and the live graph treats a chain with no wires at all as one nobody wired rather than one somebody silenced (mute is for that), so a song that already has such a channel plays - and keeps playing through knob moves - without being touched. Found on Rat 030."
    },
    {
      "title": "Tempo-synced delays and LFOs keep the song's tempo when played from the keyboard",
      "version": "2026.8.173",
      "date": "2026-09-10",
      "area": "Sound engine",
      "type": "Bug fix",
      "userFacing": true,
      "details": "The live keyboard audition renders in 50-millisecond chunks and called each chunk a bar, so anything that reads tempo off the bar - a delay set in beats, a tempo-synced LFO, the echo's ticks - saw a beat of twelve milliseconds. Rat 030's delay, set to a quarter note, came out as a comb filter and sounded like a bad reverb. The audition, the note audition and the store preview now tell the graph what a real bar is at the song's tempo, and everything in beats follows it. Rat 030's dials also read in their own units now: Hz, seconds, milliseconds, semitones and beats, rather than a fraction of the dial."
    },
    {
      "title": "Rat 030: the 303, with slides and accents that work like the hardware's",
      "version": "2026.8.173",
      "date": "2026-09-10",
      "area": "Sound engine",
      "type": "New feature",
      "userFacing": true,
      "details": "A TB-303: one oscillator (saw or square), the diode ladder filter, the six knobs - Tuning, Cut Off Freq, Resonance, Env Mod, Decay, Accent - and the two things that make a 303 line a 303 line. A SLIDE is a note that overlaps the one before it in the piano roll: the pitch glides and the envelopes do not retrigger, so the filter keeps falling through the new note. An ACCENT is a note with velocity at 0.9 or above: more filter sweep, more level, a shorter decay. After the synth, where they were always plugged in: a Drive and a tempo-synced Delay. One voice, 52 presets in seven folders, and a panel that looks like the box. Two things to know: the piano roll's default velocity is 1, so a fresh pattern is accented throughout - pull plain notes DOWN; and Rat 030 is a store module (ratx, free), not a built-in - get it from the store like the other new instruments."
    },
    {
      "title": "The notes bank has a slide signal: a note that overlaps the last one says so",
      "version": "2026.8.173",
      "date": "2026-09-10",
      "area": "Sequencer",
      "type": "New feature",
      "userFacing": true,
      "details": "Every generator's notes source now carries a seventh signal, Slide: 1 while the sounding note began while the previous one was still sounding (an overlap in the piano roll), 0 for a note that started from silence or was butted exactly against the last; and 1 again when a held note resumes because the one played over it was released, so a mono synth glides back down instead of retriggering. Mono only - a polyphonic voice is never slid onto. In the module builder it is a port on the notes source like Velocity is, so any mono synth can be given a legato glide or a no-retrigger mode by wiring it: Rat 030 does both from the one flag. Nothing changes for a device that does not wire it."
    },
    {
      "title": "New primitive fx.diodeladder: a 303 filter that self-oscillates",
      "version": "2026.8.173",
      "date": "2026-09-10",
      "area": "Sound engine",
      "type": "New feature",
      "userFacing": true,
      "details": "A four-stage ladder low-pass with its feedback through a tanh, run at twice the sample rate, whose resonance is a feedback amount rather than a Q - so at the top of the dial it sings on its own, and turning it up thins the bottom out the way the hardware does. The output is taken between the third and fourth stages, which is the diode ladder's softer, nearer-18 dB slope rather than a Moog's 24. Drive saturates the input into the ladder, which is what an accented note does on the hardware. The multifilter could not do any of this: its resonance floor and stability clamp exist to keep it from ringing, and ringing is the point here."
    },
    {
      "title": "A string machine: divide-down organ into a triple-BBD ensemble (working name Norns)",
      "version": "2026.8.173",
      "date": "2026-09-10",
      "area": "Sound engine",
      "type": "New feature",
      "userFacing": true,
      "details": "The roster's last agreed instrument: a Solina-style string machine. Three divider taps (16', 8', 4') shared by six register tabs - Contrabass, Cello, Viola, Violin, Horn, Trumpet - each pair into its own tone circuit: a low-pass for the bass, a high-pass-then-low-pass for the strings (the thinning is the whole secret of the Solina violin), and a resonant low-pass with its own ADSR for the brass. Strings and bass share the hardware's two-slider contour, Crescendo and Sustain. Nothing in the voice moves - no drift, no detune - and all the movement is one circuit at the end: a phaser, then the ensemble. Twelve voices, 52 presets in eight folders, fingerprint-checked so no two sound alike. It is a working name; the user names synths."
    },
    {
      "title": "New primitive fx.ensemble: the string machine's three-line BBD chorus",
      "version": "2026.8.173",
      "date": "2026-09-10",
      "area": "Sound engine",
      "type": "New feature",
      "userFacing": true,
      "details": "The circuit that makes a divide-down organ sound like a string section, as one component in the module builder. Three five-millisecond delay lines fed the same signal, swept by two LFOs - one slow (about 0.6 Hz), one ten times faster - each 120 degrees apart on each line, so at every instant one line is sharp, one flat and one passing through and the pitch of the sum never wobbles. The three outputs land left, centre and right, which is where a string machine's width comes from. Rate, Depth, Width and Mix; Mix defaults to fully wet because the hardware's Ensemble switch replaces the signal rather than adding to it. Two of the existing chorus in series is a wide sound and not this one, which is why it is a primitive."
    },
    {
      "title": "A store module's channel shows its art, and About shows its cover",
      "version": "2026.8.173",
      "date": "2026-09-10",
      "area": "Modules",
      "type": "Bug fix",
      "userFacing": true,
      "details": "Two places a store module's pictures went missing. A channel started from an installed store module had no art behind its header, while the same module from your library did: the store's path builds the device differently and dropped the art and the description on the way, so both now travel with the graph. And the About panel drew a plain coloured tile with the module's name for every installed module, because the sealed package's own box art was never read out of it; it is now, so About shows the cover at its proper shape, offline, for any version you have installed."
    },
    {
      "title": "Bifrost, Kenobi, Modyssey and Laufeyson are free in the store, not built in",
      "version": "2026.8.173",
      "date": "2026-09-10",
      "area": "Modules",
      "type": "Improvement",
      "userFacing": true,
      "details": "The four remaining new synths are store products now - Bifrost (bifr), Kenobi (keno), Modyssey (mody) and Laufeyson (lauf) - all free, each with its full factory bank, its own panel, box art, channel art and a preview, and they no longer ship inside the app. Bank Manager, Cloudform and Plucktopus made the same move yesterday, and each of those now has a v2 with its channel art attached, which the first versions could not carry. Every one of the seven puts its picture behind its channel header the moment it is on a channel. Of the factory synths, LadderOne and Chip stay built in; nothing has to be bought."
    },
    {
      "title": "Module properties is a proper dialog",
      "version": "2026.8.173",
      "date": "2026-09-10",
      "area": "UI",
      "type": "Bug fix",
      "userFacing": true,
      "details": "File > Module properties in the module editor was a one-off panel, missing everything the app's other dialogs have - the frame, the keyboard order, Escape to cancel, the footer buttons where they always are. It is now the same modal window as every other dialog, with the same description box, category, voices and channel art picker as before."
    },
    {
      "title": "Product art is shown at its own shape, and the welcome screen fits five across",
      "version": "2026.8.173",
      "date": "2026-09-10",
      "area": "UI",
      "type": "Bug fix",
      "userFacing": true,
      "details": "Box art is a tall 1060 by 1484 picture, and three places drew it as something else: the welcome screen's cards cropped a sliver off the top and bottom, the store's tiles cut a wide strip out of the middle, and the About panel squashed it into a square. All three now use a frame of the art's own proportions, so the picture arrives whole. Because an honest card is taller, the welcome screen shows five per row instead of four - the same row height as before, just more of them."
    },
    {
      "title": "A module's store preview plays three of its presets, not one",
      "version": "2026.8.173",
      "date": "2026-09-10",
      "area": "Modules",
      "type": "Improvement",
      "userFacing": true,
      "details": "Render preview in the publish wizard used to play a ten-second phrase on whichever preset the module happened to be sitting on, which for a synth with sixty presets said very little. It now picks three presets spread through the bank - one from the start, one from the middle, one from the end, so three different kinds of sound rather than three neighbours from the same folder - and plays each for five seconds on a fresh instance, with a short fade at each join so tails end instead of being chopped. The phrase itself is a held root, a rising figure and a fifth over the root, chosen to show an envelope, a filter and a chord in fifteen seconds."
    },
    {
      "title": "Re-publishing a module: the button says \"Check version\" until the store has answered",
      "version": "2026.8.173",
      "date": "2026-09-10",
      "area": "Modules",
      "type": "Bug fix",
      "userFacing": true,
      "details": "When you publish a new version of a module the first press only asks the store whether the new version is compatible with the last, and the second press sends it. The button said \"Publish v2\" for both, while the checks pane told you to press a \"Check version\" button that was not on the screen. The button now reads \"Check version\" until the verdict is in and \"Publish v2\" after it, and the status line names the button it wants pressed."
    },
    {
      "title": "Module properties: a module now says what it is, and it travels with it",
      "version": "2026.8.173",
      "date": "2026-09-10",
      "area": "Modules",
      "type": "New feature",
      "userFacing": true,
      "details": "The module editor has File > Module properties: a description, a category, the number of voices, and a picture to sit behind the module's channel header in the sequencer. All four are saved on the module itself and go everywhere it goes - your library, the song, an exported file and the store - so a module that is published arrives with its picture and its description, and the publish wizard starts from what you wrote here rather than being the only place you could write it. The description box takes a paragraph: Enter starts a new line and Ctrl+Enter saves. Channel art was previously something only the built-in instruments could have, which is why the three that moved to the store lost theirs on the way; that is fixed for everything published from now on."
    },
    {
      "title": "Every built-in now declares its polyphony on its definition, not only on its device",
      "version": "2026.8.173",
      "date": "2026-09-10",
      "area": "Modules",
      "type": "Bug fix",
      "userFacing": false,
      "details": "A built-in module has two builders - one for the definition, one for the device - and only the device set MaxVoices. The publish wizard reads the definition, so publishing a built-in from anywhere but a song that already had it on a channel offered to ship an eight-voice pad as a monosynth; the first three store publishes walked straight into it and were caught by hand. Every catalogue entry now sets the same count on both, and a test holds the two builders to agreeing."
    },
    {
      "title": "Bank Manager, Cloudform and Plucktopus are free in the store, not built in",
      "version": "2026.8.173",
      "date": "2026-09-10",
      "area": "Modules",
      "type": "Improvement",
      "userFacing": true,
      "details": "The three instruments finished this week are now store products - Bank Manager (bank), Cloudform (clou) and Plucktopus (pluc), all free, each with its full factory bank of presets, its own panel, box art and a ten-second preview - and they no longer ship inside the app. Get them from the store the way you would anything else; nothing is lost by the move and nothing has to be bought. The reason is the one the store exists for: an instrument that lives there can be updated on its own schedule, and a song that uses it carries a reference rather than a copy. Bifrost, Kenobi, Modyssey, Laufeyson, LadderOne and Chip stay built in. One thing did not make the move yet: the pictures behind those three's channel headers, because channel art is something a built-in declares and a store module cannot carry yet."
    },
    {
      "title": "Channel art ships at a fortieth of the size",
      "version": "2026.8.173",
      "date": "2026-09-10",
      "area": "Build",
      "type": "Performance",
      "userFacing": false,
      "details": "The channel-header art arrived as full-size PNG renders, 5.6 MB between them in every build and every web download. They are photographic renders with no flat colour and no transparency - the case PNG is worst at - and are drawn at a few hundred pixels across at most, so they now ship as 640-wide JPEGs, 260 KB in total. No code changed: the loader already tried .jpg first."
    },
    {
      "title": "Plucktopus: the physical-modelling synth has its name and its colours",
      "version": "2026.8.173",
      "date": "2026-09-09",
      "area": "Sound engine",
      "type": "Improvement",
      "userFacing": true,
      "details": "The instrument that was called Yggdrasil while it was being built is Plucktopus - eight limbs plucking, striking and bowing at once, which is what a bank of guitars, kalimbas, marimbas, harps, bells and singing bowls sounds like coming out of one synth. Nothing about the sound changed and every preset is where it was. Its panel now takes its palette from its own product art the way the rest of the roster does: the strings are the cyan lines its tentacles are plucking, the body is the warm amber of the wooden bars and the bell it strikes, and everything between is the creature. It also has channel art now, so its channels are recognisable at a glance in the sequencer."
    },
    {
      "title": "The filter envelope was holding two synths' filters wide open",
      "version": "2026.8.173",
      "date": "2026-09-09",
      "area": "Sound engine",
      "type": "Bug fix",
      "userFacing": true,
      "details": "On Bank Manager and Cloudform the filter envelope's connection to the cutoff was set up so that NO modulation still pushed the cutoff six thousand hertz upwards. The filter therefore sat wide open on every patch, and because it was already passing everything, the Cutoff dial, the key tracking and the filter LFO all appeared to do nothing - three controls that looked broken because of one that was. Both are wired the way the rest of the roster already was. The two factory banks have been re-levelled to match, since the filter is now doing something it previously was not."
    },
    {
      "title": "Cloudform: a spectral pad synthesizer, with 60 presets",
      "version": "2026.8.173",
      "date": "2026-09-09",
      "area": "Sound engine",
      "type": "New feature",
      "userFacing": true,
      "details": "A new instrument built on the PADsynth oscillator. Every other way of making a big sound in the roster takes one oscillator and detunes copies of it against each other; this one decides what the spectrum should be and works backwards to a waveform that has it, so the width is inside a single table rather than assembled out of beating copies. Play one note, one oscillator, no effects, and it is already a section - the two stereo channels differ before anything has been done to them, which is something a chorus cannot fake. Bandwidth is the big knob rather than the cutoff, because it is what actually separates the sounds: under ten cents is a clean tone, twenty to sixty is a string section or a choir, past two hundred the harmonics have merged and it is weather. The 60 presets are filed as Strings, Choirs, Pads, Bells, Drones, Keys, Brass and reeds, Basses and Textures, but what really separates those folders is that one number. There is a second oscillator for contrast - a narrow bright layer over a wide dark one is a section with a flute over it - and it is off until you want it, because turning it up doubles the tables the patch has to bake. The controls that shape the spectrum sit in their own section away from everything that moves, and that is not tidiness: there is no waveform half way between two spectra, so moving one while a note sounds is heard as a step rather than a sweep."
    },
    {
      "title": "A PADsynth oscillator, for pads that are already an ensemble",
      "version": "2026.8.173",
      "date": "2026-09-09",
      "area": "Sound engine",
      "type": "New feature",
      "userFacing": true,
      "details": "New in the module picker: PADsynth, Nasca Octavian Paul's algorithm from ZynAddSubFX. Every other way of making a wide pad takes one oscillator and detunes copies of it against each other. This one works backwards from the spectrum instead. A harmonic of a real, big sound - a choir, a string section, a bell in a hall - is not a single frequency, it is a smear a few cents wide, because a hundred slightly different things are sounding at once. So each harmonic is placed as a hill of energy rather than a line, with random phase, and the waveform is worked out from that. Play one note and it is already a section, with no chorus, no detune and no second oscillator. Bandwidth sets how wide each hill is: under ten cents is a clean synthetic tone, twenty to sixty is an ensemble, several hundred is weather. Bandwidth scale blurs the top of the spectrum faster than the bottom, which is what makes it sound like air moving rather than like a synth. Left and right are the same spectrum rolled twice, so the two sides are genuinely different signals rather than one signal panned - something a chorus cannot fake. The table is baked when the patch loads rather than computed as you play, so the controls that shape the spectrum are design controls: moving one while a note sounds re-bakes, and you hear a step rather than a sweep."
    },
    {
      "title": "Waveguide timings were being reported under the wrong name",
      "version": "2026.8.173",
      "date": "2026-09-09",
      "area": "Sound engine",
      "type": "Bug fix",
      "userFacing": false,
      "details": "The profiler's node labels are paired with the node-kind list by POSITION, and the entries for util.beats and gen.string went in the wrong order when the string voice was added. Both kinds existed and both had a label, so the count check that exists to catch exactly this could not see it: the only symptom was that every waveguide's time appeared against util.beats' name and the string itself was untagged. Corrected, with a note at the site saying why a count is not enough."
    },
    {
      "title": "Bank Manager: a four-layer romper, with 58 presets",
      "version": "2026.8.173",
      "date": "2026-09-09",
      "area": "Sound engine",
      "type": "New feature",
      "userFacing": true,
      "details": "A new instrument built around layers rather than oscillators. It has four of them, all identical, and each one carries everything it needs to be a separate instrument: its own source, its own tuning, its own tone, its own range on the keyboard and in velocity, its own envelope, its own level and its own place in the stereo field. That is what lets one patch be two instruments. Set two layers' key ranges to meet and you have a split - bass in the left hand, keys in the right. Set their velocity ranges to meet and you have a velocity switch - brush the key for a pad, hit it and a bell arrives on top. Give them both the whole keyboard and one of them a slow attack and you have a pad. The seams can crossfade over up to two octaves so a run across a split does not change instrument between one key and the next, or be exactly hard when you want the switch to be a switch. Each layer's source crossfades between a wavetable oscillator and the sampler, so a sampled attack can sit on a synthesised body - which is the thing neither a sampler nor a synth does on its own. The 58 presets are filed by what the layers are doing to each other (Stacks, Splits, Velocity) as well as by the usual Keys, Pads, Basses and Leads, because that is what the instrument is for and a bank of single-layer patches would teach it wrong. Three layers of four are silent on most patches and cost nothing while they are: the engine skips a layer whose level is down, along with everything feeding it."
    },
    {
      "title": "A layer covering the whole keyboard no longer goes quiet at full velocity",
      "version": "2026.8.173",
      "date": "2026-09-09",
      "area": "Sound engine",
      "type": "Bug fix",
      "userFacing": true,
      "details": "Zone's crossfade ramps inward from each edge of its range, which is right where two layers meet and wrong at the ends of the world. A layer set to the whole velocity range with any fade at all was silent at full velocity - the hardest hit, which is the one most notes are played at - and the same at both ends of the keyboard, because the fade was ramping down into an edge with nothing on the other side of it to hand over to. There is no crossfade at the top of velocity, so there is now no fade there either; an edge only fades when it is a real boundary. Five presets on the new Bank Manager were silent because of this before it was found. Key ranges are also rounded to whole keys now: a limit typed as 48 reaches the engine through a dial and a wire that do not round-trip exactly, and a boundary landing a hair low silenced the first note of the upper layer while every other note worked."
    },
    {
      "title": "The Constant module holds the number you give it",
      "version": "2026.8.173",
      "date": "2026-09-09",
      "area": "Sound engine",
      "type": "Bug fix",
      "userFacing": true,
      "details": "A Constant - the module that feeds a fixed number into anything - only held values between -10 and 10, and asking for more silently gave you the limit instead. It feeds pitches, frequencies in hertz, sample-bank slot numbers and delay times in frames, and it has no idea which, so a limit that suits one of those is wrong for all the others. Four shipped things were wrong because of it, and every one looked like a choice rather than a fault. Bifrost's sub oscillator asked to play an octave below the note and got ten semitones, so it was a minor seventh below and out of tune under every patch using the sub; the retired ChorusPoly had the identical problem. Plucktopus's Body dial asked for 400 Hz and got 10, so the control that shapes its resonance did nothing audible. And the Vintage drum kit's samples live at wave-bank slots 9 to 18, so eight of its nine pads were playing the same sample. The range is now a million either way - large enough that no honest use reaches it - and all four are fixed at the source. One knock-on worth knowing: a wire's scaler maps the source's declared range onto its own, so a Constant feeding a scaled wire now maps differently than it did."
    },
    {
      "title": "A Zone module, for key splits and velocity layers",
      "version": "2026.8.173",
      "date": "2026-09-09",
      "area": "Sound engine",
      "type": "New feature",
      "userFacing": true,
      "details": "New in the module picker: Zone answers 'does this note belong to this layer?' as a number between 0 and 1, which you multiply into the layer. That is all a key split is - two layers whose key ranges meet - and all a velocity layer is. The edges can crossfade over up to two octaves, so two layers hand over smoothly instead of changing tone between one key and the next, and a fade of zero is an exact hard split for when you want the switch to be a switch. Two layers set to meet at a boundary give exactly one of them on every note, with no seam and no gap. It is the piece a multi-layer sample instrument is built out of."
    },
    {
      "title": "Plucktopus: a physical-modelling instrument, with 56 presets",
      "version": "2026.8.173",
      "date": "2026-09-09",
      "area": "Modules",
      "type": "New feature",
      "userFacing": true,
      "details": "The fifth instrument of the new roster, and the first that is not subtractive. It does not start with a rich waveform and filter it - it starts with a burst of noise in a feedback loop, and the note is whatever survives going round that loop a few thousand times. Two strings, so they can be detuned against each other into a unison pair, or tuned an interval apart into a twelve-string course or a koto. A pluck position that removes harmonics the way a real string does. A body, which is what you actually hear when someone plays a guitar. Its two big knobs are Decay and Damping rather than a filter cutoff, because those two are the difference between a harp, a harpsichord, a muted bass and a marimba - and no filter setting gets you from one to another. 56 presets across nine folders: guitars, basses, plucked keys, mallets, harps and zithers, bells, beds and a few things no real instrument does. The name is a placeholder."
    },
    {
      "title": "A plucked string you can build with",
      "version": "2026.8.173",
      "date": "2026-09-09",
      "area": "Sound engine",
      "type": "New feature",
      "userFacing": true,
      "details": "A new String module in the picker: a real plucked or struck string, modelled rather than sampled. Pitch, how long it rings, how fast the brightness dies away, where along the string you pluck it, and whether it is plucked or struck. Plucking it in the middle removes the second harmonic exactly the way it does on a real string, because it is the same reason - the second harmonic is motionless at the midpoint. It plays in tune to within two cents across seven octaves, which the existing comb filter cannot do: a comb's delay is a whole number of samples and A440 needs a hundred and a fifth of them. It is the piece the physical-modelling instrument on the roster was waiting for, and it is available to anything you build yourself in the meantime."
    },
    {
      "title": "Module editor: coloured ports, gradient wires, and banks you can move",
      "version": "2026.8.173",
      "date": "2026-09-09",
      "area": "UI",
      "type": "Improvement",
      "userFacing": true,
      "details": "Every port dot is now coloured by what it does rather than by which side of a node it is on - green for audio, red for drive, amber for frequency, blue for pitch and time, violet for control, teal for level, pink for gates. It is the same scheme on every module, including ones added later, because the colour is worked out from the port rather than written down per module. Wires now run as a gradient from the colour of the port they leave to the colour of the port they arrive at, so you can see where a cable is going from either end without following it, and they are drawn with enough points to actually be curves - a long wire used to be visibly made of straight bits. The Inputs and Outputs banks are no longer rails stuck to the sides of the window: they are panels on the canvas that pan, zoom and drag like anything else, and they start beside the graph instead of at the window edges, which is why a small module's output wires are no longer as long as your monitor. Moving one is undoable."
    },
    {
      "title": "The remote control API can open the module editor",
      "version": "2026.8.173",
      "date": "2026-09-09",
      "area": "Services",
      "type": "New feature",
      "userFacing": false,
      "details": "POST /action {\"name\":\"module_editor\",\"module\":\"Bifrost\"} opens the node canvas on a module, building the same preview channel module_ui does. The panel and the graph are two different screens and only the panel could be reached headlessly, so nothing about wires, port dots or node layout could be looked at without a human opening the editor - which is exactly what the port-colour and wire-gradient work needed to verify itself."
    },
    {
      "title": "Three new building blocks: a ring buffer, a switch and a beats converter",
      "version": "2026.8.173",
      "date": "2026-09-09",
      "area": "Sound engine",
      "type": "New feature",
      "userFacing": true,
      "details": "All three are in the module picker and can go in any patch you build. RING BUFFER is a circular sample store - push a value in, read back the one you just pushed and the one about to be overwritten - with its length set in SAMPLES from 32 to 16384. That is the piece the existing Buffer could not be: Buffer measures in seconds and cannot go shorter than a tick, and a comb filter, a plucked string or a flanger's short tap all need delays of a few dozen samples, because at those lengths a delay sets a PITCH. Its contents survive across a position change in the timeline, and are cleared when you press play. SWITCH passes one of sixteen stereo pairs through to a single Left/Right output, chosen by a number - wire a toggle to it and it is an A/B switch, wire a knob and it is a rotary. Only the selected pair is processed, so the unused ones cost nothing. BEATS TO SECONDS/SAMPLES answers how long a number of beats is, in both units at once, and it knows your time signature, so one beat in 3/4 is correctly longer than one beat in 4/4."
    },
    {
      "title": "A three-band EQ, as a module you can put anywhere",
      "version": "2026.8.173",
      "date": "2026-09-09",
      "area": "Sound engine",
      "type": "New feature",
      "userFacing": true,
      "details": "There was no EQ in the engine - only low-pass, high-pass, band-pass and notch - and a cutoff filter is not a tilt. One decides what survives, the other decides how much more or less of it you want, and you cannot brighten a sound with a low-pass. The new EQ is a low shelf, a mid bell you can sweep and set the width of, and a high shelf, each plus or minus 18 dB. It is in the module picker under Effects like any other, so it can go in a channel's chain or inside a module you are building, and Bifrost now ships one at the end of its own tail. Flat is exactly flat - a band at 0 dB passes the signal through untouched, sample for sample - so putting one in a chain changes nothing until you turn something."
    },
    {
      "title": "An instrument panel's text now resizes with the panel",
      "version": "2026.8.173",
      "date": "2026-09-09",
      "area": "UI",
      "type": "Bug fix",
      "userFacing": true,
      "details": "A panel is drawn at whatever size the window gives it, and until now everything about it scaled EXCEPT the words. Shrink a panel and the knobs, cells and boxes got smaller while the text stayed the size it was, so captions started ellipsising to things like \"PHS D...\", a selector's caption came to rest on top of its own option cells, and readouts pushed out of their rows. Magnify a panel and you got the opposite - a large face with tiny lettering in it. Text now scales with the panel in both directions, with a floor of 7pt because below that there is nothing left to read. Every instrument's panel benefits, not just the ones where it was noticed."
    },
    {
      "title": "Bifrost's tail gains a phaser, a flanger and a compressor",
      "version": "2026.8.173",
      "date": "2026-09-09",
      "area": "Modules",
      "type": "New feature",
      "userFacing": true,
      "details": "The effects after the amp now run drive, chorus, delay, reverb, phaser, flanger and compressor, laid out on the panel in that order because the only question anybody asks a tail is what the sound goes through first. The compressor is one knob - Amount opens the ratio and drops the threshold together, which is what more compression means on a synth - with a Makeup beside it. It comes last, because a compressor is the thing that makes a patch sit still and a stage after it would undo that. Across the 234 Vital presets measured, the compressor is the most-used effect of any kind, ahead of the reverb. Every new stage is exactly bypassed where it rests, so no existing preset changed. Four new presets show them off - Squashed, Phase Electric, Jet Sweep and Pumped - taking the bank to 79."
    },
    {
      "title": "Bifrost has a second filter",
      "version": "2026.8.173",
      "date": "2026-09-09",
      "area": "Modules",
      "type": "New feature",
      "userFacing": true,
      "details": "A second filter sits in series behind the first, with its own kind, slope, cutoff, resonance and drive, and a Mix knob that blends it in. One filter cannot both give a bass its body and take the mud off the bottom of it, and it cannot make a vowel out of two band-passes; two can. Mix rests at nothing and is exactly bypassed there, so no existing preset changed. Three new presets show what it is for - Twin Peak, Formant Stack and Notch Wash - bringing the bank to 75. Filter 2's cutoff is the second most-modulated destination across the 234 Vital presets measured, behind only filter 1's, which is why it came before anything else still on the list."
    },
    {
      "title": "Bifrost rebuilt: three oscillators, four macros, and modulation you route yourself",
      "version": "2026.8.173",
      "date": "2026-09-09",
      "area": "Modules",
      "type": "Improvement",
      "userFacing": true,
      "details": "The first Bifrost shipped with two oscillators and twelve fixed modulation routes chosen in advance - LFO 1 to cutoff, the mod envelope to position, and ten more - and you could not add a thirteenth or point one anywhere else. It now has three full oscillators (each with its own table, warp, tuning, unison, level and pan), four macros, four LFOs and three envelopes, and NO fixed routes at all: drag any source off the rail along the bottom of the panel onto any knob on it, and drag the ring that appears around that knob to set how far it goes. That is around seventy destinations instead of twelve, and a patch can use as many as it wants. The panel is new to match - three identical oscillator columns, a live list of what is currently routed, and the instrument's own colours. All 72 factory presets were rebuilt on it, nine of them new and only possible now, and every one of them arrives with the same four macros already assigned: 1 opens the filter, 2 adds reverb, 3 moves the wavetable, 4 bends it."
    },
    {
      "title": "Bifrost's shape was measured against Vital rather than guessed at",
      "version": "2026.8.173",
      "date": "2026-09-09",
      "area": "Modules",
      "type": "Internal",
      "userFacing": false,
      "details": "Vital is open source and installed on this machine, so 234 real .vital presets were read rather than remembered. Two numbers drove the whole rewrite: the four macros are the most-used modulation sources in the corpus - ahead of every LFO - and the average preset carries 26 routings, with 201 of 234 using more than twelve. 121 of 234 run three oscillators. docs/bifrost-vital-parity.md records the measurements, what still differs (a second filter, compressor and EQ in the tail, spectral morph, a sample oscillator) and what \"copy Vital's presets\" can honestly mean. Nothing of Vital's source, algorithms or wavetables is used; it is GPL-3 and the corpus was read as evidence of what matters, which is what reading its manual would have been. The test harness now applies a preset the way the app does - ModMatrixMath.Apply for the structure, then PresetMath.ApplyValues for the numbers - because values alone would have measured a bank with every routing missing."
    },
    {
      "title": "Numpad / and * move the typing keyboard an octave",
      "version": "2026.8.173",
      "date": "2026-09-09",
      "area": "UI",
      "type": "Improvement",
      "userFacing": true,
      "details": "Playing a synth from the computer keyboard gave you two and a half octaves from C4, which is not enough to hear what an instrument actually does - a sub bass and a bell patch live three octaves apart and both sound wrong in the middle of the keyboard. Numpad / goes down an octave and numpad * goes up, four down and three up, which is as far as the layout can move and stay inside MIDI. The status bar names where you are (\"Z = C6\") rather than counting offsets. Anything you are holding is released as the octave moves, so a note cannot be left hanging at a pitch the keyboard no longer knows about, and it works everywhere the typing keyboard does: the module editor, an instrument's own panel, and the piano roll - where the highlighted rows follow it too."
    },
    {
      "title": "A store module's own presets show as its Factory bank",
      "version": "2026.8.173",
      "date": "2026-09-09",
      "area": "Modules",
      "type": "Improvement",
      "userFacing": true,
      "details": "A module bought from the store has always carried the presets its author made for it, and the preset browser has always lent them to you. What it did not do was say where they came from: they appeared under the module's raw four-letter product code, so an instrument's own presets sat under a heading like \"TSTM\". They are now grouped as Factory, above anything you bought and above your own, which is also the explanation for why they cannot be deleted - you can edit one and save it, and your copy takes its place. Two things behind that: publishing a module now ships only the author's OWN presets, where before it would also have bundled any lent to them by a preset pack they had installed for their own module; and the publish wizard now tells an author how many presets are going in, or says plainly that none are."
    },
    {
      "title": "PXL4's four chip engines actually sound like four different chips now",
      "version": "2026.8.173",
      "date": "2026-09-09",
      "area": "Sound engine",
      "type": "Improvement",
      "userFacing": true,
      "details": "The verdict was that PXL4's four engines were too samey, and measuring them agreed: the FM corner sat a long way from the rest while the NES, POKEY and SID corners sat close together, so three quarters of the blend pad was three settings of one sound. Nothing about the instrument's shape was wrong - each engine already had the controls that separate its chip and every one of them was turned down. The NES's triangle channel was buried under a pair of pulses, the POKEY's distortion and clock crush sat at half throw when a POKEY is by far the grittiest of the four, and the SID - the only one with a real resonant filter and a ring modulator - had its filter half open and ring modulation at exactly zero. Turned up to what each chip is actually known for, the closest pair of corners is now nearly twice as far apart, and a test holds them there."
    },
    {
      "title": "Bifrost: the new wavetable flagship, with 61 presets in ten folders",
      "version": "2026.8.173",
      "date": "2026-09-09",
      "area": "Sound engine",
      "type": "New feature",
      "userFacing": true,
      "details": "The instrument that replaces Cerebrum, and it is built around the two things Cerebrum could not do. Its two oscillators warp INDEPENDENTLY - Cerebrum shared one warp mode across both, which is most of why two of its patches on different wavetables still shared a character - and it has an FX tail with real mix controls: saturation, chorus, delay and reverb, each blendable from nothing to everything, sitting after the amplifier so a long release rings out through the reverb instead of being cut off by it. Both oscillators reach every one of the seventeen factory wavetables, with their own position, warp, tuning, unison and level; there is a sub, the shared noise layer, one filter, two envelopes, two LFOs and twelve fixed modulation depths. 61 presets across Basics, Bass, Lead, Pad, Pluck, Keys, Texture & Drone, Rhythmic, Brass & Strings and FX & Risers. The name is provisional."
    },
    {
      "title": "Laufeyson: a virtual-analogue polysynth, with 53 presets in nine folders",
      "version": "2026.8.173",
      "date": "2026-09-09",
      "area": "Sound engine",
      "type": "New feature",
      "userFacing": true,
      "details": "The clean, exact counterpart to Kenobi. Two oscillators with four waveforms each, a four-pole filter, two envelopes and an LFO that reaches pitch, waveform, cutoff and level at once - and three things no analogue poly could do: oscillator sync, ring modulation and oscillator FM, with ring sitting in the mixer as a fourth source rather than hiding behind a switch. Where Kenobi's voices are slightly out of tune with each other and wandering, Laufeyson's are identical on purpose, which is the whole difference between the two instruments: width here comes from unison, exactly detuned, rather than from drift. 53 presets across Basics, Bass, Lead, Pad, Pluck, Stabs & Chords, Sync & Ring, Keys and FX & Sweeps - and the Sync & Ring folder is the argument for the instrument, since nothing else in the roster makes those sounds."
    },
    {
      "title": "Modyssey: a six-operator FM synth, with 58 presets in nine folders",
      "version": "2026.8.173",
      "date": "2026-09-09",
      "area": "Sound engine",
      "type": "New feature",
      "userFacing": true,
      "details": "The DX7-family synth the roster was missing, replacing OpStack and Rhodine at once. Six operators, each with its own waveform, frequency ratio, level and envelope, and any of them can be a carrier - turn its Level up and you hear it, leave it down and it only modulates. Instead of 32 numbered algorithms it has nine FM depth dials, one per possible connection, which is the same routings and everything in between them: a DX7 says operator 2 either modulates operator 1 or does not, and here it can do so by a third. The classic algorithms ship as presets with their names on rather than as numbers you have to memorise. The operator waveform is a dial that morphs sine to triangle to square to saw, standing in for the self-feedback the engine cannot do at a musical delay, and Spread pans odd operators against even ones so a multi-carrier patch is wide rather than just louder. 58 presets: Algorithms, E.Pianos, Bells & Mallets, Bass, Brass & Reeds, Keys & Organs, Pads & Strings, Lead, Percussion & FX."
    },
    {
      "title": "Kenobi: a new eight-voice analogue polysynth, with 57 presets in nine folders",
      "version": "2026.8.173",
      "date": "2026-09-09",
      "area": "Sound engine",
      "type": "New feature",
      "userFacing": true,
      "details": "An OB-X-style poly - two oscillators a voice with saw, pulse and triangle, cross modulation, a switchable 2 or 4 pole filter with key tracking, two envelopes, and an LFO that reaches pitch, pulse width and cutoff all at once rather than one at a time. What makes it sound analogue is that its eight voices are genuinely different from each other: each one is slightly out of tune with the rest and wanders on its own as it plays, the way eight real voice cards do, so a held chord shimmers instead of sitting still. The 57 presets are filed - Bass, Brass, Pad, Strings, Keys, Lead, Pluck, Stab, Sweeps & FX - which is the first shipped preset bank that has ever used the preset browser's folders at all."
    },
    {
      "title": "Factory presets are checked for actually sounding different from each other",
      "version": "2026.8.173",
      "date": "2026-09-09",
      "area": "Sound engine",
      "type": "Internal",
      "userFacing": false,
      "details": "Every preset in a shipped bank is now rendered through the real engine and reduced to eight measurements - brightness in three bands, attack, sustain, release, movement and stereo width - and the build fails if any two presets land too close together, naming the pair. Earlier banks were only ever checked STRUCTURALLY (different mod routings, different waveforms), which is a real check and not the one that was failing: two patches can differ in every structural axis a synth has and still sound the same, which is exactly what happened to Cerebrum three rewrites running. Loudness is deliberately not one of the measurements, so a bank cannot pass by being a volume knob. Three false starts are written up in docs/synth-roster-2026.md: a Goertzel band sweep misses the harmonics of low notes entirely, fixed measurement windows report every pluck as silence, and plain variance scores a fast decay as movement."
    },
    {
      "title": "New engine part: per-voice analogue drift",
      "version": "2026.8.173",
      "date": "2026-09-09",
      "area": "Sound engine",
      "type": "New feature",
      "userFacing": false,
      "details": "mod.drift, a modulation source that knows WHICH voice of a polyphonic device it is running in - the one thing nothing in the existing kit could do, because every voice compiles the identical graph with identical settings. It gives each voice a fixed tuning error plus a slow aimless wander, which together are the difference between a chord that shimmers and one that sits still. Deterministic: seeded from the voice number and where the component sits, never from a clock or a random id, so two renders of the same song stay identical and bounce caching keeps working."
    },
    {
      "title": "Six superseded synths move to a Retired drawer at the bottom of the picker",
      "version": "2026.8.173",
      "date": "2026-09-09",
      "area": "Sound engine",
      "type": "Improvement",
      "userFacing": true,
      "details": "Cerebrum, Nova, ChorusPoly, GenesisFm, OpStack and Rhodine now live in a Retired category that sorts last in the device picker, so the instruments worth reaching for are not buried among the ones that were superseded. Nothing is deleted and nothing breaks: every one of them still loads, still plays, still carries its presets, and every song and demo that names one opens exactly as before - retiring changes where a device sits in the list and nothing else. LadderOne and Chip stay where they are, as does PXL4, along with Grand Piano, the SoundFont player, the drum kits and every effect. Retired is not offered as a category you can file your own module under."
    },
    {
      "title": "Pointers for the rest of it: clips, the ruler, the piano roll, the drum grid",
      "version": "2026.8.172",
      "date": "2026-09-08",
      "area": "UI",
      "type": "Improvement",
      "userFacing": true,
      "details": "The second half of the pointer pass. A clip's BODY now shows a move pointer, which is the commonest gesture on the timeline and had nothing at all; a ghost clip on a group row shows a hand, because pressing it takes you to the real clip rather than editing the copy; the ruler shows a playhead pointer. A drag keeps its own pointer for as long as it lasts, so moving a clip across another clip's edge no longer flickers to a trim pointer that letting go there would not do. The piano roll tells the four things one grey grid can mean apart - sound this key, draw a note here, move this note, change its length - and the drum lanes say hit. Explorer tiles say they are dragged onto the timeline. A channel's level and pan are named exactly, as are Mute and Solo, which are two identical squares a letter apart. Scrollbar thumbs are carried, a window's title bar moves the window, and a text field takes the I-beam.",
      "tickets": [
        224
      ]
    },
    {
      "title": "More of the cursor atlas reachable, and a rule for when not to",
      "version": "2026.8.172",
      "date": "2026-09-08",
      "area": "UI",
      "type": "Internal",
      "userFacing": false,
      "details": "GustUI's StandardCursors gains GrabHand and Crosshair, and its scrollbars, XY pad, toggle switch, dock splitter and modal title bar declare pointers - the splitter restating its direction in LayoutFor, since which way it resizes is a property of the docked panel rather than of construction. The app side adds a hover pass on the piano roll that mirrors HandlePress's decision tree one gesture earlier, sharing its hit-testing but none of its side effects, and quoting the same 18px/6px test so the pointer cannot promise a resize the press then declines to start. design-guide.md gains the table of what is wired and, more usefully, the rule for what is not: a custom pointer earns its place when the gesture is ambiguous or invisible, and never as a label for a control that already looks like itself."
    },
    {
      "title": "The pointer now tells you what a drag will do, nearly everywhere",
      "version": "2026.8.171",
      "date": "2026-09-08",
      "area": "UI",
      "type": "Improvement",
      "userFacing": true,
      "details": "The app draws its own mouse pointers and ships two hundred of them, but only a clip's edges ever asked for one - a window's resize handles, the channel-header splitter, the row-height bar, knobs, faders, text fields, buttons and menu rows all showed the same arrow as empty space. They say what they are now: the four directions on a resize handle, a drag pointer on anything whose value you drag, an I-beam on a text field, a hand on anything you press - and a refusal sign on anything disabled, instead of a hand promising a click that will be dropped. The clip edges are unchanged.",
      "tickets": [
        224
      ]
    },
    {
      "title": "An element can name the pointer it wants",
      "version": "2026.8.171",
      "date": "2026-09-08",
      "area": "UI",
      "type": "Internal",
      "userFacing": false,
      "details": "GustUI grows a CursorTrait, read by the hover pass it already runs every frame, so a control declares its pointer at construction instead of needing a hand-written per-frame hover handler. That price is why an app carrying 200 cursors was using three of them. The front-most declaration wins, and an imperative CursorManager.Use still runs afterwards and still wins - which is what keeps a sequencer clip's four edge zones working, since those are strips inside one element rather than elements of their own. GustUI names only the pointers that belong to a toolkit rather than to an app (StandardCursors), and an unregistered name falls back to the plain arrow, so an app with no cursor art behaves exactly as before."
    },
    {
      "title": "The control API can see the mouse pointer",
      "version": "2026.8.171",
      "date": "2026-09-08",
      "area": "Services",
      "type": "New feature",
      "userFacing": false,
      "details": "GET /cursor answers which pointer the app is drawing right now, by name, plus whether the atlas loaded and how many cursors it holds - so a script asserts cursor().name == 'ResizeHorizontal' instead of hunting a 30px sprite in a 4K screenshot, and it works whether or not the window has focus. POST /cursor {draw:true} makes the pointer appear in a capture of an unfocused window, which is the only way it appears at all: suppression is keyed on focus and a tool driving the app over this API is never the focused window, so the one thing the app draws entirely itself was the one thing a screenshot could never show. Both are on the MCP surface and in ezmuze.cursor() for Lua."
    },
    {
      "title": "A group row's ghost clips are legible again",
      "version": "2026.8.170",
      "date": "2026-09-08",
      "area": "UI",
      "type": "Bug fix",
      "userFacing": true,
      "details": "Nest a channel under another and the parent's row mirrors what its children are doing, as dimmed ghost clips. Those ghosts were being dimmed three times over - the face, then the waveform inside it by the same fraction again, and no outline at all - so a parent row with a full child under it read as an empty lane. The face and the waveform now dim by their own amounts, and a ghost keeps the block outline, so its extent reads even where the waveform is quiet. It is still plainly dimmer than the clip one row down that actually owns the sound.",
      "tickets": [
        222
      ]
    },
    {
      "title": "The sequencer toolbar has a stack button",
      "version": "2026.8.170",
      "date": "2026-09-08",
      "area": "UI",
      "type": "Improvement",
      "userFacing": true,
      "details": "Show or hide the stack from the toolbar, rather than only from the View menu. It takes the place of the three add-a-channel buttons, which asked the same question the channel gutter's [+] row already asks with the full device list behind it - and those three now sit in that menu too, so a synth channel still arrives with an empty pattern clip, a wave channel with its bank, and a module channel with the node canvas open."
    },
    {
      "title": "Stretching a clip shows a stretch pointer",
      "version": "2026.8.170",
      "date": "2026-09-08",
      "area": "UI",
      "type": "Improvement",
      "userFacing": true,
      "details": "The bottom half of a clip's edge - the half that changes its speed - now shows arrows pointing out of a waveform, at both ends. The old pointers pointed INTO a bar, which reads as moving an edge: the thing the trim half above it actually does."
    },
    {
      "title": "A loop keeps its own length instead of being squashed to the nearest power of two",
      "version": "2026.8.170",
      "date": "2026-09-08",
      "area": "Sound engine",
      "type": "Improvement",
      "userFacing": true,
      "details": "Dropping a sample used to round it to 1, 2, 4, 8 or 16 bars and stretch the audio into whichever it landed on, so a bar-and-a-half loop played at the wrong speed and repeated in the wrong place. A pattern can now be any length, and a dropped sample keeps the one it has - rounded only to the nearest beat, which is still enough to tidy a loop cut at a slightly different tempo. Everything follows it: where the clip repeats, where its marker sits, how the waveform tiles. Migrated legacy songs came out measurably closer to their originals as a result, one of them by 26%."
    },
    {
      "title": "The repeat marker sits where the sound actually comes round",
      "version": "2026.8.170",
      "date": "2026-09-08",
      "area": "UI",
      "type": "Bug fix",
      "userFacing": true,
      "details": "A clip's pinch marks - the notches that show where its pattern starts again - were placed by dividing the block into equal parts rather than by stepping one pass at a time, so any clip that did not hold a whole number of passes marked the wrong spot. A 2-bar clip of a pass lasting a bar and a half put its marker at 1 bar, half a bar before the sound repeated; a 3-bar clip of the same pass looked right only because two passes fit it exactly. The pass length is also no longer rounded to whole bars, which is what made a stretched clip's markers wrong in the first place. The waveform inside the block already used the correct arithmetic, so the two now agree."
    },
    {
      "title": "The control API can read a clip's window and speed",
      "version": "2026.8.170",
      "date": "2026-09-08",
      "area": "Services",
      "type": "Internal",
      "userFacing": false,
      "details": "GET /clips reports the open song's clips as the numbers behind them: the window each one holds onto its pattern, how many passes that is, its tempo ratio, and the pattern's own length. The element tree only ever gave rectangles, so questions about why a clip sounds the way it does needed a debug print and a rebuild. Filters by channel, by covering bar, and by count. Documented in docs/remote-control-api.md."
    },
    {
      "title": "A clip is a window onto its pattern, and it always repeats",
      "version": "2026.8.170",
      "date": "2026-09-08",
      "area": "Sequencer",
      "type": "Improvement",
      "userFacing": true,
      "details": "Clips are simpler to reason about: one carries where it starts reading in its pattern, how long it runs, and a tempo ratio between the pattern's clock and the song's. Past the end of the pattern it comes round again, always - so the resize mode is gone, along with the M key that switched it. Dragging a clip's top edge moves one end of what it plays; dragging the bottom edge changes its speed, and nothing else. Trimming a stretched clip keeps the stretch, and stretching a repeating clip keeps it repeating. Songs made before this are migrated exactly: a clip that used to play once and fall silent is shortened to the part that actually sounded."
    },
    {
      "title": "A repeating clip actually repeats its sample",
      "version": "2026.8.170",
      "date": "2026-09-08",
      "area": "Sound engine",
      "type": "Bug fix",
      "userFacing": true,
      "details": "A clip set to repeat held one endless note instead of starting its content again: the note that comes round each pass is the same note, so nothing downstream could tell a new pass from the old one continuing, and a sample played once and then ran out. Each pass is now a note-on, so the sample restarts, envelopes re-attack and the clip loops for as long as you drag it - including a stretched clip, which comes round on its stretched length. It looked right on some channels only because the renderer's cached-bar path was looping the audio by hand behind the scenes."
    },
    {
      "title": "Stretching a clip no longer switches its looping off",
      "version": "2026.8.170",
      "date": "2026-09-08",
      "area": "Sequencer",
      "type": "Bug fix",
      "userFacing": true,
      "details": "Dragging a clip's stretch handle used to also set it to play once, back when stretch was a mode rather than a stored length. The result was that a clip stopped repeating - and stopped drawing the notches that mark each pass - because of a gesture about speed. Stretch and trim now leave the mode alone: it is only the loop switch, and M is what changes it."
    },
    {
      "title": "Stretch a clip, trim it, and it stays stretched",
      "version": "2026.8.170",
      "date": "2026-09-08",
      "area": "Sequencer",
      "type": "Bug fix",
      "userFacing": true,
      "details": "A clip's stretch is now its own property rather than something worked out from its length, so the two gestures stop fighting. Stretch a one-bar sample across two bars and then trim a bar off its front, and the bar that is left plays the second half of the sample, still at half speed - it used to either snap back to full speed or fall silent, because the stretch factor was recalculated from whatever length the trim had left behind. Everything downstream reads the stored factor: the notes, the sample fit, the block's waveform and the piano-roll playhead. Existing songs are migrated exactly and play as they did."
    },
    {
      "title": "Trim on the top edge of a clip, stretch on the bottom",
      "version": "2026.8.170",
      "date": "2026-09-08",
      "area": "UI",
      "type": "Improvement",
      "userFacing": true,
      "details": "The two halves of a clip's edge are the other way round: the top half of either end trims or extends the clip, the bottom half stretches its content. Making a clip longer is the gesture people reach for constantly and it now lives on the edge people reach for first; the pointer still says which half you are in before you press. The M key switches the selected clips between repeating their content and playing it once - the old third mode is gone, since 'crop' and an unstretched 'stretch' were the same thing."
    },
    {
      "title": "A clip trimmed off its own start plays from where it was trimmed",
      "version": "2026.8.170",
      "date": "2026-09-08",
      "area": "Sound engine",
      "type": "Bug fix",
      "userFacing": true,
      "details": "Trimming the front of a sample clip on a wave channel restarted the sample from its beginning, turning the trim into a move. The channel-level route that carries a note's start position into the player is now added with the trim that needs it, in the same undo step, so the clip picks the sample up where its new edge lands. Channels that never get trimmed keep their offset knob, which is why the route is not wired by default."
    },
    {
      "title": "One way for a loop to reach a song, and it fits as it plays",
      "version": "2026.8.169",
      "date": "2026-09-07",
      "area": "Sound engine",
      "type": "Internal",
      "userFacing": false,
      "details": "A loop used to reach a song two ways: as a clip that named the loop directly, whose PCM the renderer copied into the bar, or as a wave-bank sample played by a pattern. The first way is gone.\n\nIt had to be. That clip copied audio, so everything about it was decided when the loop was decoded: the loop was stretched onto the bar grid once, at the tempo of the moment, and a stretched clip needed a whole second pre-rendered copy per length before it made a sound at all. Change the tempo and none of it followed. A pattern on a wave-bank channel fits the same loop as it plays, so it follows the tempo, the clip's stretch and the note's pitch, and there is one decoded copy of a loop instead of three.\n\nEvery legacy import already converted to that shape, so imported songs are unaffected. What changes: a project saved before this that still holds old-shaped clips plays those channels silent, and dropping a loop onto such a channel now says so rather than accepting a drop that would not sound. Two caches went with it - a fitted copy of every loop, and the pre-stretched variants - along with the background pump that computed them."
    },
    {
      "title": "Stretching a clip now stretches its sample",
      "version": "2026.8.168",
      "date": "2026-09-07",
      "area": "Sound engine",
      "type": "New feature",
      "userFacing": true,
      "details": "A clip edge has two halves - the top stretches, the bottom crops - and on a sample they did the same thing. Stretch mode slowed the pattern's clock so the notes spread out, and the audio carried on at its own rate. The two pointers promised a difference there was no way to hear.\n\nNow a stretched clip's bars are longer for the sampler too: a one-bar loop dragged out over two bars plays at half speed and fills them, and with pitch preservation off it drops in pitch, exactly as pulling a record down would. Drag it back and it comes back. Crop still reveals more or less of the sample without re-timing anything.\n\nThe stretch is handed to the player as a property of the CLIP rather than of the note under it. Fitting the sample to its note was the obvious alternative and quietly breaks sliced patterns, whose note is one bar of a four-bar loop - \"fill this note\" would cram the whole loop into it. Nothing unstretched changes by a single sample, imported songs included."
    },
    {
      "title": "A note now reports the song time it fills, not the time it claims",
      "version": "2026.8.167",
      "date": "2026-09-07",
      "area": "Sound engine",
      "type": "Bug fix",
      "userFacing": false,
      "details": "The note-length signal a sampler reads was measured in the PATTERN's beats. A stretched clip runs its pattern's clock slower, so its notes fill more of the timeline than they claim - and anything fitting a sample to a note was being told the unstretched figure, which would have made stretching a clip change nothing about how its sample plays.\n\nIt is now the song time the note actually occupies. Nothing reads it yet by default, so nothing changes audibly; it means the signal is right for when something does.\n\nFound while chasing why the stretch half and the crop half of a clip edge sound identical on a sample. They still do - the remaining half of that is a design question about what a sample pattern's note length should mean, which is written up rather than guessed at."
    },
    {
      "title": "Pointer size is a slider now, 50% to 200%",
      "version": "2026.8.166",
      "date": "2026-09-07",
      "area": "UI",
      "type": "Improvement",
      "userFacing": true,
      "details": "Display preferences had a list of pointer sizes; it is a slider, matching the display-scale row above it, and it moves the live pointer as you drag rather than waiting for Apply - a size control you cannot see the result of while setting it is a guess.\n\n50% to 200%. Below half the art stops reading as a shape, and above double it is being scaled UP rather than down, which looks like exactly what it is."
    },
    {
      "title": "Clip edges: stretch is on top now, crop underneath",
      "version": "2026.8.166",
      "date": "2026-09-07",
      "area": "Sequencer",
      "type": "Improvement",
      "userFacing": true,
      "details": "The two stacked grab zones at each end of a clip have swapped. The top half now stretches - the same content over a different span - and the bottom half crops, showing more or less of the pattern without stretching anything. They went in the other way round, matching how the feature was described; using it settled that the described order was the wrong one."
    },
    {
      "title": "The pointer stays put when you switch away from ezmuze",
      "version": "2026.8.166",
      "date": "2026-09-07",
      "area": "UI",
      "type": "Bug fix",
      "userFacing": true,
      "details": "Alt-tab away and the drawn pointer jumped to the top-left corner, which looked for all the world like something had grabbed the mouse. An unfocused window reports a mouse position of (0,0), and we were still drawing a pointer at whatever it said.\n\nThe pointer now goes back to the system whenever it is not ours to draw - while the window is unfocused, and before the cursor atlas has loaded. An unfocused window has no business drawing a pointer anyway: the real one is over whatever you switched to. Nothing was ever moving the actual mouse; it was our drawing of it."
    },
    {
      "title": "The pattern render cache no longer grows for the whole session",
      "version": "2026.8.165",
      "date": "2026-09-07",
      "area": "Sound engine",
      "type": "Performance",
      "userFacing": false,
      "details": "The repository keeps completed pattern renders by content, so a checksum that comes back does not have to be rendered again. It was cleared only when the song changed, which meant every distinct checksum rendered in a session was kept for the session: edit a pattern ten times and ten bodies stayed, nine of which nothing could ever look up again because the checksum that would find them no longer existed on any pattern. It was the largest single line in the memory report - 96 MB across 32 entries on The Dark.\n\nIt now has a 32 MB budget and evicts least-recently-used, with a hit counting as a use so the thing being reached for is not the next thing thrown away. Measured on the same song: 96.4 MB across 32 entries becomes 16.9 MB across 13.\n\nDeleting it outright was the plan, since importing a legacy song no longer creates the duplicate patterns it was built to absorb. It survives because it has a second job that is not going away: it is also consulted when a pattern's checksum CHANGES, which is what makes undo after a note edit instant instead of a re-render. Bounding it keeps that and loses the leak.\n\nIt is a convenience rather than the audio: what actually plays lives in the renderer's own store keyed by pattern, and evicting here only drops a second reference to the same arrays."
    },
    {
      "title": "Importing a legacy song creates a third as many patterns",
      "version": "2026.8.164",
      "date": "2026-09-07",
      "area": "Core",
      "type": "Performance",
      "userFacing": false,
      "details": "Migration mints one pattern per BAR, because a bar is where a legacy clip's TriggerFX and slice offset live and those have to stay per-bar in the playback graph. But most bars of a legacy channel repeat with no variation, so most of those patterns were byte duplicates. Across the 154-song corpus that is 19,389 patterns where 6,645 distinct ones exist - two thirds were copies.\n\nThey are no longer created. After each bar's pattern is finished, an identical one already built for that channel is reused instead. The checksum the render cache already computes narrows the search and note-by-note equality decides, because this deletes model data where the cache only reused a render: a wrong hash hit there costs a re-render, here it would be a song that is silently wrong for ever. There were no collisions to find, which is the reassuring result rather than a reason to skip the check.\n\nOne thing had to change with it. Sharing a pattern means sharing its note IDs, and a note-on is a change of note id - so a run of identical bars would have fused into one long note that never restarts. A new CLIP now always retriggers, whatever pattern it points at, because two placements are two performances. Without that the corpus A/B harness moved; with it, all four golden songs are identical to before.\n\nThis is also the root of what the repository's content-addressed render cache and its in-flight dedup were built to paper over."
    },
    {
      "title": "Imported songs stop working after a pitch effect - fixed same day",
      "version": "2026.8.164",
      "date": "2026-09-07",
      "area": "Core",
      "type": "Bug fix",
      "userFacing": true,
      "details": "Earlier today the wave-bank device's internal pitch-compensation wire changed source, from the player's rate output to its new fitRate one. The legacy importer builds its sampler from that same device and then REMOVES that wire, because it drives the pitch shifter from an automation lane instead - and it removed it by matching the old port name. So the wire survived, and a wired port outranks its lane: every imported song's pitch effects (PitchDown, PitchUp, NativePitch) were silently ignored.\n\nIt now unhooks the wire by either name. Caught by diffing the corpus A/B parity harness rather than by anything failing - the harness reports numbers and always passes, so a regression is only visible if somebody compares. Two of the four golden songs had moved: 0.0618 to 0.0738 and 0.0108 to 0.0148 RMS against legacy playback. Both are back to their old values exactly."
    },
    {
      "title": "A new wave channel keeps its pitch when the tempo moves, if the song asks for it",
      "version": "2026.8.163",
      "date": "2026-09-07",
      "area": "Sound engine",
      "type": "Improvement",
      "userFacing": true,
      "details": "Fitting a sample to the song's tempo is a resample, so it moves the pitch. A song already carries an answer to whether that should happen - \"Preserve pitch when the tempo changes\", on by default for new songs - and a wave channel created in a song that asks for it now switches on the sampler's own pitch compensation, which undoes the fit's share of the read rate and leaves everything else alone.\n\nIt is off by default on a bare device, because it costs a phase vocoder per voice and the answer is a per-song choice rather than a universal one.\n\nOnly newly created wave channels. Legacy imports build their sampler through a different path with a measured A/B parity harness behind it, and that one is left alone until the same change can be run against that harness."
    },
    {
      "title": "Pointer size is a preference",
      "version": "2026.8.163",
      "date": "2026-09-07",
      "area": "UI",
      "type": "Improvement",
      "userFacing": true,
      "details": "Display preferences gains a pointer size - 75% through 300%. It has to exist because hiding the system cursor also hides the system's own cursor-size setting, which is what people who want a big pointer normally use; with our own pointer drawn instead, that setting no longer reaches it.\n\nPercentages rather than a slider, because there is no useful precision in the question - somebody wants a bigger pointer, not a 137% one. The value itself belongs to GustUI, which is what draws the thing; this is only where the app remembers the choice and pushes it in, including at boot so a saved preference is in force from the first frame."
    },
    {
      "title": "Clip edges have four zones now: crop on top, stretch underneath",
      "version": "2026.8.163",
      "date": "2026-09-07",
      "area": "Sequencer",
      "type": "New feature",
      "userFacing": true,
      "details": "Each end of a clip has two grab zones stacked on each other - 5 pixels wide whatever the zoom, half the row height each. The top half crops: the clip shows more or less of its pattern, from further in or further back, and nothing is time-stretched. The bottom half stretches: the same content over a different span, which changes the fit and so the speed and the pitch correction with it.\n\nThe pointer is what tells them apart. Nothing is drawn to distinguish the halves - two stacked slivers five pixels wide would be decoration nobody could read - so hovering the top gives a trim cursor and the bottom a stretch one, and it changes before you press rather than after. Five pixels in real pixels, at every zoom: a grab zone measured in bars would be unusable zoomed out and absurd zoomed in.\n\nThe visible edge marker narrowed from ten pixels to five to match, so what you can see and what you can grab are now the same width."
    },
    {
      "title": "The mouse pointer is ours now, and there are 200 of them",
      "version": "2026.8.162",
      "date": "2026-09-07",
      "area": "UI",
      "type": "New feature",
      "userFacing": true,
      "details": "A DAW wants dozens of pointers - trim, stretch, razor, scrub, fade, pencil - and the system cursor set has about ten, none of which say \"you are on the front edge of a clip and dragging will crop it\". So the system cursor is hidden and the pointer is drawn as a sprite, which makes a new one cost an atlas cell rather than a platform API.\n\nGustUI draws it and owns none of the art; the atlas and the names live here, because what a DAW's pointers should look like is an ezmuze question and not a toolkit one. Requests last one frame and clear themselves, so something that stops asking stops getting it.\n\nThe sheet ships as wwwroot content rather than through the content pipeline - that would mean the same PNG committed into three heads' Content directories, three .mgcb entries, three manifest hashes and a rebuild needing the forked MGCB, for an asset Texture2D.FromStream loads on every head including the browser. It is stored at a quarter of the artwork's resolution, which is 2 MB of texture instead of 31 and still oversampled for a hidpi pointer, and premultiplied in the file because the geometry batch blends premultiplied and a straight export would give every soft edge a dark halo.\n\nThe system pointer stays on until ours has actually loaded, so the window is never left with no pointer at all; if the atlas fails to load the app simply keeps the system one.\n\nOne cost worth knowing: a drawn cursor lags the real pointer by a frame. Under ten milliseconds at the frame rates this runs at, and invisible in practice - but it is the first thing to blame if the pointer ever feels heavy."
    },
    {
      "title": "A sample can be fitted to the note that plays it",
      "version": "2026.8.161",
      "date": "2026-09-07",
      "area": "Sound engine",
      "type": "New feature",
      "userFacing": true,
      "details": "gen.wavebank's Fit control gains a third setting. Off plays a sample at its own rate and Tempo fits it to the bar grid, as before; Note fits it to the length of the note playing it, so making the note longer stretches the sample to match instead of leaving a gap after it.\n\nThat needed a signal that did not exist. The notes source carried pitch, gate, trigger, velocity and offset, and nothing about LENGTH - so a sampler had no way to know how long the note it had just been handed lasts. It now publishes one, in output frames rather than beats, because what reads it wants to divide a sample's length by it and frames need no tempo to interpret. Held per note like velocity and offset are, and forwarded to a device only when that device's own graph asks for it, which is the same rule the offset signal follows.\n\nA note picked up part-way through - a clip whose front has been cropped - reports the length it has LEFT, so the sample fills the time actually available rather than overrunning.\n\nThe rate is a read increment in source samples per output frame, so fitting is simply their ratio: a sample twice as long as its note reads at 2, half as long at 0.5. Speed still multiplies on top, and a length of zero means \"no note length known\" and leaves the tempo fit in charge - so an unwired length input cannot change how anything already sounds.\n\nAppended throughout: the Fit control's existing 0 and 1 still mean Off and Tempo, and every existing device and song is untouched."
    },
    {
      "title": "Cropping a clip's front now plays from further in, not from the start again",
      "version": "2026.8.160",
      "date": "2026-09-07",
      "area": "Sequencer",
      "type": "New feature",
      "userFacing": true,
      "details": "Drag a pattern clip's left edge a quarter of the way in and it now begins a quarter of the way through its pattern, with a note spanning that point already a quarter sounded. It used to simply restart the pattern at the new position, which made the crop a move rather than a crop.\n\nLoop clips have always worked this way - their Slice shifts as the front is dragged, at the loop's own quarter granularity. Pattern clips now do the same in ticks, which is the follow-up docs/clip-fine-grid-design.md deferred under \"Known limitation: pattern clips\".\n\nBoth playback paths honour it, and that is the part that had to be right: the live path adds the crop inside the shared pattern-position function that the note compiler and the channel header's voice meter both use, and the cached path reads the pattern body from that frame instead of the top of a bar - wrapping for a Repeat clip, running into silence for a Crop one. A bar can now straddle the body's loop point, which the old whole-bar copy could not express. Verified by placing a click at a known frame in a body and checking which frame it comes out at.\n\nA note the crop lands inside is picked up part-way through rather than retriggered: its elapsed fraction goes out on the offset signal, which is what a sample player latches to start reading part-way in.\n\nThe same shift applies when a selection splits a clip, when the trim helper cuts one, and when the clipboard takes the back half of one and pastes it. Nothing changes for a clip whose front has never been cropped."
    },
    {
      "title": "Pitch-compensated tempo fitting no longer cancels the note's own pitch",
      "version": "2026.8.159",
      "date": "2026-09-07",
      "area": "Sound engine",
      "type": "Improvement",
      "userFacing": false,
      "details": "Fitting a sample to the song tempo is a resample, so it moves the pitch, and the + Wave device has always been able to put it back: gen.wavebank publishes its read rate on an output wired to the pitch shifter after it, and switching that shifter on shifts by the inverse. Measured yesterday at 440 Hz in and 440.0 Hz out where an uncompensated fit gave 572.0.\n\nThe problem was what else it undid. That rate carried the WHOLE playhead speed, key tracking included, so compensating a sample played a fifth up dragged it back down to the raw sample pitch - 440 Hz where a preserved fifth is 659. Nothing could be both fitted to tempo and played at a pitch you chose.\n\nThe player now publishes a second output, fitRate, carrying the tempo-fit component alone, and newly built devices feed the shifter from that. A fitted sample keeps its fifth (measured 659.5 Hz against a theoretical 659.26), and speed stays out of the compensation too, which makes it a varispeed control rather than a time-stretch one - deliberately chosen rather than fallen into.\n\nDevices built before today keep the wire they were built with and are unaffected; their shifter is off by default in every case. Nothing audible changes until something switches compensation on."
    },
    {
      "title": "Pinned what fit-to-tempo does to pitch, and what compensating it costs",
      "version": "2026.8.159",
      "date": "2026-09-07",
      "area": "Sound engine",
      "type": "Internal",
      "userFacing": false,
      "details": "Fitting a sample to the song's tempo is a resample, so it moves the pitch - by a ratio the device already knows. gen.wavebank publishes its effective read rate on a rate OUTPUT, wired to the fx.pitchshift after it, so switching that shifter on shifts by exactly the inverse and playback becomes pitch-neutral. That was described in a comment and is now measured: a 440 Hz tone fitted from 2.6 bars to 2 plays at 572.0 Hz with the shifter off and 440.0 Hz with it on.\n\nThe second test pins the catch. The rate output carries the WHOLE read rate, key tracking included, so compensating cancels the musical pitch along with the tempo fit: the same tone played a fifth up reads 857.0 Hz uncompensated and 440.0 Hz compensated - not the 659 Hz a preserved fifth would be. Anything that wants a fitted sample to still respond to note pitch, such as a bend or a lane, needs the fit component published separately from the musical one.\n\nNo behaviour changed; both facts are now tests rather than comments. Context on bug-board #221."
    },
    {
      "title": "Design note: the six PCM caches are three layers, and one path too many",
      "version": "2026.8.159",
      "date": "2026-09-07",
      "area": "Sound engine",
      "type": "Internal",
      "userFacing": false,
      "details": "docs/channel-pcm-design.md gains the layering, prompted by the question \"aren't all but patternBodies and patternTails redundant?\". Nearly. bankDecoded and loopsNative are decoded source material a generator reads; patternBodies and patternTails are what a generator produced. In between sit loopsFitted and stretchVariants, serving a LoopClip that has no pattern and is played by indexing four-bar-fitted PCM rather than by rendering a generator - a second implementation of \"play audio at a position\".\n\nThe direction, tracked as bug-board #221: a sample dropped on the timeline becomes a pattern on a gen.wavebank channel, and stretching it means fitting the sample to the LENGTH OF THE NOTE, with the clip's edge still meaning Repeat or Crop. That lands stretched audio in patternBodies with no special case, because a note's length is already part of the pattern and the pattern's notes are already hashed into the body checksum.\n\nTwo findings, one of them backwards from what you would guess. Imported legacy songs are ALREADY the modern shape - LegacyChannelMigration converts every legacy loop clip into a per-bar pattern on a gen.wavebank channel, and 154 corpus songs exercise it; it is the NATIVE drag-a-loop-from-a-pack path that still makes bare LoopClips. And the missing pieces are small: there is no note-length signal on the notes source (pitch, gate, trigger, velocity and noteOffset exist), and gen.wavebank's fit input means fit-to-tempo, where fit-to-note is the same arithmetic with the note's frames as the denominator."
    },
    {
      "title": "A pattern's ring-out now goes through the channel's effects, live",
      "version": "2026.8.157",
      "date": "2026-09-07",
      "area": "Sound engine",
      "type": "Improvement",
      "userFacing": true,
      "details": "When a pattern stops, its decay tail keeps sounding. That tail was CACHED with the channel's whole effect chain baked into it - the one render in the app that did - and playback then mixed it in after the chain rather than through it.\n\nIt is the generator's ring-out only now, and the channel's effects process it on the way out like they process everything else. The reasoning is that a reverb inside the generator's own module graph rings out and IS generator output, so a generator-only render captures it; a reverb further down the chain does not need to be in the cache at all, because the chain runs live over whatever the cache provides - hand it the raw ring-out and it makes its own, at the settings in force right now.\n\nWhat that fixes and removes: turning a downstream effect dial used to invalidate and re-render every tail on that channel, at exactly the moment you were turning it - now it re-renders nothing and you hear the change immediately. The post-chain mixing machinery, the three fields that existed to defer it, and the rule that withheld a tail whenever any downstream device was still ringing are all gone; the only question left is whether the generator itself is live, which is the only thing that could double it.\n\nOne honest trade: seeking straight into a tail runs it through a reverb whose own state is cold, where the old cache held that reverb's accumulated energy. The seek point is a little less accurate, and it is the same approximation the pattern substrate already makes everywhere else.\n\nTails cached by an older build carry a scope salt in their checksum, so they fail to match and re-render rather than being served as generator audio they are not."
    },
    {
      "title": "Design for a channel PCM window, so NRT effects can read beyond their region",
      "version": "2026.8.156",
      "date": "2026-09-07",
      "area": "Sound engine",
      "type": "Internal",
      "userFacing": false,
      "details": "docs/channel-pcm-design.md - design only, nothing implemented. A non-real-time TFX effect can currently read only its own region, so there is no reverse-the-next-bar-into-this-one, no riser that pulls from what is coming, no lookahead gate. The narrow fix is a bounded read-ahead parameter; the better one is a channel that can be asked \"give me N frames starting at song frame F\", which makes read-ahead a consequence rather than a feature and serves per-channel overviews, freeze and single-channel export as well.\n\nIt allocates nothing: every frame it can serve already exists in one of the renderer's six PCM stores, and the song-frame-to-pattern-body mapping is already written and tested as TfxSampleLane.BodyIndexForSongFrame. The whole design is the coverage contract, because a frame with no samples has three different meanings - blank, unavailable, and not-rendered-yet - and answering the third with zeros would make a song play differently on a cold cache than a warm one. So the call reports coverage rather than just samples, the way TryGetPatternBodyPcm already reports NOT READY rather than serving stale audio.\n\nOne of the two open questions is now closed: pattern release TAILS stay out of the window. PatternTailRenderWorker builds its graph with generatorOnly:false - it is the one cache that renders downstream effects - and playback mixes it into Work AFTER the chain has run, additively, only on a seek or cold start where the live graph has no ring-out of its own. Serving those frames as generator output would mislabel post-effect audio and then let the channel's effects run over it a second time."
    },
    {
      "title": "Pinned what the legacy \"On\" effect actually plays",
      "version": "2026.8.156",
      "date": "2026-09-07",
      "area": "Core",
      "type": "Internal",
      "userFacing": false,
      "details": "\"On\" is a record starting up - it begins near a standstill and accelerates to full speed on the next beat. That is not obvious from the code, which reverses the whole bar buffer, reads it through a DEcelerating head, and then reverses its own output: three inversions that cancel down to an acceleration. Reasoning about them rather than measuring them is how the importer came to refuse it for months.\n\nChecked against the original ezmuze3 source, which the port matches byte for byte, and now measured on real frames instead of derived. At 120 BPM it plays source frames 79,304 to 88,192 - the bar's last fifth of a second - stretched across the whole quarter, at a rate rising from 0.110 to 1.000. And it plays the same material whatever quarter it sits on, because it reads the bar copy from absolute zero after reversing it; the quarter decides only where the result is written. That last fact is the one that says nothing stacked before it can be heard."
    },
    {
      "title": "Every legacy TriggerFX quarter now imports as notes and automation",
      "version": "2026.8.155",
      "date": "2026-09-07",
      "area": "Core",
      "type": "Improvement",
      "userFacing": true,
      "details": "Coverage across the 154-song local corpus is 100%: all 4,909 populated quarters become notes and lanes, with nothing left for the runtime TriggerFX path or the baker to fill.\n\nThe last six were stacks containing \"On\", refused because On reverses the whole bar buffer and never restores it. Two separate claims were being made about that, and only one is true. On is NOT coupled to what runs before it in its own quarter: its read head decays by 0.9999 per sample, so it covers at most 10,000 samples whatever the tempo, and reaching the copy of its own quarter would take three times the quarter length - 39,690 samples even at 200 BPM. It never gets there, so what it plays is the bar's original tail and nothing else, and it replaces its quarter exactly like a Segment does.\n\nWhat is true is that org stays reversed for everything after it, so a later Segment reads the bar backwards from a mirrored position. That is expressible - a note with the reverse flag - but it has not been worked out, because across the corpus's 58 bars containing On, nothing later in the bar reads org at all. The translator carries one bar-level flag and refuses that shape rather than approximating it; the baker stays as the escape hatch for a song outside the corpus that hits it.\n\nThe A/B parity harness is unchanged on all four golden songs, none of which contain the shape."
    },
    {
      "title": "Imported songs play stacked TriggerFX as one sound, not several at once",
      "version": "2026.8.154",
      "date": "2026-09-07",
      "area": "Core",
      "type": "Bug fix",
      "userFacing": true,
      "details": "A legacy quarter can carry two or three TriggerFX at once, and legacy runs them one after another over a single buffer - so each one works on what the one before it produced, and what you hear is what the last one leaves behind. The importer modelled that as a sum instead, emitting a note per effect: \"Segment4 + PitchDown\" played the segment AND the straight quarter simultaneously, on top of each other, rather than the segment shifted. That was 187 of the 214 stacked quarters in the 154-song corpus, and nothing reported it - they were not in the untranslated set, and the A/B parity harness averages a whole song.\n\nA quarter is now an arrangement - the pieces of sample that play across it, each with an offset, a direction and a rate curve - and every effect is a function from one arrangement to the next. Which of legacy's two buffers an effect reads decides which: Segment, ShiftSegment, Wiki and the DoubleUp/Down family read the untouched copy and so REPLACE the quarter; Retrigger, Shuffle and Reverse read the working buffer and rearrange it; the pitch family, the rate ramps, Stretch, Trance and InPlace leave the arrangement alone.\n\nTwo old limits fell out of that on their own. A rate ramp that is not last composes like any other modifier (a retrigger over a tape stop replays the ramp's opening, which is what legacy does). And two effects that both set a rate MULTIPLY - the composition happens on the curve, before the lane that holds one value per beat. Quarters the importer cannot express drop from 27 to 6, all six of them stacks containing \"On\", which reverses the whole bar and never restores it.\n\nMeasured against the corpus A/B harness: the song with the most TriggerFX channels improves (RMS delta 0.0670 to 0.0618, peak 0.68 to 0.64), two are unchanged, and one moves by 0.00001. LegacyTriggerFxCoverageTests now re-derives the coverage number from the corpus on every run rather than it being worked out by hand and written down."
    },
    {
      "title": "Up to eight TriggerFX at once on a lane, stacked like legacy",
      "version": "2026.8.153",
      "date": "2026-09-07",
      "area": "Sequencer",
      "type": "New feature",
      "userFacing": true,
      "details": "A TFX lane held one effect at any instant: dropping a region over another trimmed or split whatever was already there. Legacy stacked them - a quarter could carry several TriggerFX at once, applied in order - and now a lane can too, up to eight deep.\n\nALT-drag on the strip stacks another region over the one under the pointer; a plain drag still picks that one up to move or resize it, because that is what you are pointing at. Right-click removes the top layer, one press at a time. A ninth layer at the same instant is refused rather than replacing anything.\n\nConcurrent regions share the row the way legacy drew them: one fills the band, two take half the height each at 0% and 50% down, three take a third at 0%, 33% and 66%. The layer drawn highest is the first the audio meets. A stacked layer is too short to carry its four-character code, so every block now has a tooltip naming its effect and its place in the stack.\n\nBoth kinds of lane stack. Real-time slots open together on the same beat; non-real-time sample effects chain, each one reading what the layer below it produced rather than all of them reading the same dry bar."
    },
    {
      "title": "Mouse gestures can hold a modifier for the length of the drag",
      "version": "2026.8.153",
      "date": "2026-09-07",
      "area": "Desktop",
      "type": "Internal",
      "userFacing": false,
      "details": "The control API's /click and /mousedown now take \"alt\", \"ctrl\" and \"shift\", held across every /mousemove until the click or the /mouseup lets go. The scroll endpoint already had a one-frame version of this; a drag is many frames and the handler reads the modifier on all of them, so an Alt-drag - the sequencer's new stack-another-TFX gesture - was not drivable from the API at all. A modifier sent as its own /key request has been released long before the press arrives.\n\nA gesture checked with Keyboard.GetState() cannot see synthetic input either, so the sequencer reads InputManager.CurrentKeyboardState for it, which is the same thing for a real keypress."
    },
    {
      "title": "The importer composes stacked legacy effects instead of giving up on them",
      "version": "2026.8.152",
      "date": "2026-09-07",
      "area": "Core",
      "type": "Improvement",
      "userFacing": false,
      "details": "A stack of legacy TriggerFX where the LAST effect was Stop, Slow or Forward used to be marked unusable, on the grounds that those three write into a shared buffer the others read. Reading them properly, they all do the same thing: copy whatever is in the quarter right now - the previous effect's output - and replay it under an exponential rate ramp. That is not shared state, it is a speed curve laid on top of what came before, and the importer already emits both halves when either effect appears alone.\n\nSo the trailing ramp now contributes its curve to the note the earlier effect emitted rather than adding one of its own. Measured across the local corpus - 154 songs, 4,909 populated quarters - the quarters the importer cannot express drop from 38 to 27, and the corpus A/B parity harness passes unchanged.\n\nStill excluded, deliberately: a ramp that is not last (something after it reads its output), two effects that both write the speed lane, and \"On\", which reverses the whole bar and never puts it back."
    },
    {
      "title": "Two more legacy effects import as real devices instead of falling back",
      "version": "2026.8.151",
      "date": "2026-09-07",
      "area": "Core",
      "type": "Improvement",
      "userFacing": false,
      "details": "The .eez importer translates legacy per-quarter TriggerFX into notes, lanes and devices. Two of them - InPlace's flanger and chorus - were excluded on the recorded grounds that the module registry's flanger and chorus were \"different algorithms and would not sound like them\". That was wrong: FlangerEffectState wraps LegacyFlanger and ChorusEffectState wraps LegacyChorus, the same classes the legacy path itself runs, with a mix control on top.\n\nThey now import as fx.flanger / fx.chorus devices with a mix lane gated open for their own quarter, the same shape InPlace's clipper already used. That takes the quarters the importer cannot express from 108 to 76 across the 140-song corpus. The corpus A/B harness passes unchanged, which is what settles the one real difference between them - legacy shares its effect state across both stereo sides and every channel, a device does not."
    },
    {
      "title": "Dragging a panel off a dock now looks like it came off",
      "version": "2026.8.150",
      "date": "2026-09-07",
      "area": "UI",
      "type": "Improvement",
      "userFacing": true,
      "details": "A docked panel filled its edge of the screen, and its resize splitter sits on the same line you grab to move it - so pulling a panel off the dock and dragging the splitter looked the same for the first moments of the gesture, and they do very different things.\n\nA panel now changes size as it comes off. If it was floating before it docked it goes back to the size it was then, which may be taller than the dock rect was; one that has only ever been docked just gets a little smaller. Either way it stops being edge-to-edge, which is the tell. Where you grabbed the title bar is kept as you drag, so the bar does not slide out from under the pointer as the window narrows."
    },
    {
      "title": "Songs that played their synths live now play them from cache",
      "version": "2026.8.149",
      "date": "2026-09-07",
      "area": "Sound engine",
      "type": "Performance",
      "userFacing": true,
      "details": "A pattern can be heard from PCM rendered once in the background instead of running its generator every bar. Two things were stopping that from happening on real songs.\n\nReopening a saved song restored every block's picture from the saved render cache and never asked for the AUDIO, which is not saved with it - so every channel that qualified ran its generator live for the whole session and nothing said so. And a clip only qualified at all if it was set to Repeat, which excluded the ordinary one-pass clip most songs are made of.\n\nA one-pass clip that fits inside its own pattern is the same sound either way, so it is cached now. A Crop clip LONGER than its pattern still plays live - past the end it needs the synth's release tail ringing out, which a fixed render cannot express - and Stretch never qualifies.\n\nMeasured on a real 172 BPM song with seven channels, at the same bar: 44 to 95 fps, and its heaviest synth went from 44% CPU to 1%. The cost is memory - about 86 MB of held audio on that song - so a \"low memory\" setting that turns the whole thing off is the next thing this wants."
    },
    {
      "title": "Every generator says whether it is costing you anything",
      "version": "2026.8.148",
      "date": "2026-09-07",
      "area": "Sequencer",
      "type": "New feature",
      "userFacing": true,
      "details": "A pattern clip can be heard from PCM rendered once in the background instead of running its generator every bar, which is most of the difference between a song that costs 3% and one that costs 30%. Nothing said which was happening.\n\nThe Stack now puts a dot before a generator's name: green when it is being heard from cached PCM and not running at all, orange while that render is in flight, red when it is being processed live. And a pattern having its PCM rendered fills the bottom two pixels of its block in the sequencer, in green, in proportion to how far it has got.\n\nTurning a knob on a generator WHILE THE SONG PLAYS now takes it off the cache for ten seconds instead of re-rendering. A knob drag streams an edit per frame, and each one used to queue a whole-pattern render - sixty a second, every one thrown away before it finished, all of it competing with the audio for the same CPU. The window slides forward while you keep adjusting and rebuilds once when you stop. Stopped, nothing changes: the block face follows the knob as it always did."
    },
    {
      "title": "A project on disk can be opened through the control API",
      "version": "2026.8.148",
      "date": "2026-09-07",
      "area": "Services",
      "type": "New feature",
      "userFacing": false,
      "details": "POST /action {\"name\":\"open_project\",\"path\":\"...\"}. File > Open is a native picker, which is why plain \"open\" was never an action - but the picker is the only part that cannot be scripted, and refusing the whole verb meant a real song on disk could never be put in front of the app from a test. Everything past the picker is the same call it makes."
    },
    {
      "title": "The release stamp cannot land on the wrong change-log entry",
      "version": "2026.8.147",
      "date": "2026-09-07",
      "area": "Build",
      "type": "Bug fix",
      "userFacing": false,
      "details": "The pipeline resolves \"NEXT\" to a real version as a one-line change, and adding an entry at the top of the file is an insertion directly above one. Every entry began with a bare \"{\", so every version line had identical context and git's three-way merge was free to anchor the insertion a few lines out - landing the resolved number on the NEW entry and resetting the entry that actually shipped back to NEXT, with no conflict reported. It happened on all five builds of one session.\n\nEntries now lead with their title and carry the version on the next line. A title is the only line unique to an entry, so there is nothing left for an insertion to slide through. Measured both ways over a matrix of built-versus-local entry counts: every version-first case shifted, every title-first case came out right. ShippedChangelogOrderTests fails on the old shape and stamp-changelog.py re-normalises it, so it cannot rot back."
    },
    {
      "title": "A Lua script can use the endpoint paths as they are written down",
      "version": "2026.8.146",
      "date": "2026-09-07",
      "area": "Services",
      "type": "Bug fix",
      "userFacing": false,
      "details": "ezmuze.call(\"/window\", ...) answered 404: the HTTP server strips the leading slash on its way in and the in-app script host did not, so a path copied out of docs/remote-control-api.md - where every endpoint is written with one - reached nothing. Trimmed on the way in now."
    },
    {
      "title": "Hear a module before you decide, from its About panel",
      "version": "2026.8.146",
      "date": "2026-09-07",
      "area": "Modules",
      "type": "New feature",
      "userFacing": true,
      "details": "A published module carries a ten-second demo, rendered by the publish wizard, and the store shelf could play it. The About panel could not - which is the screen you actually read before deciding whether you want a module.\n\nIt has the same play button now, in the same corner of the art, and the two share one player: pressing play in one stops the other, and only the one that is sounding shows a pause icon. The button appears only for a module that has a preview, and the sound stops when the panel closes.\n\nThat was the last piece of the module store plan - publishing, licensing, remixing, preset packs, reviews and previews are all in now.",
      "tickets": [
        179,
        161
      ]
    },
    {
      "title": "The piano roll opens on your pattern, and edits a selection as one thing",
      "version": "2026.8.145",
      "date": "2026-09-07",
      "area": "UI",
      "type": "Improvement",
      "userFacing": true,
      "details": "The piano roll opened massively zoomed out with the notes jammed into a corner - it worked out its zoom before the window had sized anything, fitted a placeholder, and never looked again. It now opens with the pattern filling the grid, centred, with a few empty rows above and below, and it no longer scrolls past the bottom of the keyboard into empty space.\n\nThere is a bar and beat ruler above the grid now, like the sequencer's, and it follows zooming and scrolling.\n\nSelecting and moving notes: right-drag a box to select everything it touches; dragging any selected note now moves the whole selection with it, keeping its shape; Ctrl with the arrow keys nudges the selection a semitone or a beat, and Ctrl+Shift moves it an octave or a whole bar. At the edges a nudge refuses rather than flattening what it cannot move.\n\nAnd a gesture is one undo. Deleting thirty notes was thirty presses of Ctrl+Z to get back, through twenty-nine half-deleted states; it is one press now, and so is a drag however many notes it carried.",
      "tickets": [
        211
      ]
    },
    {
      "title": "A clip that loops shows it with a pinched outline, not stripes",
      "version": "2026.8.144",
      "date": "2026-09-07",
      "area": "Sequencer",
      "type": "Improvement",
      "userFacing": true,
      "details": "A clip longer than its pattern plays it again, and the timeline used to say so by shading every second pass a little darker. It read as stripes, and the shade fell over the waveform and the note preview as well as the block, so the content lost contrast every other bar for a reason nothing explained.\n\nEvery block now has a one-pixel outline in its own channel colour, brighter, with rounded ends - and where the pattern starts again the top and bottom edges pinch inwards to a small cusp, with nothing drawn across the block. It is the shape a row of butted rounded boxes would have, so a clip reads as a run of passes at a glance and its content is left alone.\n\nPasses narrower than about 18 pixels are not marked, same as before: below that the seams stop being information and turn into a comb.",
      "tickets": [
        212
      ]
    },
    {
      "title": "Closing an editor tab takes its toolbar with it",
      "version": "2026.8.143",
      "date": "2026-09-07",
      "area": "UI",
      "type": "Bug fix",
      "userFacing": true,
      "details": "Open a piano roll or a module editor as a tab of the sequencer, close it again, and its toolbar buttons stayed on the window's toolbar for the rest of the session - so the next view's buttons were drawn on top of a dead set at the same coordinates, \"Editor: Roll\" came out clipped to \"ditor: Roll\", and the leftovers were still clickable, still reaching the view you had closed. Close two editors and there were two dead strips.\n\nA tab now takes its toolbar down when it is closed, the way it already did when it was merged into another window or torn out into its own.\n\nThe same switch had a second half: the sequencer's own ruler and channel gutter were drawn across the top of whatever tab was showing, because they belonged to the window rather than to the sequencer's tab. They belong to the tab now and go away with it.",
      "tickets": [
        216
      ]
    },
    {
      "title": "Reading the app no longer waits for whatever is clicking it",
      "version": "2026.8.142",
      "date": "2026-09-07",
      "area": "Services",
      "type": "Bug fix",
      "userFacing": false,
      "details": "The control API ran one queued command at a time and a gesture owned every frame it lasted, so nothing else was answered until it finished. Invisible for a click, which lasts three frames; ruinous for a held hover, which lasts as long as it was asked to - a dwell exists precisely to make something appear WHILE the pointer is held, and the screenshot that would show it sat in the queue until the dwell ended and the thing had gone. Worse than an error, because the answer came back looking perfectly valid while describing a moment after the one asked about; it cost most of a session on the menu-dwell bug, where five identical \"the submenu did not open\" readings were really five readings taken too late. A GET changes nothing, so it is now answered at any point during a gesture. Anything that would change input state is set aside and run in order once the gesture ends, because two gestures interleaved is not a thing anybody means. Measured: a stats read taken one second into an eight-second dwell went from 7.1 seconds to 0.02.",
      "tickets": [
        213
      ]
    },
    {
      "title": "Removing a device twice no longer crashes the app",
      "version": "2026.8.142",
      "date": "2026-09-07",
      "area": "UI",
      "type": "Bug fix",
      "userFacing": true,
      "details": "The bin on a Stack card carried a device id captured when the card was built and ran the removal unconditionally, and the edit throws rather than shrugging when the device is not on the channel any more. So any path that delivered a second click for a card whose device had just gone took the app down - reported from the wild with a stack trace, through the pointer-capture path that needs both edges of a click inside one frame, which only a real mouse can produce. A removal that has already happened is not an error, it is the state you asked for, so the handler now does nothing. Guarded at the click rather than by making the edit lenient: an EDIT addressing a device that is not there is a genuine bug worth throwing over; a CLICK on a card that outlived its device by a frame is not.",
      "tickets": [
        214
      ]
    },
    {
      "title": "Publishing will not quietly drop a module to one voice",
      "version": "2026.8.142",
      "date": "2026-09-07",
      "area": "Modules",
      "type": "Bug fix",
      "userFacing": true,
      "details": "The publish wizard takes polyphony from the module, or from an instance of it in the open song, and when neither said anything it fell back to 1. Against a module the store holds at eight voices that makes the next version BREAKING - created by the absence of information rather than by any decision - and a breaking version pins every song using the module to the old one until its owner upgrades by hand. Three changes. The app now records what each published version claims, so a later publish has something to fall back on when the definition and the song are both silent. The wizard knows whether its number came from the module, from an instance, from that record, or from nowhere. And when it came from nowhere AND the store's own dry run says voices changed, publishing is REFUSED with the reason, rather than warned about in a list of eight other lines: set it in \"Save module as\" or on the Stack card, and it will publish whatever you chose.",
      "tickets": [
        210
      ]
    },
    {
      "title": "The app says when something is driving it, and how hard",
      "version": "2026.8.141",
      "date": "2026-09-07",
      "area": "Services",
      "type": "New feature",
      "userFacing": true,
      "details": "The app looked identical whether it was being driven remotely or not, so an automated run was indistinguishable from a haunting, and when a script and a person acted at once neither knew the other was there. There is now a toast when a caller arrives or leaves - \"MCP connected\", \"Remote control disconnected\" - and a readout in the status bar for as long as anything is driving it, naming the most significant caller and the command rate: \"MCP 119.0/s\", or \"MCP +1 119.0/s\" when there is more than one. GET /client lists everyone with their rate and total. Connected means two different things and is answered two ways: the MCP server is a long-lived process so it announces itself on start and on every exit route, which is exact, while a plain HTTP caller is one packet and gone and is inferred from having called in the last twenty seconds - a guess that flaps is worse than no readout. A caller can name itself with an X-Ezmuze-Client header rather than being guessed at. Found while measuring that rate: the control server bound IPv4 only, and \"localhost\" resolves to ::1 first on Windows, so every client that used the name rather than the address paid a failed IPv6 connection before falling back - two full seconds per call, against nine milliseconds on 127.0.0.1. It binds the IPv6 loopback now, which took localhost from 2.033s to 0.009s a call.",
      "tickets": [
        219
      ]
    },
    {
      "title": "The real mouse and keyboard can be locked out while something drives the app",
      "version": "2026.8.140",
      "date": "2026-09-07",
      "area": "Services",
      "type": "New feature",
      "userFacing": true,
      "details": "When the app is being driven remotely - a test, a script, Claude - a stray click or keypress from whoever is sitting at the machine lands in the middle of the run and breaks it, in a way that looks like a bug in the thing being tested rather than an accident. POST /input {\"enabled\":false} now ignores the real mouse and keyboard until it is turned back on, on the same gate an unfocused window already uses; ezmuze.lockInput() and ezmuze.unlockInput() do it from a Lua script, and set_input_enabled from MCP. Synthetic input is untouched, which is the point: the caller keeps driving a locked app. Because an app that ignores the mouse is indistinguishable from one that has hung, it fails safe three ways. The status bar says \"Input locked by the remote control - Ctrl+Alt+U to take it back\" in amber for as long as it lasts. That chord is read from the real keyboard BEFORE the gate, so the way out of a lock can never itself be locked out, and the app notices it was overridden rather than believing it still holds a lock. And if no remote endpoint of any kind has been called for a minute the lock lifts itself, on the assumption that whatever set it has crashed, disconnected or forgotten - a working script's own calls keep it alive, so only a caller that has actually gone quiet loses it.",
      "tickets": [
        218
      ]
    },
    {
      "title": "Lua scripts can drive the app from inside it",
      "version": "2026.8.140",
      "date": "2026-09-07",
      "area": "Services",
      "type": "New feature",
      "userFacing": false,
      "details": "The remote-control API and the MCP server can now load and run a Lua script INSIDE the app, which can call every verb those surfaces already have. The point is fewer round trips: a job that needed thirty separate HTTP calls is one script that runs at the app's own speed. A script gets an ezmuze table - click, move, key, tree, stats, action and the rest, with results arriving as ordinary Lua tables it can walk - plus three things the outside interface cannot offer. ezmuze.wait polls a condition instead of sleeping and hoping, which is what makes scripted checks reliable rather than timing-dependent. ezmuze.hover HOLDS the pointer somewhere and keeps running, so a script can look at the tree while a hover-triggered submenu opens; the existing /move dwell exists only because an HTTP caller cannot do that, and while a dwell runs nothing else can. And ezmuze.report says something immediately, so a long script can be watched through GET /script while it is still going rather than only when it ends. It runs on its own background thread the same way an HTTP request already does, so it has no authority the API did not already have and a runaway script costs one thread instead of freezing the app; it is sandboxed with no io, os.execute, dofile or require. The executor also answers up to 32 instantaneous commands per frame now instead of one, stopping as soon as a command starts a gesture, so a script asking several questions in a row no longer pays a frame for each. Desktop heads only for now: MoonSharp's interop is reflection-based and the browser head is AOT with trimming, so the web head answers 501 rather than pretending.",
      "tickets": [
        217
      ]
    },
    {
      "title": "Every channel says how many notes it is being asked to play, and how many it can",
      "version": "2026.8.139",
      "date": "2026-09-07",
      "area": "UI",
      "type": "Improvement",
      "userFacing": true,
      "details": "How many notes at once a channel could play was findable in one place only: a knob called Voices on a Stack card, the same size and shape as the Attack, Decay, Sustain and Release knobs it sat between, so a fact about the device read as a parameter of the sound. Nothing said what the song was asking of it either, which meant a chord coming out as a single note had no visible cause. Four changes. Each channel header now reads \"Voices 3/4\" while the song plays - notes asking to sound against voices available - and turns red the moment the first number passes the second, which is exactly when notes are being cut off; it is counted from the song at the playhead rather than from the renderer, which computes bars ahead of what you are hearing, and through the same pattern-position mapping the engine's own note allocator uses so the two cannot disagree. On a Stack card the device's own controls, Voices on a generator and Mix on an effect, now come first and sit on their own quiet ground instead of being mixed in among the module's knobs, and every knob has more air above it. \"Save module as\" carries Voices beside the name and category, so what your instrument declares is something you choose where you decide everything else about it, rather than something you discover by placing it in a song and finding the knob. And the publish cap, which was 32 while the engine has always clamped at 16, is now the one number the engine actually enforces.",
      "tickets": [
        215
      ]
    },
    {
      "title": "A submenu you have left stops coming back over the one you moved to",
      "version": "2026.8.138",
      "date": "2026-09-06",
      "area": "UI",
      "type": "Bug fix",
      "userFacing": true,
      "details": "Hovering a menu row that has a submenu opens it after a short dwell, counted in frames: a hovered frame added two to a count, every update took one back, and the submenu opened when the count hit fifty. Nothing capped it, so a row sat under the pointer kept counting up - measured at 241 after four seconds - and when you moved away the count drained back down at one a frame and passed through fifty again on the way. The test was an exact equality, so it fired a second time and reopened the submenu you had just left, on top of the one you had moved to, about three seconds after you left it. The count is now clamped just above the threshold, so leaving a row puts it under on the very next frame and the drain can never cross it again. The test is >= rather than ==, because the count is stepped from two places and a frame that dispatches hover twice could move it by three and step straight over fifty, leaving a row whose submenu never opened at all. Verified by log: hovering one row then another now fires exactly once per row, and every row peaks at the threshold instead of running away.",
      "tickets": [
        209
      ]
    },
    {
      "title": "ezmuze central saves projects to studio's own cloud folder, not over 3.0's songs",
      "version": "2026.8.138",
      "date": "2026-09-06",
      "area": "Services",
      "type": "Fix",
      "userFacing": true,
      "details": "Saving to ezmuze central went through the ezmuze 3.0 upload, which is the wrong shape for a project in two ways. It takes the whole file as a JSON string and works out the name by reading a UID out of it, then writes {uid}.eez - so a project landed among ezmuze 3.0's songs, wearing an extension 3.0 cannot open. And studio calls that field Id while the server reads UID, so where they disagreed EVERY save wrote the same file, 00000000-0000-0000-0000-000000000000.eez, over whatever was there before. Both were found in the live cloud. Studio now has its own half of the cloud and its own endpoints, and ezmuze central shows two folders when you open: \"ezmuze studio\", which is yours to read, write, rename, move and make folders in, and \"ezmuze 3.0\", which is READ ONLY - your 3.0 songs still open here through the legacy importer, and studio can no longer write anything into them. Saving skips the choice and goes straight into the studio folder, because it is the only one a save can use."
    },
    {
      "title": "A REAPER session ported to a song made only of factory devices",
      "version": "2026.8.138",
      "date": "2026-09-06",
      "area": "Core",
      "type": "Internal",
      "userFacing": false,
      "details": "tools/PortStuckInMyHead turns the \"Stuck in my head\" REAPER project into an ezmuze song without a single hand-wired module: the \"+ Wave\" channel for the drums, percussion and both vocal tracks, and catalogue instruments with their own factory presets plus the stock effect rack for the four Phase Plant synths. Two things it establishes are worth keeping. Chop-and-cancel does NOT convert a source-separated drum BUS - decomposed on its own the main groove loop folds nothing at all, because no two hits in a compressed, reverberant, separated stem are the same waveform, and the method assumes they are; the loops stay loops, which costs under 2 MB for 67 of them. And a wave channel has no per-note level, so the arrangement's per-bar gains ride the CLIP VOLUME LANE, which the renderer plays as (v x 1.3) cubed - measured per bar against the source stem, that tracks gains from 0.38x to 3.6x to within half a decibel. A run of bars playing the same loop at one gain is ONE clip that many bars long in Repeat mode rather than that many one-bar clips - both what the arrangement actually is and a third of the objects. docs/stuck-in-my-head-port.md is the write-up."
    },
    {
      "title": "Modules say what they are, and the Add menu groups by it",
      "version": "2026.8.137",
      "date": "2026-09-06",
      "area": "UI",
      "type": "Improvement",
      "userFacing": true,
      "details": "Two groups - Generators and Processors - held every device between them, which is not much of a list once you have a hundred of them. Every module now carries a CATEGORY its designer writes in their own words: Synth, Rompler, Keys, Reverb, Delay, anything at all. The Add menu builds one group per category, alphabetically, with the entries alphabetical inside; where a module came from is still the icon beside its name, and a bought one still carries its author. Everything that ships with ezmuze is filed - Cerebrum, Nova and OpStack under Synth, Rhodine and YDP Grand under Keys, PXL4 under Synth, Chip and GenesisFm under Chip, the kits under Drums, the SoundFont Player under Rompler, and the effects under Reverb, Delay, Filter, Distortion, Dynamics, Compressor, Saturation, Chorus, Pitch and Utility. You set your own when you save a module or publish one: the box is free text with a list that filters as you type, offering the categories already on this machine first and the usual ones after, and typing something nobody has used before is always allowed. Leave it blank and the module lands in Generators or Processors by what it does, exactly as before, so nothing goes missing.",
      "tickets": [
        207
      ]
    },
    {
      "title": "The menu row you are pointing at is blue, and you can read it",
      "version": "2026.8.137",
      "date": "2026-09-06",
      "area": "UI",
      "type": "Bug fix",
      "userFacing": true,
      "details": "Menus used one grey hover fill for both the bar and the dropdown rows, and in the dark theme that grey was (44,44,52) - very nearly black. Dropdown rows are drawn on a light strip in both themes with black text, so the row you had deliberately picked out was the only one in the menu you could not read; the menu bar had the same fill against its own dark header, where the lift was so slight that File/Edit/View/Help gave no sign of being hovered at all. Both now take a blue highlight, the same blue in either theme because a menu is chrome sitting on a light strip either way and selection is blue everywhere else in the app. The ink is picked by contrast against that fill rather than hard-coded, so the label, icon, submenu chevron and shortcut letter all turn white on it and cannot be left unreadable by a later retune. Fill and text cross together over 0.1s, off one clock: menus are faster than the 150ms a button uses, because a menu row is something you sweep past on the way to another one. A disabled entry stays unlit.",
      "tickets": [
        208
      ]
    },
    {
      "title": "The device list is sorted by what a thing does, not where it came from",
      "version": "2026.8.136",
      "date": "2026-09-06",
      "area": "UI",
      "type": "Improvement",
      "userFacing": true,
      "details": "Picking a device offered six headings - Generators, Effects, Instruments, Processors, In this song, My modules, and one per author you had bought from - so finding a filter meant knowing first whether you had built it, bought it or been given it, and four reverbs sat in four different places. There are two groups now, Generators and Processors, each a single alphabetical list. Where something came from is an icon beside its name instead: a stack of books for what ships with ezmuze, a music note for what is already in this song, a floppy for one you saved, and a shopping bag for one from the store, which also carries its author's name since the author used to be the heading. A saved module is listed by what it actually is rather than where it was filed, so a module that takes audio in appears under Processors even if it was saved from a song."
    },
    {
      "title": "Adding a channel and adding a device look like the same thing, and a channel can start with a module",
      "version": "2026.8.135",
      "date": "2026-09-06",
      "area": "UI",
      "type": "Improvement",
      "userFacing": true,
      "details": "The sequencer's add-channel row and the Stack's add-device entry were the same offer wearing different clothes - one a filled, bordered \"+ Add channel\" that read as a channel already in the song, the other a faded rectangle with a plus in it. Both are now the same control: a thin ring with a + in it, centred, sitting at 40% until you point at it. It deliberately does not look like a button, because it sits at the end of a list of real things and should not look like one more of them; the whole area stays clickable, so the target is much bigger than the ring. Add channel also gains \"With Module\", above \"From Template\", offering exactly the list the Stack's + offers - factory generators and effects, your saved modules, what is already in this song, and every installed store module grouped by author - and the new channel is named after what you picked rather than \"Channel 4\"."
    },
    {
      "title": "A scripted hover can dwell, so submenus open through the control API",
      "version": "2026.8.134",
      "date": "2026-09-06",
      "area": "Desktop",
      "type": "Bug fix",
      "userFacing": false,
      "details": "POST /move placed the synthetic pointer for a single frame and then handed it straight back to the real cursor. Anything that measures a dwell therefore never saw one: a menu item opens its submenu after about fifty hovered frames, counting up on hover and down on every update, so a one-frame visit was cancelled by the very next update and sixty /move calls in a row still opened nothing. That made the Stack's add-device submenus look impossible to drive, and an installed store module unreachable without a real mouse. /move now takes an optional seconds, meaning the same as it does on /mousemove, and holds the pointer there for that long; omitting it keeps the old single frame exactly. Developer tooling only - nothing changes for anyone using the app with a mouse.",
      "tickets": [
        206
      ]
    },
    {
      "title": "Change a store module's graph and it becomes your remix, with one undo back",
      "version": "2026.8.133",
      "date": "2026-09-06",
      "area": "Modules",
      "type": "New feature",
      "userFacing": true,
      "details": "A module you got from the store keeps its graph in the store, not in your song - so changing that graph used to have nowhere to go. Now the first change forks it: the device stops referencing the store, gains a copy of the graph that is yours, and remembers what it was forked from. The module editor says so before you touch anything, in a band across the top - who made it, that changing it makes a remix, and their terms: whether a remix may be sold and what share of a sale goes to them. After the fork the band says you are editing a copy and that undo removes the fork; one press of Ctrl+Z really does put the store's module back, exactly as it was, knob positions included. The title bar reads \"Remix of <name> by <author>\" from then on. Turning knobs, applying presets and automating never fork anything - those are yours already and always were. If the creator allows their graph to be read but not remixed, the editor still opens so you can see how it is built, with a banner saying so and every attempt to change it landing on the About panel with the reason. The lineage travels: Save, Save As, your module library and an exported .ezmodsrc bundle all carry it, so the publish wizard shows the parent, states their royalty, and refuses a price when their terms say a remix of theirs must be free. And going the other way: if a song of yours still holds its own copy of a module you have since published, the device's right-click menu on the Stack offers to link that copy to the published version so the song follows your future updates - offered, never forced, with what it replaces spelled out first, and undoable.",
      "tickets": [
        177
      ]
    },
    {
      "title": "Ratings and reviews: see what people make of a pack or a module, and say what you make of it",
      "version": "2026.8.133",
      "date": "2026-09-06",
      "area": "UI",
      "type": "New feature",
      "userFacing": true,
      "details": "Everything in the store now carries a score. Tiles show stars and how many people gave them; a product's page and a module's About panel show the average, the three newest reviews - the rating, the headline, who wrote it and which version they had, with the whole review on hover - and a link to the rest on the store page. If you own it you can write your own: a compact form with a star picker (it says what each score means as you point at it), a headline and a few sentences, saved to ezmuze central and yours to edit or delete from the same place afterwards. Only owners may review, and a creator does not get to review their own work; when you cannot, the panel says which of those it is rather than simply not offering the button, and if you are signed out it says so. A product nobody has rated says 'No reviews yet' and shows no stars at all, because five empty stars read as five bad reviews.",
      "tickets": [
        189
      ]
    },
    {
      "title": "A module you buy keeps the polyphony its author gave it",
      "version": "2026.8.133",
      "date": "2026-09-06",
      "area": "Modules",
      "type": "Bug fix",
      "userFacing": true,
      "details": "A published module records how many voices it plays, and that number is part of what the store versions against - but nothing read it back on the way in, so an eight-voice polysynth bought from the store played one note until you found the Voices knob and guessed. The same gap bit authors on the way out: the publish wizard read the voice count off whichever instance happened to be in the song, so a module published straight from the module editor went out as a monosynth. A saved module now carries its own polyphony, placing one restores it, and publishing takes the author's number rather than an accident of the song.",
      "tickets": [
        204
      ]
    },
    {
      "title": "A module you may listen to now plays, instead of going quiet",
      "version": "2026.8.133",
      "date": "2026-09-06",
      "area": "Modules",
      "type": "Bug fix",
      "userFacing": true,
      "details": "When a creator publishes a module and does not tick 'needs a licence to hear', anyone opening a song that uses it is meant to HEAR it - that is the whole point of the setting, and it is what makes a shared song an advert for the module. Until now the app got a listen copy from ezmuze central, badged the channel honestly, and then played nothing: the device was bypassed and the part was simply missing. Now the listen copy is opened for the session and the channel plays exactly as its author intended, whether or not you are signed in - a link to a shared song sounds right to somebody who has never had an ezmuze account. Nothing is installed and nothing is written to disk: the decrypted module lives in memory for as long as the song is open, and it goes when the song closes, when you sign out, or when the 24-hour permission runs out (which is quietly renewed under an open song, so nothing goes silent overnight). Everything except listening still refuses, on the About panel, with the reason: saving it to your library, adding another instance, opening its graph or its panel, remixing it, exporting it. The channel wears a small 'listen only' badge and keeps its controls, because they work. If the listen copy cannot be fetched the device is bypassed and now says so - 'no listen copy' rather than 'not installed', because the answer is to try again rather than to buy something.",
      "tickets": [
        200
      ]
    },
    {
      "title": "PXL4 — a four-engine chip synthesizer, in the store",
      "version": "2026.8.133",
      "date": "2026-09-06",
      "area": "Modules",
      "type": "New feature",
      "userFacing": true,
      "details": "PXL4 (Mad Ninja Skillz) is the first instrument built here to be SOLD through the module store rather than shipped inside the app, and the acceptance test for the store itself (#161). One note lights up to four independent engines: N, two NES pulse channels with a duty control, a triangle and a noise channel run through a rate crusher for the LFSR buzz; M, a Mega Drive serial FM stack (Op3 -> Op2 -> Op1) with real operator feedback through a util.buffer plus a doubling PSG square an octave down; A, a POKEY tone pair that can be detuned in whole semitones, ground through bit crush and rate crush; and C, a SID pulse-and-saw pair crossfaded against their ring modulation into a resonant multimode filter swept by its own envelope. Each engine has its own enable, level, pan, semitone transpose and amp envelope. They are mixed by a four-corner Chip Blend pad whose gains are EQUAL POWER - a corner solos an engine, the centre is an even four-way blend, and a sweep across the pad holds its loudness instead of dipping ~3 dB in the middle. Four macros (Tone, Motion, Bite, Age) each default to zero and only ever add, so an untouched patch is exactly what its dials say. A short fixed output chain - drive, chorus, ping-pong delay, reverb - and a non-defeatable safety clip finish it. Eleven factory presets, role-tagged (Bass, Lead, Keys, Pad, Pluck, Percussion, Sequence, Texture), and a Play page in the product's own near-black-and-neon livery with one persistent colour per engine, always paired with that engine's letter so state is never colour alone. Entirely composition: 163 primitive components and 258 wires, no bespoke DSP, the pattern ChipModule and GenesisFmModule established. It needed exactly ONE new primitive, math.equalpower (one position in, cos/sin out, squares summing to 1) - the wire scaler is affine and math.mul is a product, so between them they can only express polynomials, and no polynomial pair is exactly equal-power. NOT in this version, deliberately: Hardware Limits voice-stealing, PAL/NTSC clock tables, the modulation matrix, per-engine stem outputs, the Edit/Motion/FX pages and DPCM sample import. Also here: the remote-control API can now open the panel of a module you built (not just a factory one) and set a device's polyphony, which is what a store module needs to be verified and published without a mouse.",
      "tickets": [
        201
      ]
    },
    {
      "title": "A store module's new version waits for you, and says what it would cost",
      "version": "2026.8.132",
      "date": "2026-09-06",
      "area": "Modules",
      "type": "New feature",
      "userFacing": true,
      "details": "A device that uses a published module now decides for itself how it follows that module's versions. Bug-fix versions - ones that keep every control exactly where it is - apply on their own when a song opens, and the device menu's \"Follow compatible updates\" turns that off for one device (an ordinary, undoable edit, saved with the song). A version that CHANGES the controls never applies itself: the Stack card shows \"v4 available - breaking\", and clicking it opens a dialog that lists what moving this song onto it would actually cost - the port that goes and the wire on it, the param that goes and the value you set on it, the automation lanes that would be dropped and which channel they are on, what the new version adds, and how many of your settings carry over by slot. The author's notes for the version sit underneath. Upgrade is one undoable step: the graph is rebuilt from the new version, every knob position re-attaches by slot, and Ctrl+Z gives back the old version AND every lane and wire it had to drop. Keep vN pins the device where it is. A version its author has withdrawn is never chosen for you, and a song already on one keeps playing while the badge offers the nearest version that costs nothing. A song saved against a version newer than this machine has now says \"needs refresh\" instead of looking like a missing module, and the same button fetches it. And the publish wizard warns an author, before the store is even asked, when their new version removes an exposed param or a custom port.",
      "tickets": [
        178
      ]
    },
    {
      "title": "The store has a Modules tab — browse, preview and get published synths and effects",
      "version": "2026.8.132",
      "date": "2026-09-06",
      "area": "UI",
      "type": "New feature",
      "userFacing": true,
      "details": "The store now has three shelves: loop packs, preset packs and modules. The new Modules tab lists every synth and effect people have published: cover art, who made it, whether it is a synth or an effect, its stars and review count, and what it costs. Filter chips narrow it to Synths, Effects, Free or Remixes — they are toggles, so \"free effects\" is one press away. Where an author uploaded a preview, a play button on the tile auditions it without leaving the shelf. A free module is a Get: one press claims its licence, downloads it with the progress shown on the tile itself, and the tile flips to owned. A paid one is a Buy, which opens its page on ezmuze.co.uk, because payment happens there. A module you already own but have not installed on this machine offers Install instead of a price, and one that needs a newer studio says so rather than offering to sell you something you cannot open. Clicking a tile opens the About-this-module panel: what it is, who made it, what you may do with it, and its versions. Nothing published yet? The shelf says so in a sentence instead of showing an empty grid. Two things found while building it are fixed here too: the list of module licences you hold failed to load at all (the store said \"could not read your module licences\"), and a refused purchase — not enough sigma, or a licence you already hold — was being read as a successful one, so the real reason never reached you.",
      "tickets": [
        175
      ]
    },
    {
      "title": "About this module: what you may do with a store module, and why",
      "version": "2026.8.132",
      "date": "2026-09-06",
      "area": "Modules",
      "type": "New feature",
      "userFacing": true,
      "details": "Every published module now has an About panel, and it is where every refusal lands. It shows the module's art, name, author and version, badges for anything you should know (a newer version that changes the controls, a version its author withdrew, listen only, needs a licence, removed from the store, needs a newer studio), what it is, and then the eight plain-English lines of what YOU can and cannot do with it - hear it, use it in your own songs, open its graph, edit its panel, publish a remix, build it into your own module, credit it, publish preset packs for it - each with the reason beside it and the full sentence on hover. A refused line names the thing that would actually have to change: if you hold no licence it says so, and only a licence holder is ever told that the creator keeps the graph closed. Underneath: the usage tier for the sound, remix lineage (what it was remixed from, and the original where that is known), what it is built on, the credits it carries, and buttons for Get or Buy, Upgrade or Keep this version, Open store page and Report. Reach it from a device's right-click menu on the Stack, its panel's title bar, the component picker in the module editor, and a click on any bypass or upgrade badge. The locks themselves are real, not just hidden buttons: a module whose creator keeps its graph closed has no 'e' on its Stack card and refuses the module editor however you ask for it; one whose panel is locked hides Edit UI; adding another instance, saving to your library or exporting refuses when you can only listen; and the module editor's component picker now lists installed store modules and refuses the ones their creators do not allow to be nested. File > Licences gains a Modules section - version, author, usage tier, whether a credit is owed, and which channel is bypassed when one is - and its headline licence now considers modules as well as samples, so a personal-use-only module makes the whole song personal-use-only. Sharing or cloud-saving a song that uses a module only licence holders can hear now warns you which channel everybody else will hear bypassed.",
      "tickets": [
        174
      ]
    },
    {
      "title": "Preferences > Services no longer lists \"This computer\" twice",
      "version": "2026.8.132",
      "date": "2026-09-06",
      "area": "Services",
      "type": "Bug fix",
      "userFacing": true,
      "details": "Two different services were both called \"This computer\": the one that stores your songs on disk, and the one that saves the finished song out as an audio file. The Services tab lists a service by name and connection state, so they appeared as the same row twice - two toggles, no way to tell which turned off what. They really are two services (song storage and a publish target are separate things a service can offer), so the fix is two names rather than one merged entry: the audio one is now \"Save audio to this computer\", and its description says what it does and what it is not. Nothing else moves - the service ids are untouched, so existing settings and the credits stored in saved songs still resolve, and the Share dialog and the song picker looked right already because they label buttons with the action (\"Save as audio\") and list song storage only. The registry now logs a warning when any two services share a name, so this cannot come back unnoticed.",
      "tickets": [
        196
      ]
    },
    {
      "title": "Publish your presets as a pack, and use packs other people made",
      "version": "2026.8.131",
      "date": "2026-09-06",
      "area": "Modules",
      "type": "New feature",
      "userFacing": true,
      "details": "Stage S13 of the module store (docs/module-store-design.md section 7.9). A preset pack is a set of presets for ONE module, given away or sold like a module. Making one: open a device's preset browser, tick the presets you want, and choose 'Publish as a pack...' - which opens the same publish wizard a module goes through, in preset-pack mode. It shows the name, a description, the module the presets are for (shown, never chosen - a preset belongs to a module type), a price in sigma, optional box art, and one line saying whether ezmuze central will take it: how many presets, for what, and whether their interface still matches the version in the store. 'Export .ezmodsrc' writes the same pack to a file, so you have a backup and can hand it to somebody by name. Using one: an installed pack's presets appear in the preset browser under the pack's own heading with its author, marked 'Owned' or 'Needs <module>' when the module they are for is not installed, and with a plain warning when that module has moved on since - 'Made for Acid Pulse v3 - you have v5, which changed its controls. Some of these presets may not fit.' A pack's preset can be applied and edited like any other; saving your edit makes it yours, and the pack's copy stays underneath. Nothing from a pack is ever written to disk in the clear, and a pack's preset cannot be deleted or republished. The store ('Get more packs') gains a Preset packs tab whose tiles say which module each one is for. Under the hood: ModuleBundle writes and reads the presetpack kind of the open bundle (manifest with kind, targetTypeId, targetName and targetInterfaceHash, plus presets.json and optional art.png, no definition and no layout); PresetPackMath owns the grouping, the staleness check and the warning sentence; InstalledModules indexes a pack separately from a module so it never appears in the device picker, opens every installed pack at startup and seeds its presets through PackageSeeds; EzmuzeCentralService gains the site-API client for /api/presetpacks and /api/me/presetpacks, beside the module half and sharing its bearer auth and multipart plumbing; and the publish wizard (#176) gains the pack build, export and upload its preset-pack mode was waiting on. Fixes a bug found on the way: InstalledModules captured the pinned blob store once, and anything that touched it before the services started bound it to an in-memory one - so a package installed early worked all session and was 'missing from this machine' the next launch.",
      "tickets": [
        191
      ]
    },
    {
      "title": "Songs that use store modules open properly — with or without the modules",
      "version": "2026.8.131",
      "date": "2026-09-06",
      "area": "Modules",
      "type": "New feature",
      "userFacing": true,
      "details": "ezmuze studio now talks to the module store. Signing in fetches your account's package key and every module licence you hold, installs the ones this computer does not have, refreshes any with a new version published, and removes any that were taken down. Opening a song that uses published modules asks the store about each of them first, before anything loads, and if some are missing it says so: which module, by whom, what it costs, and what happens to your song either way. Install and open gets everything you already own (and claims anything free); Open without them keeps every channel, pattern and automation lane exactly as they are — the device simply waits, bypassed, holding its reference and its knob positions, so getting the module later restores the sound with nothing lost. A bypassed device says why on its Stack card: not installed, needs a licence, sign in to check, or removed from the store. Effects pass through dry and instruments stay silent rather than pretending. Songs saved with a store module are format v24.",
      "tickets": [
        173
      ]
    },
    {
      "title": "The browser can read a sealed store module: WebCrypto keys, IndexedDB packages",
      "version": "2026.8.131",
      "date": "2026-09-06",
      "area": "Web",
      "type": "Internal",
      "userFacing": false,
      "details": "Stage S11 of the module store (docs/module-store-design.md sections 4, 5.3 and decisions 12.17-18), the browser half of S3 (#172). The .NET WASM runtime has neither AES-GCM nor ECDSA verification, so the web head reads a sealed .ezmod through WebCrypto over [JSImport] (wwwroot/js/ezmuze-sealing.js): ES256 verify of the header signature - the raw 64-byte IEEE P1363 r||s the server writes, which is exactly what WebCrypto's ECDSA takes, no DER conversion - AES-256-GCM for both the A256GCMKW package-key unwrap and the body, and SHA-256 of the decrypted bundle. SubtleCrypto is asynchronous and a single-threaded WASM runtime cannot block on a promise, so rather than fork Ezmuze.Core's reader the browser runs its six steps ahead of time and remembers each answer, then lets the synchronous reader collect them; the rules stay in one place and callers get SealingCrypto.OpenAsync, which prepares on the head that needs it and does nothing on the ones that do not. The account key is imported ONCE, non-extractable, and kept as a CryptoKey handle in IndexedDB: after that it can be used and read by nothing on the origin, this app included - genuinely stronger than the desktop, where DPAPI protects a key the process can still read. What the settings store holds is a 32-byte handle in its place, so the key material never reaches localStorage. Sealed packages are pinned in IndexedDB, the browser's equivalent of the desktop's library/ folder, outside the LRU budget and kept by sign-out; never localStorage, which is a few megabytes for the whole origin against a 13 MB SoundFont module. The anonymous listen route (Module/Module/GetListenPackage/{flc}) is wired up so a signed-out visitor following a shared-song link can hear a listen-only module, with the signing key taken from the sign-in fetch when there is one and the public health route when there is not; the /svc proxy allowlist now names every module route it carries. Verified headless against the shared sealing vectors in a real Chromium (tools/web-sealing-selftest/run.mjs, driving a [JSExport] self-test in the app): non-extractable key, exportKey refused, install through the whole reader, a package surviving a simulated reload in IndexedDB, the listen grant opening with its session key, and the three refusals - an edited header, the wrong account key, a listen package offered for install.",
      "tickets": [
        180
      ]
    },
    {
      "title": "Publish a module you built to the store, from inside the app",
      "version": "2026.8.131",
      "date": "2026-09-06",
      "area": "Modules",
      "type": "New feature",
      "userFacing": true,
      "details": "The publish wizard (docs/module-store-design.md section 9), opened from the module editor's toolbar and File menu, and from a right-click on a device's name in the Stack. Left: the listing - name, description, the kind the package detects, a price where 0 means free, and box art picked from your image library or imported. Under it, every permission the store carries as a toggle - who may hear it, whether the graph and panel are open, remixes, embedding, preset packs by other people, attribution - with remix, embed and preset-pack royalty sliders capped at 50% and the consistency rules enforced as you toggle, so closing the graph turns remixing off with it rather than letting you build a listing the store would refuse. Right: 'what buyers will see', the same eight plain-English lines the store page shows, updating live as you toggle; then the checks - size after dedupe against the 64 MB cap, every embedded module with its royalty and whether your price covers it, embedded audio credits with any refusal named in the words the store uses (ezmuze pack content may not be redistributed inside a module; CC0, CC-BY and CC-BY-SA may), the component/voice/nesting caps, presets and panel, and the format version. Publishing a module that is already on the store asks the server first: the verdict - compatible or breaking - and the interface diff appear above the checks with a box for what changed, uploading nothing and costing nothing against your upload limit, and only the second press sends the version. 'Render preview' renders ten seconds of the module through the real audio graph and uploads it with the publish; 'Export .ezmodsrc' saves the same bundle to disk. The first publish records the product code so the next one reads 'Publish v2'. The wizard also runs in preset-pack mode from the preset browser's 'Publish as a pack...', with the target module and its interface hash shown in place of the module checks.",
      "tickets": [
        176
      ]
    },
    {
      "title": "Sealed store packages: reader, account key store, pinned install",
      "version": "2026.8.130",
      "date": "2026-09-06",
      "area": "Modules",
      "type": "Internal",
      "userFacing": false,
      "details": "Stage S3 of the module store (docs/module-store-design.md sections 3.2, 4, 5.3). Ezmuze.Core gains the .ezmod reader: SealedPackage.Parse splits a package into header, signature and body; SealedHeader is the signed header model (product, author, versions with permission snapshots, grant, wrap, body); SealedPackage.Open verifies the ES256 signature over the raw header bytes with the pinned signing key, unwraps the package key with the account key (A256GCMKW), decrypts the body (AES-256-GCM) to memory and checks the plaintext SHA-256 - every tamper is a SealedPackageException. The crypto sits behind ISealingCrypto so Core stays platform-free: DesktopSealingCrypto (System.Security.Cryptography) serves the desktop heads, the browser stub throws until S11's WebCrypto reader (#180). SealedBundle reads the decrypted zip (versions/<n>/definition|layout|presets.json, shared content-addressed assets/) and ModuleVersions resolves a device's version under its pin policy. ModuleKeyStore keeps the account key, key version and signing key in the services' secret store (DPAPI on Windows; the plain-text file the other desktops use is now written mode 600). InstalledModules pins sealed packages in the same library/ area packs use, outside the LRU budget, with an index entry per product; Open decrypts to memory only and stamps every definition Origin.PublishedAs; CreateDevice and TryMaterialize build a referenced ChainDevice through ModuleMaterializer. An opened package lends its panel, presets and images to ModuleUiLibrary, PresetLibrary and ImageAssetLibrary for the session through PackageSeeds, package-owned, with a user's own edit winning (the ModuleUiLayout UserEdited rule). The stack's [+] picker lists installed modules under 'Installed - <author>'; File > Install module package... installs one from disk; module_key / module_install / module_uninstall / module_installed control-API actions drive it. Tests: the server's sealing vectors (byte-identical copy under tests/Ezmuze.Tests/Vectors), tamper cases, a package sealed in-test, install/uninstall round trips, seeding precedence.",
      "tickets": [
        172
      ]
    },
    {
      "title": "Export and import your own modules as .ezmodsrc files",
      "version": "2026.8.130",
      "date": "2026-09-06",
      "area": "Modules",
      "type": "New feature",
      "userFacing": true,
      "details": "File > Export module... lists the modules you built and saves the one you pick as a single .ezmodsrc file (a native save dialog on desktop, a download in the browser); the module editor's toolbar and File menu gain the same Export for the module you are looking at. The file carries everything the module needs to be itself somewhere else: its graph and every nested module it embeds, its custom panel with its theme, its presets, and every image and SoundFont those reference, each stored once under its content hash. File > Import module... (or dropping the file on the sequencer) brings one back into your library under My modules, replacing an earlier import of the same module rather than duplicating it, and always as an unpublished module of your own: a file never establishes store provenance, only the store does. Files from a newer version of the app are refused with a message saying so, as are files that are not bundles. This is the open bundle of docs/module-store-design.md section 3.1 and the body of the coming store upload; the design-only '.ezmod import/export' item in docs/module-builder-design.md section 9 and docs/preset-system-design.md section 9 is closed by it.",
      "tickets": [
        164
      ]
    },
    {
      "title": "Design note: preset packs on the server (C10) - factory targets and the pack's content key",
      "version": "2026.8.130",
      "date": "2026-09-06",
      "area": "Services",
      "type": "Internal",
      "userFacing": false,
      "details": "docs/module-store-design.md section 12 gains decisions 19 and 20 from building C10 on ezmuze central (bug board #190): the site API knows factory preset targets (shape.<types>) through a server-side list of Ezmuze.Audio's registered component types plus EZMUZE_FACTORY_TYPES, because Ezmuze.Core carries no type registry to ask; and NGPresetPacks carries ContentKeyWrapped so a pack's version blobs are encrypted at rest exactly like a module's. The note also fixes the preset-pack bundle the app (S13) has to write: manifest.kind \"presetpack\" with targetTypeId, targetName and targetInterfaceHash, and presets.json as a JSON array of Preset in SongSerializer.SerializePreset form. Docs only; nothing in the app changes.",
      "tickets": [
        190
      ]
    },
    {
      "title": "Services probe walks the module-store routes on ezmuze central",
      "version": "2026.8.129",
      "date": "2026-09-06",
      "area": "Services",
      "type": "Internal",
      "userFacing": false,
      "details": "tools/ServicesProbe gains a 'modules' command that exercises the legacy API's new module-store surface (docs/module-store-design.md 7.3, bug board #168): Module/Module/GetCatalogue and GetListenPackage/{flc} anonymously, and with a token (--token or EZMUZE_PROBE_TOKEN) License/License/GetPackageKey, Module/Module/GetEntitlements and GetPackage/{flc}. --api or EZMUZE_PROBE_API points it at a local run. It reports an empty catalogue, an account that holds nothing, or sealing being off on the server as states rather than failures, so it can be run before any module is published. Straight HTTP for now; the S4 client (#173) will take over.",
      "tickets": [
        168
      ]
    },
    {
      "title": "Core model for published modules: permissions, references, credits, access decisions",
      "version": "2026.8.128",
      "date": "2026-09-06",
      "area": "Modules",
      "type": "Internal",
      "userFacing": false,
      "details": "Stage A of the module store (docs/module-store-design.md sections 2, 5, 6), all pure and unit-tested, nothing user-visible yet. ModulePermissions is the creator's settings record with the consistency rules as Validate() and the 50% royalty cap. ChainDevice gains Ref (a ModuleRef: product code, ids, version, pin policy, name, author, kind, permission snapshot) and RefState (params by preset slot): a referenced device saves its state and NOT its graph, via a serializer contract hook, and reads back under the same format version - v23 is not bumped; the store client spends the bump when it first writes a Ref. ModuleDefinition gains Origin (RemixOf for a fork in progress, PublishedAs for a package definition, which the loader drops from a song). Song.ModuleCredits sits beside Credits with SongCredits.RecordModule/PruneModules, and one attribution formatter now serves Freesound, Openverse and modules alike. InterfaceHash computes the version-compatibility hash over kind, custom ports, control faces, component slots, sub-device slots, embedded modules, pattern editor and voices, and InterfaceDiff says in plain words what changed. ModuleIds derives RFC 4122 v5 ids from the device id and slot key so ModuleMaterializer rebuilds a referenced device with the same component ids on every load - automation lanes keep their targets. ModuleAccess.Decide implements the enforcement table including the 14-day offline grace shared with BetaGate, and the 'what you can do' wording is one embedded JSON (ModuleWording) the site can mirror. ShippedDemoFreshnessTests asserts no demo carries a Ref.",
      "tickets": [
        163
      ]
    },
    {
      "title": "Module store design: sealing uses ES256 and AES-GCM key wrap",
      "version": "2026.8.128",
      "date": "2026-09-06",
      "area": "Services",
      "type": "Internal",
      "userFacing": false,
      "details": "Two deviations recorded under docs/module-store-design.md section 12 while building the server's sealing service (ezmuze-central EzmuzeCentralSealing): package headers are signed with ECDSA P-256 (ES256) rather than Ed25519, because .NET 8 has no Ed25519 and the browser build verifies through WebCrypto where P-256 is universal; and the package key is wrapped with AES-256-GCM (A256GCMKW, nonce and tag in the header) rather than AES-KW, because .NET 8 has no RFC 3394. The HKDF info encoding and the body hash are pinned down at the same time. The canonical byte layout and the shared test vectors live in ezmuze-central; the studio's readers (S3/S11) verify against them.",
      "tickets": [
        167
      ]
    },
    {
      "title": "Ezmuze.Core ships to the site as a NuGet package on every beta",
      "version": "2026.8.128",
      "date": "2026-09-06",
      "area": "Build",
      "type": "Internal",
      "userFacing": false,
      "details": "Internal. The release pipeline now packs src/Ezmuze.Core as a NuGet package (tools/release/pack-core.sh, run in the deploy-beta job after the stage is whole and before beta goes live) versioned with the beta's own number, and drops it in a folder feed on the VPS at /dockerStorage/nuget-feed. The site API in ezmuze-central builds against that feed, so uploaded module bundles are parsed by the app's own reader rather than a copy that drifts (docs/module-store-design.md 7.5, decision 12.11).\n\nA folder feed rather than GitHub Packages: both repositories are private and the runner's token has no packages scope, so the feed is a directory the site's deploy copies into its Docker build context. Ezmuze.Core.csproj carries the package metadata and refuses any ProjectReference or engine package at build time; CoreIsolationTests checks the compiled assembly references only the framework. docs/release-pipeline.md, \"The Core package feed\".",
      "tickets": [
        162
      ]
    },
    {
      "title": "Opening the UI editor from a module panel no longer crashes",
      "version": "2026.8.127",
      "date": "2026-09-06",
      "area": "UI",
      "type": "Bug fix",
      "userFacing": true,
      "details": "File > Edit this UI... on an open module panel (and the Open UI editor button on a panel with no custom UI yet) closed the app with a NullReferenceException. When the editor opens as a tab beside the panel it edits, it built its menu and toolbar against a window it had never been given; it now builds them on the window it actually lives in. The editor also follows the panel if that window is later merged into another container instead of closing itself.",
      "tickets": [
        194
      ]
    },
    {
      "title": "Module store design: cash-out, reviews, preset packs",
      "version": "2026.8.126",
      "date": "2026-09-06",
      "area": "Modules",
      "type": "Internal",
      "userFacing": false,
      "details": "docs/module-store-design.md gains three decisions: sigma cash-out at half value, built now but admin-only while testing (7.7); ratings and reviews by owners on every product (7.8); preset packs as a product in their own right with a royalty to the target module's author (7.9). Eight more sub-tickets under bug board #161 (#185-#192). No code changed."
    },
    {
      "title": "Design for publishing user modules to the store",
      "version": "2026.8.126",
      "date": "2026-09-06",
      "area": "Modules",
      "type": "Internal",
      "userFacing": false,
      "details": "docs/module-store-design.md: the normative design for building a synth or effect in the module builder and publishing it to ezmuze central, free or for sale - creator permissions (hear/use/graph/panel/remix/embed), sealed per-account packages (.ezmod) and open bundles (.ezmodsrc), server-derived version compatibility with auto-applied bug fixes and explicit breaking upgrades, remix lineage and royalties, licence display beside sample licences, and a 35-row gaps register. Decisions locked 2026-09-06; the work is broken into shippable sub-tickets under bug board #161. No code changed."
    },
    {
      "title": "Record your own samples",
      "version": "2026.8.125",
      "date": "2026-09-06",
      "area": "UI",
      "type": "New feature",
      "userFacing": true,
      "details": "File > Record a sample opens a microphone or line input and puts what you record straight into the song's wave bank, where a sampler or drum machine can play it — so a hit, a voice or a Pringles can is a sound in your song without leaving the app. The input opens when the window does, with a live level meter and the device's name, so you can set your level before committing to a take; the waveform draws as you record, and Listen plays the take back before you keep it. A recording goes in as a one-shot by default, which is what a sampled object is. When a take lands within a fraction of a bar of a whole number of bars at the song's tempo, a switch offers to add it as a loop of exactly that many bars instead — trimmed to the exact bar length, so it does not drift — and that becomes a clip you can drop on the timeline. The audio is embedded in the song, so it survives a save and reopens on a machine with no microphone attached. It works on all three builds: Windows, macOS and Linux desktop, and in the browser, where it asks permission the first time and tells you plainly if the browser refuses.",
      "tickets": [
        11
      ]
    },
    {
      "title": "Audio input works on every head",
      "version": "2026.8.125",
      "date": "2026-09-06",
      "area": "Sound engine",
      "type": "Internal",
      "userFacing": false,
      "details": "Groundwork for recording (bug board #11): the app can now open a microphone or line input and read samples from it on all three heads, behind one API (EzmuzeStudio.Audio.AudioCapture) that hands back mono float at the device's rate. Only the GL heads could do this before, because KNI inherited MonoGame's microphone support, and MonoGame only ever implemented it for OpenAL — the DirectX and browser backends were both files full of NotImplementedException, and no other MonoGame fork has anything to drop in for either (FNA's is real but SDL-based). The DirectX head now captures through Media Foundation, which the KNI fork already depended on for video playback, so it needs no new binaries; XAudio2 could never have done it, having no capture side at all. The browser captures through getUserMedia into an AudioWorklet on the same AudioContext playback already uses, so input arrives at the engine's rate with no resampling. Both queue what the device produces off the UI thread and are bounded at 60 seconds, dropping and COUNTING the oldest audio rather than growing without limit, so a recording that lost samples can say so. Nothing in the UI records yet — that is the next step; the new POST /capture control endpoint is how the backends are exercised in the meantime."
    },
    {
      "title": "Building a chain while the song plays no longer clicks",
      "version": "2026.8.124",
      "date": "2026-09-05",
      "area": "Sound engine",
      "type": "Bug fix",
      "userFacing": true,
      "details": "Adding a device to a channel, or a trigger-effect lane or slot, made an audible click and a brief drop in level — even when the thing added was silent, like a device at zero mix or a slot with no regions drawn yet. Any of those edits recompiles the channel's graph, and the replacement used to start from nothing: oscillator phase, envelope position, delay contents and reverb tails all back to zero in the middle of a note. On a sustained sound the level fell by about a quarter for a tenth of a second and the waveform jumped. A replacement now takes over the sound of the graph it replaces — including which note is still being held, so nothing is re-attacked — and it waits for the bar to end before it takes over, because a graph is told what a bar contains once, at the start of it. So a chain edit made during playback is heard from the next bar, which is the same rule knob moves and trigger-effect regions have always followed. Stopped, it applies at once.",
      "tickets": [
        160
      ]
    },
    {
      "title": "A damaged file in the playback cache no longer crashes the app",
      "version": "2026.8.123",
      "date": "2026-09-05",
      "area": "Sound engine",
      "type": "Bug fix",
      "userFacing": true,
      "details": "A malformed file in the bar cache could take the whole app down. The cache is written in the background while you play, and it was written straight over the top of the previous file - so a copy of ezmuze that was killed or crashed mid-write left a damaged file behind, and playing that bar afterwards crashed on a thread you cannot see, taking any unsaved work with it. It happened every time you reached that bar, because nothing ever checked the file again.\n\nThree things changed. The WAV reader now rejects a damaged file cleanly instead of falling over: 400,000 deliberately corrupted files used to produce nearly ten thousand crashes and now produce none, while every valid file still reads exactly as before. The bar cache treats an unreadable file as simply not cached and renders that bar live, which is what it always said it did. And bars are written to a temporary file and renamed into place, so a copy of ezmuze that dies mid-write cannot leave a damaged one at all.",
      "tickets": [
        159
      ]
    },
    {
      "title": "Pack badges have an edge, so they read against any cover art",
      "version": "2026.8.121",
      "date": "2026-09-05",
      "area": "UI",
      "type": "Improvement",
      "userFacing": true,
      "details": "The Owned and price chips on a pack card sit on top of cover art, which is whatever the artist made it - so a flat block of colour could disappear into a bright picture or float on a dark one.\n\nThey have a thin border now, taken from the chip's own colour rather than a fixed one, so it works against every cover instead of half of them. Both the pack store and the pack rows in the wave browser get it."
    },
    {
      "title": "Release stamping matches entries by ticket rather than by position",
      "version": "2026.8.121",
      "date": "2026-09-05",
      "area": "Build",
      "type": "Internal",
      "userFacing": false,
      "details": "Internal. When the build server resolves a change log entry's version it now works against the change log the build actually compiled, matching each entry on its tickets and title, rather than substituting whatever happens to say NEXT in whichever commit main is on by then.\n\nThat matters on the release channel, which resets to main and stamps without checking whether main moved while the build ran - so a commit landing mid-build could be stamped with a version it was never part of. The beta channel avoided that by skipping the stamp entirely when main had moved, which was safe but deferred it: an entry could end up carrying a later build than the one it first shipped in. It no longer skips.\n\nThis does NOT fix the version shifting onto the wrong entry after a local git pull --rebase. That happens in a working copy, not on the server, and there is already a test that catches it."
    },
    {
      "title": "The pack store notices a pack you just bought, or one just released",
      "version": "2026.8.120",
      "date": "2026-09-05",
      "area": "Services",
      "type": "Bug fix",
      "userFacing": true,
      "details": "Buy a pack and the app did not notice. Not that session, not after restarting it, and not after signing out and back in - because the list of what you own was cached for twelve hours on disk, and held in memory for the life of the app on top of that. A pack released while you were working was invisible for the same twelve hours. There was nothing you could press to make it look again.\n\nOpening Get more packs now asks the server what is actually there, rather than answering from a cache that might be half a day old, and there is a Refresh button in it for when you have just bought something and want it now. What you own is re-checked on the minute rather than twice a day.\n\nNone of this makes the app worse offline: a request that fails still falls back to the last answer it had, exactly as before.",
      "tickets": [
        157
      ]
    },
    {
      "title": "A module UI kept as a tab now has its own menu, and fills its tab",
      "version": "2026.8.119",
      "date": "2026-09-05",
      "area": "UI",
      "type": "Bug fix",
      "userFacing": true,
      "details": "Drag a device's module UI panel onto the sequencer to keep it as a tab, and it did not behave like one: the menu bar at the top showed the sequencer's File, Edit, View and Help rather than the panel's own, the panel's real menu sat on a second row lower down inside it, and the panel did not fill the space so the sequencer's ruler and channels showed around the edges.\n\nIt has one menu now, in the same place every other tab puts it, and it fills its tab. The same goes for the module UI editor.",
      "tickets": [
        158
      ]
    },
    {
      "title": "The toolbar and menus follow the tab you are looking at",
      "version": "2026.8.118",
      "date": "2026-09-05",
      "area": "UI",
      "type": "Bug fix",
      "userFacing": true,
      "details": "Open a piano roll beside the sequencer and the toolbar turned to nonsense: the piano roll's tools drawn on top of the sequencer's transport, both sets still clickable so a button could do something other than the one you were pointing at. Worse, the sequencer's File, Edit, View and Help menus vanished and did not come back - for the rest of the session, whichever tab you were on.\n\nA window's menu and toolbar now belong to the tab you are looking at and change with it. They also travel: pop an editor out into its own window and its tools go with it, which they never used to - a popped-out piano roll arrived with no toolbar at all.",
      "tickets": [
        156
      ]
    },
    {
      "title": "Credits, Help and the pack store had two scroll bars each",
      "version": "2026.8.117",
      "date": "2026-09-05",
      "area": "UI",
      "type": "Bug fix",
      "userFacing": true,
      "details": "Credits, Help and Get more packs each had two scroll bars down the right-hand side, side by side. The inner one scrolled the page; the outer one moved it about two lines and was the one your pointer reached first.\n\nEach of those screens was putting its content in a scroller before handing it to the window — and the window already scrolls anything too tall to fit, so it scrolled the scroller. There is one now, and it is the window's.",
      "tickets": [
        155
      ]
    },
    {
      "title": "Every dialog's text now sits under its title bar rather than over it",
      "version": "2026.8.116",
      "date": "2026-09-05",
      "area": "UI",
      "type": "Improvement",
      "userFacing": true,
      "details": "The first pass at this made the title bar's text bigger to match the dialog. Wrong way round: the title bar is 30 pixels tall, and the answer to \"the writing is bigger than the bar above it\" is smaller writing, not a taller bar.\n\nSo the title bar is back where it was, and a dialog's own text sits under it instead: its heading level with the title, its explanation a step below that, its buttons level with the title again. Nothing in a dialog is larger than the bar naming it any more.\n\nThis runs through every dialog in the app, not just the one it was reported on — the prompts and warnings, Credits, Help, the pack store, the file and preset browsers, the sign-in and share dialogs, and the titles on the welcome screen's project cards. Credits and Help in particular now fit a great deal more on screen before you have to scroll.\n\nPreferences is deliberately untouched: it was already the example the rest was being matched to. The sequencer, the piano roll and the module editors keep their own sizes too — a dialog is text to read, and those are instruments to play.",
      "tickets": [
        153
      ]
    },
    {
      "title": "A development build reports the newest build, not the last release",
      "version": "2026.8.116",
      "date": "2026-09-05",
      "area": "Build",
      "type": "Improvement",
      "userFacing": false,
      "details": "Help > Check for updates on a local development build was answering a question nobody asked. It reported the newest RELEASE, and releases are promoted rarely — so it named a build from five days and eighty-odd versions ago and looked broken even though the number was true.\n\nA development build is a build of the latest code, so it now follows the same channel the betas do and reports the newest build there is, not the last thing promoted.",
      "tickets": [
        154
      ]
    },
    {
      "title": "Beta builds check the beta channel, and finally show What's new",
      "version": "2026.8.115",
      "date": "2026-09-05",
      "area": "Build",
      "type": "Bug fix",
      "userFacing": true,
      "details": "If you run a beta, Help > Check for updates has been telling you nonsense: that your build was \"a development build\", that the newest ezmuze studio was 2026.8.32 — five days and 81 builds behind you — and offering a green Download button that would have installed that older one over the top of yours.\n\nWorse, and nobody had noticed: \"What's new\" never appeared on a beta at all. Not once. The whole list of what changed since your last version was silently empty on every beta build there has ever been, and so was the update banner on the welcome screen.\n\nOne cause behind all of it. A beta's version is written 2026.8.113-beta, and the code that reads version numbers could not cope with the \"-beta\" on the end, so every comparison involving the build you were actually running quietly failed. It reads them now.\n\nAnd each channel now checks itself: a beta looks for the newest beta, a release looks for the newest release. Betas ship several times a day and releases rarely, so asking one about the other was never going to give a sensible answer. A beta will offer you the next beta rather than moving you onto releases, and the Download button only ever appears when what is on offer is genuinely newer than what you have.",
      "tickets": [
        154
      ]
    },
    {
      "title": "Dialogs read the same way everywhere: title type, one body surface, no flush edges",
      "version": "2026.8.114",
      "date": "2026-09-05",
      "area": "UI",
      "type": "Improvement",
      "userFacing": true,
      "details": "A dialog's title bar was set in smaller type than the text inside it, so every window in the app was named in a smaller size than it was written in — including the full-screen views, where the File / Edit / View / Help row sat directly under a title smaller than itself. Titles are the body size now, which still leaves the dialog's own headline a step above them.\n\nSeveral dialogs — Unsaved changes, Recover unsaved work, Song open error, Missing packs, Check for updates — also drew a darker panel behind their text, with a margin around it but none inside it, so the words sat flush against its edges. That panel was covering a background that was never see-through in the first place. It is gone: the dialog body is one surface, and everything on it keeps the same margin.\n\nThe dialogs that genuinely scroll — Credits, Help, Get more packs — keep that surface, but it now fills the window edge to edge instead of floating in the middle of it, with the text properly inset inside. Help had one more fault while we were there: a shortcut whose description wrapped onto a second line drew that line straight through the shortcut below it.\n\nEvery dialog is also 10px shorter, because the space reserved for the title bar had not been updated since the bar itself was made smaller.",
      "tickets": [
        153
      ]
    },
    {
      "title": "One visualiser everywhere, with next and previous on the now-playing toast",
      "version": "2026.8.113",
      "date": "2026-09-05",
      "area": "UI",
      "type": "New feature",
      "userFacing": true,
      "details": "The full-screen visualiser, the little one on the now-playing toast and the sequencer's playing background used to be three separate visualisers that each picked their own effect — so the app could show you three different ones at once. They are one now: the same effect, running from the same clock, because it is rendered once and they all show it.\n\nThe mini visualiser on the toast has a chevron at each side. Click them to step through the effects, and the full-screen visualiser and the sequencer background follow. Left and Right on the full-screen visualiser do the same thing. Your choice is remembered between sessions.\n\nThe two showpiece effects — LiquidCarbon and SpectrumBlob — now work in the toast and behind the sequencer as well, which was never possible before: they are built differently from the other six and a small panel could not draw them at all. Rendering once and sharing the picture gets them everywhere for free, and costs less than the three separate renders it replaces.",
      "tickets": [
        152
      ]
    },
    {
      "title": "The piano roll and the other editors are real windows now",
      "version": "2026.8.112",
      "date": "2026-09-05",
      "area": "UI",
      "type": "Improvement",
      "userFacing": true,
      "details": "The piano roll, drum lanes, module editor and wave picker used to be sheets: they filled the screen and could be nothing else. They are ordinary windows that simply open maximised, so you can resize them, dock them, and put the piano roll beside the arrangement instead of on top of it.\n\nOpening one while the sequencer is up adds it as a TAB of the sequencer rather than burying it — both stay one click away, and either can be dragged out and docked next to the other. Each tab keeps its own keyboard shortcuts, so Space, Escape and Delete always mean whatever the tab you are looking at says they mean.\n\nThe old Korben panel has been removed. It was already unreachable — every device, Korben included, has opened the newer module panel for some time.",
      "tickets": [
        22
      ]
    },
    {
      "title": "A named master channel keeps its own name in the gutter",
      "version": "2026.8.111",
      "date": "2026-09-05",
      "area": "Sequencer",
      "type": "Internal",
      "userFacing": false,
      "details": "Follow-up to #149. Splitting the display name out introduced a dormant difference: the master row answered \"Master\" before looking at the channel's own name, so a master named anything else would have lost it. Every master in every shipped song is named exactly \"Master\", so nothing on screen was ever different — but the fallback was wrong and is now the way round it always was: the channel's own name if it has one, \"Master\" only when it does not.",
      "tickets": [
        149
      ]
    },
    {
      "title": "Name your channels",
      "version": "2026.8.110",
      "date": "2026-09-05",
      "area": "Sequencer",
      "type": "New feature",
      "userFacing": true,
      "details": "Right-click a channel and choose \"Rename...\" to call it whatever it is doing in this song. The name it came with stays in front, in brackets — an atmos channel renamed \"Gated pad\" reads as \"[atmos] Gated pad\" — so you can still see what kind of channel it is and what will load into it.\n\nA channel with no type of its own just shows the name you gave it. \"Clear name\" puts it back, and so does clearing the box in the dialog. Renaming is undoable like anything else, and the full name is in the channel's tooltip for when the gutter is too narrow to show all of it.",
      "tickets": [
        149
      ]
    },
    {
      "title": "Deleting an automation block no longer opens the clipboard menu too",
      "version": "2026.8.109",
      "date": "2026-09-05",
      "area": "Sequencer",
      "type": "Bug fix",
      "userFacing": true,
      "details": "With bars selected, right-clicking an automation or trigger-FX block below them removed the block and then opened the Cut/Copy/Paste menu over the space it had just left. Both things were happening because the click reached the block and then carried on up to the timeline underneath it.\n\nA right-click in a channel's automation or TFX strip is now only ever about that strip. Right-clicking inside a selection on the channel's own row still opens the clipboard menu as before.",
      "tickets": [
        151
      ]
    },
    {
      "title": "Reset a channel's volume or pan from its right-click menu",
      "version": "2026.8.109",
      "date": "2026-09-05",
      "area": "Sequencer",
      "type": "New feature",
      "userFacing": true,
      "details": "Right-click a channel — on its header, its volume knob or its pan slider — and the menu now offers \"Reset volume\" and \"Reset pan to centre\". Pan is the one that really wanted it: a slider can sit a pixel off centre and look centred, and there was no way to be certain by dragging.\n\nBoth are greyed out when there is nothing to reset, so the menu also answers \"is this channel actually panned?\" at a glance. Both are undoable like any other change.",
      "tickets": [
        150
      ]
    },
    {
      "title": "Fold arrows no longer pile up in the channel gutter",
      "version": "2026.8.108",
      "date": "2026-09-05",
      "area": "Sequencer",
      "type": "Bug fix",
      "userFacing": true,
      "details": "Nesting a channel under another and then decoupling it left a dead fold arrow behind, and doing it again left a second one overlapping the first. In fact any change to the channel list did it — adding a channel was enough — so they accumulated quietly: three channels had six fold arrows, and nine after a nest.\n\nThe fold arrow sits in the indent column to the left of a channel's card rather than inside it, and the code that tears a row down was removing the card and everything else on the row but not the arrow. It goes with the row now, so there is exactly one per channel and it appears only when that channel actually has something nested under it.",
      "tickets": [
        148
      ]
    },
    {
      "title": "Browse the pack store without leaving the app",
      "version": "2026.8.107",
      "date": "2026-09-05",
      "area": "UI",
      "type": "New feature",
      "userFacing": true,
      "details": "File ▸ \"Get more packs…\" opens a catalogue of everything ezmuze central sells: every pack with its cover and its pitch, and a badge saying whether it is already yours or what it costs. Clicking one opens its page — the full description, who made it, and what it would take to own it.\n\nBuying itself happens on ezmuze.co.uk, because that is where the checkout is. The difference is that \"Get this pack\" now opens that pack's own page rather than the front of the store, so you do not have to go and find it again.\n\nThis is something ezmuze 3 had and studio did not: \"I need more sounds\" was a trip to the website, and it is two clicks again.",
      "tickets": [
        87
      ]
    },
    {
      "title": "Play the visualiser behind the sequencer",
      "version": "2026.8.106",
      "date": "2026-09-05",
      "area": "UI",
      "type": "New feature",
      "userFacing": true,
      "details": "Preferences ▸ Display ▸ \"Background while playing\" gains a Visualiser option, alongside the existing green and magenta key colours. While the song plays, the visualiser runs behind the timeline: the clips, grid, ruler and playhead all draw over it as usual, and everything that would have hidden it gets out of the way. As with the key colours it is playing-only, so editing is never done over a moving background.\n\nIt is dimmed on purpose, enough to be clearly moving without making a clip's name hard to read — the point is a recording you do not have to edit afterwards, not a screen you cannot work on. The effect is Ribbons, which flows across the beat rather than marking it; bars or a grid behind a grid read as one confused thing. Choosing the effect yourself, or supplying your own images and video, is a separate piece of work and is not part of this.",
      "tickets": [
        46,
        119
      ]
    },
    {
      "title": "The now-playing visualiser square draws again",
      "version": "2026.8.106",
      "date": "2026-09-05",
      "area": "UI",
      "type": "Bug fix",
      "userFacing": true,
      "details": "The little visualiser on the now-playing toast has been an empty square since the shader rebuild: it was still asking for the effect by its old name. It failed quietly by design — caught, logged once, and the toast kept working — which is why it went unnoticed for so long."
    },
    {
      "title": "Fixed intermittent glitches and crashes on the OpenGL builds",
      "version": "2026.8.105",
      "date": "2026-09-05",
      "area": "Desktop",
      "type": "Bug fix",
      "userFacing": true,
      "details": "On the Linux, macOS and Windows OpenGL builds, work that finished in the background — a wave browser listing, signing in to a service, opening or saving a file — carried on running on a background thread instead of handing back to the one drawing the app. Two threads then changed the same screen at the same time. The result was not a tidy error at the point of the mistake: it was occasional wrong or missing items in a list, panels that drew half-updated, and the odd crash blamed on something innocent, all with no way to reproduce it on purpose. The DirectX build was never affected, which is why this was hard to see.\n\nThose builds now hand background results back to the drawing thread, the way the DirectX build already did, so the same code runs the same way everywhere. If one of those results does fail, it is reported and the app carries on instead of the window disappearing.",
      "tickets": [
        77
      ]
    },
    {
      "title": "Songs grow past their end — put a clip anywhere on the timeline",
      "version": "2026.8.104",
      "date": "2026-09-05",
      "area": "Sequencer",
      "type": "Bug fix",
      "userFacing": true,
      "details": "A new song was 8 bars and could never become longer. Everything that places something — dragging a clip, dropping a loop from the pattern explorer, stretching a clip's right edge — stopped dead at the last bar, usually with no message at all: the clip simply snapped back, or the drop evaporated. The only thing that lengthened a song was a clip ending past its end, and a clip could only be placed inside the end, so the number never moved. Nothing in the app could set it either — it is not in the New project dialog or in Song settings. The timeline drew about 60 bars of grid and you could use 8 of them.\n\nThe timeline now always offers a run of empty bars past the end of the song — a screenful, and never fewer than 16 — and you can scroll into them. Put a clip out there and the song grows to hold it, then offers a fresh run past the new end. Moving, dropping and resizing all reach it, so every bar you can see is a bar you can drop into.\n\nThe sound engine had the same limit, which would have made this half a fix: its song length was fixed when the song loaded, so a clip placed past the old end would have been skipped over in silence. It now follows the arrangement as you edit, and playback runs to the real end.",
      "tickets": [
        147
      ]
    },
    {
      "title": "Three references to the retired wave sources panel corrected",
      "version": "2026.8.104",
      "date": "2026-09-05",
      "area": "Core",
      "type": "Internal",
      "userFacing": false,
      "details": "Follow-up to #142. A comment in SequencerView still described an L shortcut that no longer exists, docs/view-menu-context.md still listed \"Toggle loop browser (L)\" among the sequencer's View menu items, and docs/wave-bank-design.md still called the wave picker's Loops browser \"floating\" — which is precisely the thing that stopped it working. No behaviour change; these are the stale references the deletion left behind.",
      "tickets": [
        142
      ]
    },
    {
      "title": "One sample browser, and the wave picker's “Loops »” works again",
      "version": "2026.8.103",
      "date": "2026-09-05",
      "area": "UI",
      "type": "Improvement",
      "userFacing": true,
      "details": "The separate Wave sources panel is gone — the docked window opened from the sequencer toolbar, the L key and View ▸ “Toggle wave sources”. Everything it browsed (Local folders, ezmuze central, Freesound, Internet Archive, Openverse) is the pattern explorer's Samples tab, which shows the same sources with waveforms and drag‑to‑place. Dragging a loop onto a channel, or onto the empty strip under the channels to make one, is unchanged. The panel's arm‑a‑loop‑then‑click‑a‑cell shortcut is not carried over; drag is now the way to place a loop. The wave picker's “Loops »” button opened this panel too, and had stopped showing anything at all: the panel was a window and the picker is a full‑screen view, so it opened underneath and stayed invisible. It is now a browser panel down the right‑hand side of the picker, wide enough to read the source names, and “Import selected” still adds the chosen pack loop to the wave bank.",
      "tickets": [
        142
      ]
    },
    {
      "title": "The shipped supporter snapshot carries real data now",
      "version": "2026.8.102",
      "date": "2026-09-05",
      "area": "Build",
      "type": "Internal",
      "userFacing": false,
      "details": "supporters.json shipped empty in the commit that added the credits roll, because the endpoint it comes from was not deployed yet. GET /api/credits is live, so the snapshot now holds what it returns. It is a fallback rather than the truth - the app prefers its own cache and replaces both from the endpoint whenever there is a network, so this only has to be good enough for a first run with no connection, does not need keeping current, and costs nothing when it goes stale. Checked before committing that the payload carries no amount in any form: no cents, cost, price, tier, amount or currency symbol, which is the contract the server side exists to keep."
    },
    {
      "title": "The credits screen names the people who support ezmuze",
      "version": "2026.8.101",
      "date": "2026-09-05",
      "area": "UI",
      "type": "New feature",
      "userFacing": true,
      "details": "Help > Credits now lists supporters. Patreon patrons and anyone holding a supporter licence from ezmuze central appear together, grouped with the largest pledges first and everyone who used to support underneath, under \"Thanks to all those who have previously supported us\". The groups are separated by nothing but a gap: no tier is named and no amount is printed anywhere, so you can see that the groups differ without the screen publishing what any person pays - the same line ezmuze central already draws, where a supporter badge says that somebody supports and never how much. Names are the ezmuze central username, the one they chose and the one the site already shows. It works with no network: the list is fetched from ezmuze central and remembered, and a copy ships inside the build so a first run offline still has something to show rather than an empty space. If the fetch fails, whatever it knew last stands and nothing is said about it - a credits screen is not worth an error message.",
      "tickets": [
        146
      ]
    },
    {
      "title": "Drop a sample under the channels and it makes the channel for you",
      "version": "2026.8.100",
      "date": "2026-09-04",
      "area": "Sequencer",
      "type": "Improvement",
      "userFacing": true,
      "details": "In a brand new song there was nowhere to put anything. Dragging a sample or a loop in did nothing at all - no clip, no message, no hint that anything was wrong - because whether a drop is allowed was decided per channel, and a new song's only channel is the master, which accepts nothing. You had to know to add a channel from the toolbar first, and nothing told you. The empty strip under the channel list is now a drop target: let go there and the right channel is created and the thing you dragged lands on it. What gets made depends on what you dropped - a sample, a wave from the bank or a pattern makes a wave channel, while a loop makes a channel of its own kind, so dropping a bass loop gives you a bass channel. It also says so while you are dragging: the outline appears in that strip and the label reads \"<name> - new channel\", instead of the drag marker simply vanishing when you left the grid, which was the old way of hiding that there was nowhere to go. Dropping onto a channel that already exists is unchanged. One thing to know: undoing this takes two presses, one for the clip and one for the channel, because the channel has to exist before anything can be placed on it.",
      "tickets": [
        144
      ]
    },
    {
      "title": "One wave bank instead of two",
      "version": "2026.8.99",
      "date": "2026-09-04",
      "area": "UI",
      "type": "Improvement",
      "userFacing": true,
      "details": "The song's wave bank had two homes: a panel of its own that docked at the side, and a tab in the pattern explorer that had quietly become the better of the pair - the same slots, but with the whole sample drawn, drag-onto-a-channel to make a pattern, and click-to-preview. The panel is gone. Before it went, the one thing it could do that the tab could not moved across: every wave in the tab now has a bin icon to take it out of the song again, undoable like everything else. View > \"Toggle wave bank\" still exists and still says that; it opens the explorer on the Wave bank tab now, and shows it rather than toggling, because closing the whole explorer would be a strange answer to \"show me the wave bank\". Dropping a .wav on the sequencer still tells you where it went by opening that tab. One thing did NOT come across and is worth saying plainly: the old panel's \"+ from browser\" button, which took whatever loop was selected in the loop browser and put it in the bank as a pack reference. A pack loop still reaches the bank by the ordinary routes, but that particular one-click path is not there any more.",
      "tickets": [
        143
      ]
    },
    {
      "title": "The Windows OpenGL download would not start at all",
      "version": "2026.8.98",
      "date": "2026-09-04",
      "area": "Build",
      "type": "Bug fix",
      "userFacing": true,
      "details": "If you downloaded the win-x64-gl build, it did not run. Not slowly, not with a missing feature - it closed during startup without ever drawing a window, because the installer did not contain SDL2.dll or soft_oal.dll. Those two libraries are what the OpenGL build uses to open a window and make sound, and they were being left behind: the package that supplies them puts them beside the built app, and the step that packages a release copies what was PUBLISHED, which is not the same folder. The Linux build already copied them across by hand; Windows never did. They now travel with any publish of that build, so this cannot depend on the release script remembering, and the release script additionally refuses to package a Windows OpenGL build that is missing them rather than shipping one that cannot start. The DirectX build - the default download, and the one most people run - was never affected: it uses DirectX and XAudio2 and needs neither library, which is also why this went unnoticed for so long.",
      "tickets": [
        141
      ]
    },
    {
      "title": "The SDL2 clipboard resolver names the macOS library, instead of relying on a fallback",
      "version": "2026.8.97",
      "date": "2026-09-04",
      "area": "Desktop",
      "type": "Bug fix",
      "userFacing": false,
      "details": "SystemClipboard maps its SDL2 import onto whichever filename the platform actually ships, and the candidate list was three Linux names: libSDL2-2.0.so.0, libSDL2.so, SDL2. None of them exist on macOS, where the DesktopGL package ships libSDL2.dylib. It would have worked anyway - a resolver returning zero delegates to .NET's own probing, which does find libSDL2.dylib beside the assembly - but resting a feature on what happens AFTER the code fails is a poor arrangement, and that list is the first place anyone looks when the clipboard is dead on a platform. libSDL2.dylib is named explicitly now. No behaviour change on Windows or Linux."
    },
    {
      "title": "Only the web build ships a wwwroot now; desktop content lives in data/",
      "version": "2026.8.96",
      "date": "2026-09-04",
      "area": "Desktop",
      "type": "Internal",
      "userFacing": false,
      "details": "A published desktop build shipped a folder called wwwroot. It was named after its ROLE and that much was fair - LocalContentHandler is a fake HTTP server over a directory, so SongRepository can fetch demos and songs by relative URL on web and desktop through one code path, and a document root is what the folder is. It was a poor description of the contents: there is no web server in a desktop build, nothing in there is served over HTTP, and a DAW shipping a directory whose name says otherwise invites exactly the question it got. It is now data/, and only the web head has a wwwroot. In a checkout the SOURCE is still the web project's wwwroot, which is correct - that is where the demos and songs are authored and shipped from - and LocalContent.targets still copies just the three things desktop actually asks for. On macOS the folder goes further, to Contents/Resources/data, which is where Apple's convention puts data; LocalContentHandler probes ../Resources/data for that case. It cannot go further than that for anything else: .NET resolves assemblies, DllImport and AppContext.BaseDirectory from the directory holding the executable, so the managed DLLs, the .xnb content and the dylibs have to stay beside the apphost, and a self-contained .NET app in a .app bundle looks the way it looks. The handler still checks for a legacy wwwroot beside the exe before giving up, for an install upgraded in place - the failure it prevents is a startup crash with no window, which is what 2026.8.2 did, and that is worth three lines of defence. Verified by publishing the Windows head and running it: no wwwroot in the output, the demos load from data/, and the app reaches the welcome screen at 60fps."
    },
    {
      "title": "Signing out no longer throws away the packs you have downloaded",
      "version": "2026.8.95",
      "date": "2026-09-04",
      "area": "Services",
      "type": "Bug fix",
      "userFacing": true,
      "details": "Sign out of ezmuze central and sign back in, and the pack downloader ran through every pack you own all over again. The record of which packs this machine holds was kept in the same place as everything else the service caches, and signing out emptied the lot - so on the way back in the app genuinely believed it had never seen any of them. On desktop it was worse than forgetting: the files were DELETED and re-fetched over the network, hundreds of megabytes of it, because the sign-out wiped the cache folder whole and the packs live inside it. In the browser the packs themselves survived on disk and were served from there, so the cost was mostly the downloader appearing at all. Signing out now clears what belongs to the account - your licences, artwork, cached listings - and keeps the packs, which are the expensive part and are not personal: what you are allowed to USE is decided by the licence list fetched when you sign in, every time, and that has not changed. A second, quieter half of the same fault went with it: in the browser, signing out never actually cleared the cached listings at all, so the previous account's licence list was still sitting there afterwards. It is cleared now.",
      "tickets": [
        140
      ]
    },
    {
      "title": "The macOS head's signature check reads the binary instead of guessing at a tool's output",
      "version": "2026.8.95",
      "date": "2026-09-04",
      "area": "Build",
      "type": "Internal",
      "userFacing": false,
      "details": "The first macOS build failed, and not because signing failed - signing worked. The check after it ran `rcodesign print-signature-info | grep -q code_signature`, which was a guess at another program's output format made by someone with no Mac to test it on. The guess was wrong, a correctly signed apphost was reported as unsigned, and the build died - taking three sibling heads with it, because the matrix is fail-fast. It is now tools/release/check-macho-signed.py, which reads the Mach-O directly: it finds LC_CODE_SIGNATURE, walks the embedded SuperBlob to the CodeDirectory, and rehashes every page of the binary against the hashes stored there. That is the question dyld actually asks when it decides whether to run an image, and it is a stronger check than the old one was even meant to be - presence of a signature proves nothing on its own, because the .NET apphost arrives from the runtime pack already signed and `publish` then patches it. It also runs over every dylib rather than the apphost alone, since the app is only as launchable as the least-signed thing it loads. Unlike the thing it replaces, it was tested before it shipped: against known-good signed binaries, against one with a single byte of code flipped, and against a file that is not Mach-O at all."
    },
    {
      "title": "ezmuze studio now builds for the Mac",
      "version": "2026.8.95",
      "date": "2026-09-04",
      "area": "Build",
      "type": "New feature",
      "userFacing": true,
      "details": "There is a fifth download: an Apple Silicon build, as ezmuze studio.app inside a .tar.gz, alongside the two Windows installers and the Linux archive. It is the same app - DesktopGL is already the Mac's graphics path, so this rides the road the Linux build opened rather than a new one, and the only genuinely new code is the file dialogs, which go through the real Mac Open/Save panels. The whole thing is built on the Linux release box; no Mac takes part in making it. Two things to know before you rely on it. First, NOBODY HAS EVER LAUNCHED ONE - it compiles, bundles and signs, and all of that is checked, but no Mac has run it and nothing on the build machine can, so treat this first one as untested and please report what you find. Second, the first launch will be refused: the app is signed well enough for Apple Silicon to run it at all (an unsigned one is killed outright, with no dialog to click past) but it is not notarised, so macOS greets a fresh download with \"is damaged and can't be opened\". It is not damaged. Open System Settings, go to Privacy & Security, and press Open Anyway - after that it starts normally. Intel Macs are not covered yet, though nothing rules them out. Like the Linux build, it tells you when there is an update and hands you the archive rather than replacing itself.",
      "tickets": [
        139
      ]
    },
    {
      "title": "Desktop heads say which platform they are, instead of being identified by elimination",
      "version": "2026.8.95",
      "date": "2026-09-04",
      "area": "Build",
      "type": "Internal",
      "userFacing": false,
      "details": "The shared project used to work out that it was on Windows by ruling Linux out - `#if DESKTOPGL && !LINUXGL`, across seven files plus the DPI query and one guard in Program.cs. That holds only while there are exactly two DesktopGL platforms, and it fails in the worst available direction when a third arrives: a Mac head defining DESKTOPGL satisfies the Windows test and silently compiles the WinForms and user32 branch on a runtime where neither exists. Every head now declares what it IS - WINDESKTOP, LINUXGL or MACGL - and Data/DesktopHead.cs carries the convention plus two #error guards, one for a DESKTOPGL head that declares none of them and one for a head that declares two. Adding a fourth platform is now additive and cannot misfire quietly. Note that BOTH Windows heads define WINDESKTOP: the DX head defines DESKTOPGL as well as DESKTOPDX and has always shared the WinForms branch, so putting the constant on the GL head alone leaves DX compiling neither arm. The posix heads share an arm (`LINUXGL || MACGL`) wherever they genuinely agree - the SDL2 clipboard, DPI, the shell-out file dialogs - and split only where they do not. No behaviour changes on any existing head; all four still build with identical output."
    },
    {
      "title": "One pack's cover art was missing in the browser",
      "version": "2026.8.94",
      "date": "2026-09-04",
      "area": "Web",
      "type": "Bug fix",
      "userFacing": true,
      "details": "Better Way Of Life Remix Pack showed no cover in the web build while looking fine on desktop. Its artwork lives on a different one of our CDN names than everything else - the catalogue stores whichever host each image was uploaded under, and rows written years apart disagree - and only one of those names was on the list of hosts the browser build is allowed to fetch through. Desktop never goes through that list, which is why it only ever went missing in a browser. All three CDN names are the same machine behind one certificate and are all allowed now."
    },
    {
      "title": "Pack downloads report in a toast, with a bar you can believe",
      "version": "2026.8.94",
      "date": "2026-09-04",
      "area": "UI",
      "type": "Improvement",
      "userFacing": true,
      "details": "Getting your packs used to open a window in the middle of the screen with a row for every pack, over the top of whatever you had opened the app to do - for a job that needs nothing from you and that the window itself invited you to ignore. It is now a toast in the corner with two bars: the top one is packs finished against packs to fetch, with (3/8) under it, and the one below is the pack currently coming down, with its percentage under that. Two bars because one cannot tell the truth here - fetching eight packs, a single bar either steps in eighths and looks frozen inside each one, or runs to 100 eight times and never says how much is left. The percentage is also real now: the download used to report 5%, go quiet for the whole of a hundred-megabyte transfer, and then report 100, which reads as a hang rather than a wait. It now reports as the bytes actually arrive. When there is only one pack to fetch the top bar is dropped altogether - it would only ever read empty then full, and \"(1/1)\" is not a fact anybody needed - so you get that pack's bar and its percentage and nothing else. Closing the toast still does not cancel anything - the download is what signing in asked for, and closing the thing that reports a job should never be what abandons it - but now it does not lose the ending either: dismiss it and you still get a brief \"packs ready\" when the job finishes, and a failure still puts itself on screen and stays there, because closing a progress bar means \"I don't need to watch this\" and not \"don't tell me if it breaks\"."
    },
    {
      "title": "The browser keeps your packs between visits",
      "version": "2026.8.93",
      "date": "2026-09-04",
      "area": "Web",
      "type": "Performance",
      "userFacing": true,
      "details": "Opening ezmuze studio in a browser re-downloaded every pack you own, every time. With a well-stocked account that is around 500 MB on each visit. The browser build had no store of its own - its cache lived in the page's memory and went with the page - so as far as it was concerned it had never seen any of them before. Packs are now kept on disk by the browser and served from there, so the first visit downloads them and later visits do not. Two things had to be stored in different places for that to work: the packs themselves, which are far too large to put through the store the app keeps its settings in, are held by a small worker that answers the download from disk; and the short record of WHICH packs you have, which is what stops the downloader opening at all. The app also asks the browser to keep this content rather than clear it when space runs short - browsers may say no, in which case a pack is simply fetched again. Desktop already worked this way and is unchanged, though it also stops reading a whole 138 MB pack merely to check whether it has one."
    },
    {
      "title": "The pack downloader reads properly while it works",
      "version": "2026.8.92",
      "date": "2026-09-04",
      "area": "UI",
      "type": "Improvement",
      "userFacing": true,
      "details": "Each pack's state used to sit on its own line directly above that pack's progress bar, close enough to read as sitting on it. The bar is a little shorter now and the state sits beside it, right-aligned in a column of its own, in the smaller caption face - so the words and the bar they describe are one line rather than two, and the numbers stay in a column instead of jittering left and right as \"waiting\" becomes \"7%\" becomes \"ready\". It also fixes something worse that only showed up when a download failed: the message is whatever the server said, and a long one wrapped, grew the row from the inside and printed over the name of the pack below it. The state is now one line, shortened with an ellipsis if it has to be. And a failure leads with the reason rather than with \"couldn't get it\", which used to fill the whole line before the useful half of the sentence arrived."
    },
    {
      "title": "Signing in as a supporter now actually opens the beta gate",
      "version": "2026.8.91",
      "date": "2026-09-04",
      "area": "Services",
      "type": "Bug fix",
      "userFacing": true,
      "details": "Signing in worked, your supporter licence checked out, and the gate stayed up anyway - and refreshing the page let you straight in. The gate caches when it last checked, and it read that timestamp back using a combination of date-parsing options that .NET does not merely dislike but refuses outright. It could only fail once a timestamp existed to read, which is to say from the moment your first successful check wrote one: before that the gate worked, and after it every evaluation threw before it could decide anything. The refresh appeared to fix it because the failure happened before the gate was ever put on screen, so there was simply no wall to be stuck behind. The timestamp is now read correctly, with tests that fail if the old spelling ever comes back. And should the check fail for any reason we have not thought of, the gate now lets you in and writes down why rather than throwing away the error - it is a supporter check, not a lock, and a bug of ours should never be the thing standing between a supporter and the application.",
      "tickets": [
        39
      ]
    },
    {
      "title": "Pack downloads use https",
      "version": "2026.8.90",
      "date": "2026-09-04",
      "area": "Services",
      "type": "Improvement",
      "userFacing": false,
      "details": "EzmuzeCentralHosting.Cdn was plain http, with a comment explaining that the CDN host had no working certificate. That was true when it was written and stale by the time anyone read it again: nginx-proxy-manager issued a Let's Encrypt certificate covering cdn, fastcdn and oldcdn on 2026-08-23 and renews it automatically. Checked as the same origin before switching rather than assumed - the same asset over http and https comes back byte-identical with the same ETag and Last-Modified - so this changes how a licensed pack travels and nothing about what arrives. A paid download had no business crossing the network in the clear once it did not have to. NOTE: the product catalogue still lists most cover-art URLs as http://cdn.ezmuze.co.uk/..., which is data on the server rather than anything in this build."
    },
    {
      "title": "The beta gate offers a way in, and no longer offers to quit your browser",
      "version": "2026.8.89",
      "date": "2026-09-04",
      "area": "UI",
      "type": "Improvement",
      "userFacing": true,
      "details": "Two changes to what the supporter gate puts in front of you. If you are signed in and your account does not hold a supporter licence, there is now a \"Get beta access\" button that opens the page explaining how to get one in a new tab - being told no is only useful alongside how to change it, and it is offered only when central actually said no rather than when it could not be reached. And the web build no longer shows \"Quit\": there is no application to close in a browser, the tab is yours, and a button that either does nothing or looks like it should have is worse than no button. The desktop keeps it - a window that will not let you in and will not let you out is a trap. The row is now centred for whatever buttons it actually has.",
      "tickets": [
        39
      ]
    },
    {
      "title": "The beta supporter check works in the browser",
      "version": "2026.8.89",
      "date": "2026-09-04",
      "area": "Services",
      "type": "Bug fix",
      "userFacing": true,
      "details": "On the web build the supporter check never reached ezmuze central at all, so a signed-in supporter was still refused. Everything the app asks of a service is rewritten to go through our own host, because none of the service APIs will answer a browser directly - and this one call was built in a way that skipped that rewrite. It went straight out, was refused for being cross-origin, and the app could only report that it had not been able to ask; with nothing cached to fall back on, that means no entry. Desktop was never affected, because the rewrite does nothing there. The check now goes the same way as every other request, and when it does fail it writes down why instead of failing silently - that silence is what made this look like being offline.",
      "tickets": [
        39
      ]
    },
    {
      "title": "Signing in works in the browser again",
      "version": "2026.8.88",
      "date": "2026-09-04",
      "area": "Web",
      "type": "Bug fix",
      "userFacing": true,
      "details": "Signing in to ezmuze central from the web build failed at the last step - you approved the computer, and the app then said the sign-in had expired or been rejected. The browser build cannot call the service APIs directly, so every request goes through a proxy on our own host, and that proxy was decoding the address one time too many: the routing layer had already decoded it once. Almost nothing noticed, because decoding twice does nothing when there is nothing left to decode. The exception was the one thing that mattered - the sign-in token, which is written in an alphabet whose \"+\" has to be escaped, and which the extra decode turned back into a plain \"+\", which anything reading a web address treats as a space. So the browser redeemed a token with spaces where its plus signs should have been, and was told, quite correctly, that no such token existed. The proxy now reads the address exactly as it arrived. The desktop builds never used the proxy and were never affected.",
      "tickets": [
        39
      ]
    },
    {
      "title": "The beta gate no longer hides its own sign-in box, and cannot be resized around",
      "version": "2026.8.87",
      "date": "2026-09-04",
      "area": "Services",
      "type": "Bug fix",
      "userFacing": true,
      "details": "Two ways past - or into - the supporter gate on a beta build. It drew itself far above everything else on screen, which is what a gate is for, but that included the sign-in dialog it opens itself: clicking \"Sign in\" put the box up behind the wall, so the one button that leads anywhere appeared to do nothing. The dialog is now raised above the wall rather than the wall lowered underneath it, which matters - dropping the wall let whatever else was on screen come up through it. Separately, the wall measured the window once, when it appeared, so resizing the window left it at the old size with the application visible and clickable around the edges. It now tracks the window every frame and re-centres what it says. The link it hands you to approve this computer also points at www.ezmuze.co.uk again, which is where ezmuze central now lives.",
      "tickets": [
        39
      ]
    },
    {
      "title": "GustUI: a modal window can be given an explicit depth ceiling",
      "version": "2026.8.87",
      "date": "2026-09-04",
      "area": "UI",
      "type": "Internal",
      "userFacing": false,
      "details": "ModalWindowElement.DepthCeiling overrides both the default clamp below the modal tier and the FloatAboveModalTier opt-in, for the rare owner that draws above the modal tiers entirely and still has to show a dialog over itself. The beta gate is the first: a full-window wall at a depth far above everything, whose only way forward is a dialog it opens itself and was burying. A one-shot Depth assignment at spawn cannot do this - MoveToFront runs on the modal's first Update and again on every title-bar drag, and re-clamps each time, so the ceiling is what has to move. Null, the default, changes nothing for every other modal."
    },
    {
      "title": "A holding page for ezmuze.studio, and the script that swaps it in",
      "version": "2026.8.86",
      "date": "2026-09-04",
      "area": "Build",
      "type": "Internal",
      "userFacing": false,
      "details": "tools/release/comingsoon.sh points ezmuze.studio at the countdown on www.ezmuze.co.uk/comingsoon, and puts the application back. It swaps the same web root and restarts the same container as publish.sh, with the same extract-then-swap so the site is never serving a half-written directory. What it installs is a PAGE that redirects rather than an HTTP 301, because publish.sh finishes a promote by asserting ezmuze.studio returns exactly 200 with no -L to follow a redirect - a real redirect there would kill the next promote. The page redirects three ways (a script replace, a meta refresh, and a plain link) so it is not a dead end without JavaScript. A promote deliberately overwrites it: shipping puts the application back where the countdown was, which is what launch should do, but it does mean promoting before 1 October 2026 quietly removes the holding page and this script is how it goes back."
    },
    {
      "title": "The sign-in box grows to fit what it has to tell you",
      "version": "2026.8.86",
      "date": "2026-09-04",
      "area": "Services",
      "type": "Bug fix",
      "userFacing": true,
      "details": "The sign-in dialog measured its height once, when it opened, against the word \"Starting...\". Anything longer that arrived afterwards was clipped - which in practice meant the ERROR, the one message in that box worth reading, was cut to a single line. It now re-measures and grows whenever the status changes, so a long message is shown in full, and that applies to the taller device-code layout with the QR too. Its text is also in the smaller face throughout: the body face is sized for prose and made a sign-in box read like a paragraph."
    },
    {
      "title": "Beta builds are for supporters",
      "version": "2026.8.86",
      "date": "2026-09-04",
      "area": "Services",
      "type": "New feature",
      "userFacing": true,
      "details": "A beta build now checks that your ezmuze central account holds an active supporter licence, and stops if it does not. Release builds are unaffected - the check only exists on betas - and so is a local development build, which carries no channel at all. The app never learns what a supporter tier is: it asks central one question and is told yes or no, so the rules about who qualifies can change without shipping a new build, and a Patreon pledge linked to your account will simply become a second reason for the same answer. It does not need to ask every time you start: once it has checked successfully it keeps working offline for a fortnight, because insisting on the server at every launch would lock out a supporter on a train and would turn any outage at our end into an outage for everyone who paid. And it distinguishes the two things that can go wrong - being told no, and being unable to ask - because telling somebody with no internet that they are not a supporter would be a guess.",
      "tickets": [
        39
      ]
    },
    {
      "title": "VST3 teardown and the Splice download watcher say when something goes wrong",
      "version": "2026.8.85",
      "date": "2026-09-04",
      "area": "Splice",
      "type": "Bug fix",
      "userFacing": false,
      "details": "Two audit findings, both the same shape: a bare catch swallowing something that mattered. VST3 teardown wrapped eight COM calls in catch {} - a plugin that faulted while being unloaded left no trace at all, and the failure then surfaced on the NEXT load of the same plugin with nothing connecting the two. Teardown still cannot be allowed to throw, so it still keeps going; it just names the fault in the log now. The Splice download watcher's wake callback caught everything and dropped it; it now distinguishes the two expected cases (a wake already pending, and the wait having finished) from a genuine surprise, which gets logged. Its semaphore is also capped at one pending wake, so a burst of filesystem events during a large download can no longer pile up a count the wait loop then spends running the settle-check once per queued event instead of once per half second. Nothing user-visible changes; both were about being able to diagnose the next report rather than about behaviour.",
      "tickets": [
        81,
        82
      ]
    },
    {
      "title": "Presets get a proper browser, with folders you can make and move things between",
      "version": "2026.8.84",
      "date": "2026-09-04",
      "area": "UI",
      "type": "New feature",
      "userFacing": true,
      "details": "The preset list was a flat dropdown - four of them, in fact, because the Stack, the module builder, the module panel designer and Korben each built their own copy of the same menu. That is replaced by one two-panel browser: folders on the left, the presets in the folder on the right. It STAYS OPEN when you click a preset, so you can try one after another instead of reopening a menu every time, which is what made comparing presets a chore. Presets now carry a free-text category that works as a folder path, so \"daves presets/drum kits\" files something two levels down; New folder makes one (a typed path makes every level at once), Save here saves the current device into whatever folder you are looking at, and right-clicking a preset offers Delete, Move to and Copy to. Anything with no category lives in Unsorted, which is where every preset you already had will be. There is no folder list stored anywhere - a folder exists exactly as long as some preset is filed in it, so moving the last one out makes it disappear and nothing can get out of step.",
      "tickets": [
        133
      ]
    },
    {
      "title": "Home and End jump the playhead, with buttons to match",
      "version": "2026.8.83",
      "date": "2026-09-04",
      "area": "Sequencer",
      "type": "New feature",
      "userFacing": true,
      "details": "Home and End were not bound to anything. In the sequencer, Home now takes the playhead to the start of the time selection if there is one and to the start of the song otherwise; End takes it to the end of the selection, or to the end of the LAST PATTERN - the last tick any clip reaches, rather than the song's declared length, which is routinely longer than anything written into it and would drop you in empty space. Either way the view scrolls so the playhead is on screen when you get there, because a jump you cannot see has told you nothing. The transport has skip-to-start and skip-to-end buttons either side of play, in the order every transport puts them. In the piano roll the same two keys and the same two buttons mean the CLIP you opened: Home to its start, End to its end, tick-accurate so a clip trimmed off the bar line lands where it actually starts.",
      "tickets": [
        132
      ]
    },
    {
      "title": "Channel headers use the space you give them, and lanes sit under the channel they belong to",
      "version": "2026.8.82",
      "date": "2026-09-04",
      "area": "Sequencer",
      "type": "Improvement",
      "userFacing": true,
      "details": "Widening the channel header column now actually buys you something. Mute and Solo hold station against the right-hand edge instead of sitting at a fixed position mid-row, so the channel name gets every pixel of the space you dragged open - it grew from a 94px box to over 320 on a wide column, and names that used to be truncated now fit. The balance slider is a fixed size: it used to stretch to fill whatever was left, which on a wide column turned a balance control into a metre-long slider where the centre was a lucky guess. The resize handle itself lights up when you point at it, so there is something to aim for. The small marker that says a channel has content further along the song has moved out of the button cluster to the bottom left of the row, stepping aside on rows too short to have a spare corner. And lanes: a channel's automation and trigger-FX lanes now line up under the channel they belong to, always appear BEFORE that channel's child channels rather than among them, and the + Add lane row goes away with them when the lanes are collapsed - it used to be left behind, under a channel with no lanes showing.",
      "tickets": [
        138
      ]
    },
    {
      "title": "The channel header column can be resized again, and lanes line up under their channel",
      "version": "2026.8.81",
      "date": "2026-09-03",
      "area": "Sequencer",
      "type": "Bug fix",
      "userFacing": true,
      "details": "The drag handle on the right edge of the channel header column was never attached to anything. It was built, wired up, given a tooltip and repositioned on every layout pass - and never added to the screen, so it was invisible, could not be clicked, and the column was stuck at whatever width it worked out for itself. It is there now, and dragging it widens the headers up to half the window, with the name, volume and meter spreading out to use the space. Separately, a channel's automation and trigger-FX lanes now start at the same indent as the channel they belong to instead of flush against the left edge, so a nested group reads as a group rather than as a flat list of unrelated rows.",
      "tickets": [
        135
      ]
    },
    {
      "title": "The module builder zooms, pans, and opens showing the whole graph",
      "version": "2026.8.80",
      "date": "2026-09-03",
      "area": "UI",
      "type": "New feature",
      "userFacing": true,
      "details": "The node canvas was fixed to the window: a graph bigger than it was simply cropped, with no wheel, no drag and no scrollbars, so most of a real device could not be seen OR reached. Cerebrum is 139 components and only a fraction of it was ever on screen. Opening a device now fits the whole graph in the view. Drag the background to pan, wheel to scroll and Shift+wheel to scroll sideways, Ctrl+wheel to zoom about the pointer, Home to fit again and Ctrl+0 to go back to 100%; there are scrollbars down two edges and Fit and 100% buttons on the toolbar. Zooming is a real re-layout rather than a stretched picture, so the type stays sharp and every knob, slider and port is still exactly where it is drawn and still clickable. The device's own Inputs and Outputs rails deliberately do not scale - they are the edges of the device rather than part of the graph, and they stay readable and reachable however far out you zoom.",
      "tickets": [
        129
      ]
    },
    {
      "title": "The control API can hold Ctrl or Shift while it scrolls",
      "version": "2026.8.80",
      "date": "2026-09-03",
      "area": "Desktop",
      "type": "Improvement",
      "userFacing": false,
      "details": "POST /scroll takes optional \"ctrl\" and \"shift\" flags, held on the same frame as the wheel delta the way /key already does for a key. Ctrl+wheel and Shift+wheel are usually different gestures from a plain notch - zoom and sideways scroll - so without this the API could drive only the least interesting of the three, and the module builder's new Ctrl+wheel zoom could not be verified from a test at all. A handler has to read StaticResources.InputManager.CurrentKeyboardState rather than Keyboard.GetState() to see it, which is now documented alongside the endpoint."
    },
    {
      "title": "Grab a stretch of a pitch bend and move it, control points and all",
      "version": "2026.8.79",
      "date": "2026-09-03",
      "area": "UI",
      "type": "New feature",
      "userFacing": true,
      "details": "In the piano roll's Bend tool, dragging the curve between two control points now moves that stretch - up and down in pitch, left and right in time - carrying the two points that bound it and leaving the rest of the bend alone. It stops against the neighbouring points rather than passing through them, and the note's own start and end stay put, because those are the note's edges rather than part of the shape. The drag sounds where it is going: the pitch under the cursor as the curve moves, so dragging through the keys walks them, instead of holding the note's written root. Adding a control point is now a DOUBLE-click on the curve, which still adds it and lets you shape it in the same motion. Moving a whole note is unchanged and stays in the Notes tool. Two older faults surfaced doing this and are fixed as well: a bend point could not be placed or dragged further than two octaves from its note, which was less than the app's own \"Too many chords\" study already used - so on those notes a point added on the curve landed five semitones below it and a drag would not follow the cursor at all.",
      "tickets": [
        128
      ]
    },
    {
      "title": "Delete removes the trigger FX you clicked, not the last clip you touched",
      "version": "2026.8.78",
      "date": "2026-09-03",
      "area": "Sequencer",
      "type": "Bug fix",
      "userFacing": true,
      "details": "Clicking a trigger-FX block and pressing Delete removed a CLIP instead - whichever one had been selected last, somewhere else in the arrangement, which is about the worst thing a destructive key can do. Underneath it, a TFX block had no notion of being selected at all: a click on one only ever began a drag, so Delete found nothing to act on and fell through to the clips. Blocks can now be selected. A click that does not move one selects it - it draws with the selection outline and the status bar says so - and Delete then removes that block and nothing else. Selecting a clip clears the TFX selection and vice versa, so a destructive key can only ever mean one thing, and the removal undoes like any other edit.",
      "tickets": [
        131
      ]
    },
    {
      "title": "A synth edit is heard immediately, instead of the sound it had when the song loaded",
      "version": "2026.8.77",
      "date": "2026-09-03",
      "area": "Sound engine",
      "type": "Bug fix",
      "userFacing": true,
      "details": "Applying a preset to a device could leave the panel showing the new patch while the channel went on playing the old one - and touching any knob afterwards put it right, which is what made it so confusing. The pre-rendered audio a channel plays from was filed under the pattern alone, and nothing anywhere ever threw an entry away: once a body had been rendered it was served for ever, however much the synth changed underneath it. It is now filed under what it was rendered FROM as well, so audio made by a different patch is refused rather than played - the channel falls back to rendering live, which is what it already did before anything was cached, and goes back to the cheaper pre-rendered path as soon as the new version is ready. Not a trigger-FX bug, though that is where it surfaced: TFX lanes put more channels on the pre-rendered path, which made an old fault easy to hit.",
      "tickets": [
        98
      ]
    },
    {
      "title": "Move and resize a trigger FX like any other block",
      "version": "2026.8.76",
      "date": "2026-09-03",
      "area": "Sequencer",
      "type": "Improvement",
      "userFacing": true,
      "details": "A trigger effect you have already placed can be dragged along the lane, and its edges grabbed to resize it - the same 10-pixel handles a clip uses, and only on a block wide enough that grabbing the middle to move it is still possible. Everything snaps to the grid unless Shift is held. A moved region keeps its OWN effect rather than picking up whichever one is armed, and it no longer collides with the hole it just left. Clicking a block no longer stamps a new region on top of it, which is what used to happen; clicking empty space still places one. A press that moves nothing costs no undo step. This matters most in the NRT lane, where a region's length IS the effect: pulling a reverse from one bar to two changes what it does, not just how long it does it.",
      "tickets": [
        98
      ]
    },
    {
      "title": "NRT TFX: reverse, retrigger and the rest of legacy's trigger effects",
      "version": "2026.8.75",
      "date": "2026-09-03",
      "area": "Sound engine",
      "type": "New feature",
      "userFacing": true,
      "details": "A third lane, \"Add NRT TFX\", carrying the half of legacy's TriggerFX that rearranges time rather than filtering it: Reverse, Retrigger x2/x4/x8, Shuffle 1 and 2, Tape Stop, Half Speed, Double Up and a Stutter Hold. They were impossible in the other two lanes and this is why: an insert answers a tick at a time, and reverse means the first sample out is the last sample in, so the effect has to hold the whole region before it can say anything about the start of it. That is only possible where the audio already exists - after the generator, whose bar is rendered before the effects run. Hence \"non real time\": not about when it plays, but about what it needs. The region's LENGTH is part of the effect here rather than just when it fires - a reverse across two bars puts what was at the end of the second at the start of the first, which is why dragging a longer region had to come first. Each lane only offers the effects it can actually run.",
      "tickets": [
        98
      ]
    },
    {
      "title": "Drag a trigger FX out to the length you want",
      "version": "2026.8.74",
      "date": "2026-09-03",
      "area": "Sequencer",
      "type": "Improvement",
      "userFacing": true,
      "details": "A click still places one grid step, which is the gesture the lane is mostly used with, but dragging now draws the region out to whatever length you pull - with a ghost showing it as you go, the same way a clip drag does, because a length you cannot see until you let go is a length you get wrong. Dragging backwards works and means the same as dragging forwards over the same ground. The whole drag is one undo step however many bars it crosses. The two menu items are now \"Add PreTFX\" and \"Add PostTFX\".",
      "tickets": [
        98
      ]
    },
    {
      "title": "Trigger FX are actually audible",
      "version": "2026.8.73",
      "date": "2026-09-03",
      "area": "Sound engine",
      "type": "Bug fix",
      "userFacing": true,
      "details": "A trigger-FX lane drew its regions, armed its effect and did nothing to the sound. Most channels are not heard through the graph the lane was spliced into: a channel whose body is cached is played back through a DOWNSTREAM graph over that cached audio, and a channel with no effects of its own ran neither graph at all - so on the ordinary case, a loop or a pattern with nothing after it, the lane had nowhere to happen. The slots are now spliced into the downstream compile too, and a channel with a trigger effect gets a downstream graph even when it has no effects, which is precisely what a lane on such a channel means. One consequence guarded on the way: a lane whose effect names a module this build does not have no longer claims that graph, because an empty downstream chain outputs silence and would have muted a working channel outright.",
      "tickets": [
        98
      ]
    },
    {
      "title": "27 trigger effects ship with the app",
      "version": "2026.8.72",
      "date": "2026-09-03",
      "area": "Sound engine",
      "type": "New feature",
      "userFacing": true,
      "details": "A trigger-FX lane opens on a full palette rather than an empty one. Six of them are legacy's own, under the names they had: In Place and its flanger and chorus, Pitch Down, Pitch Up and Mute - the TriggerFX legacy itself built as inserts, and between them a third of every effect placed across the 140-song corpus. The rest are what a trigger lane obviously wants and legacy never had: filter slams, a telephone, a screamer, bit and rate crush, tape drive, a metal comb, a phase sweep, three vowels, echo and reverb throws, a ping-pong, air, and mono/wide. Legacy's other TriggerFX are not here and cannot be - retrigger, reverse, shuffle and tape stop move the PLAYER rather than processing a stream, and imports already handle them as notes and rate lanes. The lane's palette now shows the effect you are drawing with and clicking it picks another, which is also how you take one out.",
      "tickets": [
        98
      ]
    },
    {
      "title": "Trigger FX: throw an effect on and off, on the beat",
      "version": "2026.8.71",
      "date": "2026-09-03",
      "area": "Sequencer",
      "type": "New feature",
      "userFacing": true,
      "details": "A channel can now carry up to two trigger-FX lanes - one before its effects, one after them - and each is a strip of time you draw effects onto. Add one from \"+ Add lane\" on an expanded channel, fill its palette from your TFX presets with \"+ FX\", then click the strip to throw that effect on for a quarter-bar. Click again further along for another hit; right-click a block to take it away. Everything snaps to the quarter-bar grid unless you hold Shift, which slips it off. A preset becomes a trigger FX by being given a short code when you save it - up to four characters, because that is what fits in the strip, and it is what the lane draws. Where you put the lane matters: before the effects, a stutter is something the reverb then hears; after them, it is the last word. There is no automation on a trigger FX and there is not meant to be - it is a switch you throw on the beat, and a fade would make it something blurrier.",
      "tickets": [
        98
      ]
    },
    {
      "title": "TFX presets, and undo for a TFX lane",
      "version": "2026.8.70",
      "date": "2026-09-03",
      "area": "Core",
      "type": "Internal",
      "userFacing": false,
      "details": "Fourth piece of the TFX lane (#98). A TFX preset is an ordinary preset with one extra promise - that it is worth throwing on and off mid-bar - so it is a flag and a short code on the preset that already exists rather than a parallel library that would have to reimplement saving, listing and applying in order to differ in one boolean. Turning a preset into a lane slot COPIES its values, because a song has to sound the same on a machine that has never heard of the preset it was made from. And three undo steps rather than one, because the lane, its palette and its regions are things a user changes independently: undoing the region you just dropped should not also take back the effect you spent a minute choosing."
    },
    {
      "title": "A TFX lane's effects are spliced into the channel's chain",
      "version": "2026.8.70",
      "date": "2026-09-03",
      "area": "Sound engine",
      "type": "Internal",
      "userFacing": false,
      "details": "Third piece of the TFX lane (#98), and the one that makes it real audio: a slot is now an ordinary device inserted into the channel's chain - after the generator for a pre-effects lane, at the end for a post-effects one - wrapped in the same dry/wet the Stack's Mix knob already uses, with the blend driven by a clock-reading gate instead of a dial. \"Off\" is therefore \"fully dry\", so nothing new entered the execution model and a channel with no lanes compiles bit-identically to before. The wires are rebuilt the same way the app rebuilds them when you insert a device by hand, so hand-patched overlay wiring survives. Still nothing a user can reach: the presets and the editor come next."
    },
    {
      "title": "A TFX lane can be run over a bar of audio",
      "version": "2026.8.69",
      "date": "2026-09-03",
      "area": "Sound engine",
      "type": "Internal",
      "userFacing": false,
      "details": "Second piece of the TFX lane (#98): the part that actually processes. A slot compiles as its own one-device chain fed from the external-audio source - which is exactly what the master chain already is - so no DSP is reimplemented and every effect module in the registry can be a trigger effect for free. Only the frames a region covers go through it, which means each firing starts COLD: an echo inside a trigger effect begins when it is switched on rather than carrying a tail from the last time it fired, and smearing the transient the effect exists to make. Nothing is called by the renderer yet, so no song sounds different."
    },
    {
      "title": "The model a TFX lane is made of",
      "version": "2026.8.69",
      "date": "2026-09-03",
      "area": "Core",
      "type": "Internal",
      "userFacing": false,
      "details": "First piece of the TFX lane (#98): the lane, its slots, its regions, and the rules they obey - which slot is live at a given moment, what a region dropped on top of another does to it, and where the quarter-bar grid falls. Pure and in Core with 31 tests, because every one of those is decided many times a second by the audio engine and never once by anything anyone can look at. Two decisions worth recording: a region owns its start and not its end, so two laid end to end hand over cleanly instead of both claiming the boundary; and the grid is a quarter of a BAR derived from the song's time signature rather than a hard-coded beat, which would sit wrong on every song that is not 4/4. Nothing can create a lane yet, so no song format version is spent on it."
    },
    {
      "title": "The app hosts its own MCP server",
      "version": "2026.8.69",
      "date": "2026-09-03",
      "area": "Desktop",
      "type": "New feature",
      "userFacing": false,
      "details": "A desktop build with the remote-control API switched on now speaks MCP directly, at POST /mcp on the same port - so an agent connects to the app itself instead of to a separate Node process that proxied it. The reason is not the saved process, it is honesty: the proxy's tool catalogue was a hand-kept copy of the app's endpoints, and had fallen eight actions behind by the time it was replaced. A catalogue that lives beside the executor it describes cannot drift from it. 19 tools, including the transport and the two clipboards, which the proxy never had. The old adapter is kept for the two things it still does better - finding a running instance's port by itself, and pointing at any build - but is marked superseded and no longer the default.",
      "tickets": [
        31
      ]
    },
    {
      "title": "A failing control-API command answers instead of hanging the caller",
      "version": "2026.8.69",
      "date": "2026-09-03",
      "area": "Desktop",
      "type": "Bug fix",
      "userFacing": false,
      "details": "An exception while running a remote-control command left that request's completion source unset for ever: the HTTP handler waited on a task that never finished, so the caller saw a dead connection with no status and no message, and could not tell a crash from a hung app. Every command now runs inside a try, and a throw comes back as a 500 carrying the message as well as going to the log. Found by asking for a channel row that does not exist - which is also fixed: the row lookup was unchecked, safe only because every caller before the control API got its row from a hit test."
    },
    {
      "title": "Copy and paste notes in the piano roll and the drum lanes",
      "version": "2026.8.68",
      "date": "2026-09-03",
      "area": "UI",
      "type": "New feature",
      "userFacing": true,
      "details": "Ctrl+C, Ctrl+X, Ctrl+V and Ctrl+D now work on notes the same way they work on blocks in the sequencer and on points in an automation lane: copy the selection, cut it, paste it at the beat you last clicked, or duplicate it immediately after itself. They are on the Pattern menu too, with their keys shown beside them. A pasted note arrives selected, so it can be dragged straight away, and the whole paste is one undo step however many notes it moved. Velocity, the sample slice offset and any pitch bend you shaped travel with the note - a paste that flattened a bend would quietly lose work. It is ONE clipboard across the app rather than one per screen, so a hat pattern copied in the drum lanes pastes into the piano roll: both screens edit the same notes of the same pattern and only draw them differently.",
      "tickets": [
        60
      ]
    },
    {
      "title": "Keep the automation you like, and stamp it anywhere",
      "version": "2026.8.67",
      "date": "2026-09-03",
      "area": "Sequencer",
      "type": "New feature",
      "userFacing": true,
      "details": "Box-select points in an automation lane and Ctrl+C copies that movement; right-clicking the empty space beside the points opens a menu that lists every copy you have made and pastes the one you pick at the beat you clicked. Ctrl+X cuts, Ctrl+V pastes into the lane you last pointed at, and the paste lands on the beat unless you hold Shift. A copy can be KEPT - pinned so the next dozen copies do not push it out, and saved with your settings rather than in the song, so a filter sweep you shaped in one song is in the menu in every other one, in every future session. \"Clear the unkept copies\" throws away the rest in one go. Paste onto a different parameter and the shape is rescaled into that parameter's own range, so a cutoff sweep dropped onto a reverb mix does the same movement instead of a nonsense number - the status bar says when it has rescaled. The lane's Trick modulator travels with the copy, because it is half of what made the movement worth keeping. Right-clicking a point still deletes it, as before.",
      "tickets": [
        48
      ]
    },
    {
      "title": "The control API can select lane points and run the automation clipboard",
      "version": "2026.8.67",
      "date": "2026-09-03",
      "area": "Desktop",
      "type": "Internal",
      "userFacing": false,
      "details": "POST /action select_lane_points selects an automation lane's points in a beat range and reads every point back as beats@value, and POST /action automation_clipboard runs one operation by name through the same methods the lane menu and the shortcuts call. The lane marquee is a Shift+drag, the one gesture synthetic input cannot hold and move at once, so without these #48 could only be checked by hand - and a pasted curve is drawn, not readable, so reading the points back is the only way a script can assert on it."
    },
    {
      "title": "The shortcut-description test no longer fails on correct code",
      "version": "2026.8.67",
      "date": "2026-09-03",
      "area": "Build",
      "type": "Bug fix",
      "userFacing": false,
      "details": "The #91 test that checks every keyboard shortcut is registered with a description matched its arguments with a non-greedy regex ending at the first \");\", so a hook whose arguments contained one - a lambda with a call in its body - was truncated before the description and reported as undocumented. It now walks the call with balanced parentheses, skipping string literals, and asserts it found a plausible number of registrations so a broken scan cannot pass by finding none."
    },
    {
      "title": "Copy, cut and paste an area of the sequencer",
      "version": "2026.8.66",
      "date": "2026-09-03",
      "area": "Sequencer",
      "type": "New feature",
      "userFacing": true,
      "details": "Right-drag selects an area of the timeline across channels, and that selection can now be operated on. Ctrl+C copies it, Ctrl+X cuts it and leaves the gap, Ctrl+Shift+X cuts it and closes the gap by pulling everything after it left. Ctrl+V stamps over wherever you have dragged the marquee to, and Ctrl+Shift+V pushes what is already there right to make room instead. Ctrl+D duplicates the selection immediately after itself, which is how an eight-bar idea becomes a three-minute arrangement. Right-clicking inside a selection opens a menu with all of it, plus clearing the area, inserting empty bars, and dropping in an empty pattern to write into - and the same items are now in the Edit menu, which previously held only Undo and Redo. Every one of them is a single undo step, however many blocks it moved. A ripple only ever moves the channels you selected, so a whole-song ripple is a selection dragged on the bar ruler rather than a different command.",
      "tickets": [
        36
      ]
    },
    {
      "title": "The control API can select an area and run the clipboard",
      "version": "2026.8.66",
      "date": "2026-09-03",
      "area": "Desktop",
      "type": "Internal",
      "userFacing": false,
      "details": "POST /action select_area sets the sequencer's time selection from whole bars and rows, and POST /action clipboard runs one operation on it by name, through the same methods the menus and shortcuts call. The marquee is a right-drag, so nothing hanging off a selection could be driven from a script at all - the same gap the transport loop had before #122, and the reason that fix took so long to check by hand."
    },
    {
      "title": "The playhead is read from the audio, not guessed at",
      "version": "2026.8.65",
      "date": "2026-09-03",
      "area": "Sequencer",
      "type": "Bug fix",
      "userFacing": true,
      "details": "The moving play bar was worked out from a clock - where playback started, plus how long it had been running, folded back into song time by a second copy of the engine's own loop rules. Two copies of one rule drift, and this pair did: with a selection looping, the audio repeated the selection while the drawn play bar swept straight past it, and changing loop mode mid-song could throw the bar to the end. The engine now reports which part of the song each piece of audio came from as it renders it, and the play bar simply reads that back for whatever you are hearing. It cannot disagree with the audio any more, because it is no longer a separate opinion about where the song is. Starting playback after a looped selection also behaves as intended now - it plays out the song and arrives at the selection on the way round, rather than jumping backwards into it.",
      "tickets": [
        122
      ]
    },
    {
      "title": "The control API can drive the transport's loop",
      "version": "2026.8.65",
      "date": "2026-09-03",
      "area": "Desktop",
      "type": "Internal",
      "userFacing": false,
      "details": "POST /transport {action:loop, mode, start, end} sets the loop mode and selection range, and GET /transport reports the mode. Added while fixing #122, whose entire subject is whether the drawn playhead agrees with the audio at a loop boundary - which until now could only be reached by dragging a time selection and clicking a mode button, and so could not be checked from a script at all."
    },
    {
      "title": "Pick several slices of a loop, in the order you want them",
      "version": "2026.8.64",
      "date": "2026-09-03",
      "area": "Sequencer",
      "type": "New feature",
      "userFacing": true,
      "details": "A pack loop is four one-bar slices, and you could place one of them or the whole thing in 1-2-3-4 order. You can now pick as many as you like, in any order: click a slice as before, then ctrl-click or shift-click to add more. A small number on each picked slice shows where it sits in the sequence, so 3-then-1 is visibly different from 1-then-3. Dragging any one of the picked slices onto the timeline places all of them, one bar each, contiguously, in the order you picked them - and takes one press of undo to remove again. Drop them where there is not enough room and it places what fits rather than refusing the whole thing.",
      "tickets": [
        52
      ]
    },
    {
      "title": "An imported sample's quality upgrade no longer hides unsaved work",
      "version": "2026.8.63",
      "date": "2026-09-03",
      "area": "Services",
      "type": "Bug fix",
      "userFacing": true,
      "details": "When you import a sample from a service, the app plays a quick preview immediately and fetches the full-quality copy in the background. That background swap wrote into the song without telling anything - so the song could differ from the file on disk while the app still reported there was nothing to save, which is the opposite of what the unsaved-changes prompt exists to promise. It now marks the song as modified when it actually replaces the audio, and only then. If the full-quality fetch fails, it says so instead of leaving the low-quality preview in place with no sign that a better copy was ever attempted.",
      "tickets": [
        80
      ]
    },
    {
      "title": "The control API reports whether a song has unsaved work",
      "version": "2026.8.63",
      "date": "2026-09-03",
      "area": "Desktop",
      "type": "Internal",
      "userFacing": false,
      "details": "GET /stats carries songDirty, the same answer the unsaved-changes prompt uses. Added while fixing #80, whose entire symptom was that answer being wrong and which had no way to be observed from a script or a test - only by opening the app and reading the title bar."
    },
    {
      "title": "New effect: Formant",
      "version": "2026.8.63",
      "date": "2026-09-03",
      "area": "Sound engine",
      "type": "New feature",
      "userFacing": true,
      "details": "A vowel filter. Three resonant band-passes sit on the first three formants of a sung vowel, so whatever you play through it takes on the character of a voice saying that vowel - and the Vowel control morphs continuously through A, E, I, O and U rather than switching between them, which is the thing worth automating. A pad swept across it talks; a bass swept slowly wobbles in a way no filter cutoff does. Shift moves all three formants together, changing the apparent size of the singer without changing which vowel it is, and Resonance narrows the bands rather than making them louder. It mixes against the dry signal by default, because a fully wet formant filter throws away everything below about 270 Hz - which is sometimes the effect you want and rarely the one you expected.",
      "tickets": [
        53
      ]
    },
    {
      "title": "Built-in help: press F1",
      "version": "2026.8.63",
      "date": "2026-09-03",
      "area": "UI",
      "type": "New feature",
      "userFacing": true,
      "details": "The Help menu's Help item had never done anything - it shipped disabled. F1 now opens a guide on any screen, and so does Help > Help. The top half lists what the keys do right now, generated from the shortcuts the app actually registers rather than from a written list beside them, so it cannot describe a shortcut that no longer exists or miss one that was just added. It groups by screen, because opening a panel over the sequencer genuinely leaves both sets of keys live and showing only one of them would be misleading. Below that is Quick Tips: short explainers for the words this app uses - channels, loops and slices, clips, patterns, modules and stacks, wave banks, automation, the blue and red lines, TriggerFX - which studio had never explained anywhere despite having more of its own vocabulary than the version that did.",
      "tickets": [
        91
      ]
    },
    {
      "title": "Keyboard shortcuts are registered with their documentation",
      "version": "2026.8.63",
      "date": "2026-09-03",
      "area": "Core",
      "type": "Internal",
      "userFacing": false,
      "details": "Eight views each had their own copy of the same three-line helper for adding a keyboard hook. They now share ShortcutRegistry, which takes the description as a REQUIRED argument alongside the shortcut and its handler - so a new shortcut cannot be added without saying what it does, and it is the compiler that enforces it rather than a test anybody has to remember. Tests cover the two ways it could still rot: a view registering a hook straight with the InputManager behind the registry's back, and a screen that binds keys without binding F1 to show them."
    },
    {
      "title": "Most of the build warnings are gone",
      "version": "2026.8.63",
      "date": "2026-09-03",
      "area": "Build",
      "type": "Internal",
      "userFacing": false,
      "details": "The build reported 1790 warnings. Counting unique source locations rather than repeats across target frameworks, 443 of them were this repository's and the rest belong to the KNI fork and GustUI. Those 443 are now 139. The bulk was a single misattribution: the Blazor head only ever runs in a browser and marks some of its types as browser-only, but it targets plain net8.0, so the analyser judged every call into one of those - including calls from elsewhere in the same browser-only assembly - as reachable from any platform. That accounted for 287 warnings describing a situation that cannot occur, and is now declared once at assembly level and suppressed in that project with the reason written down. Two genuine platform guards around Windows DPAPI were also made visible to the analyser, a dead field and a dead retry latch removed, three test asserts switched to the form xUnit asks for, and the vendored WavPack decoder given a scoped suppression so it can stay close to upstream. What remains is 127 nullable-reference warnings, mostly in the Splice VST3 interop, which need judging one at a time rather than sweeping.",
      "tickets": [
        32
      ]
    },
    {
      "title": "Add a channel from the sequencer",
      "version": "2026.8.63",
      "date": "2026-09-03",
      "area": "Sequencer",
      "type": "New feature",
      "userFacing": true,
      "details": "There was no way to add a channel. Channels only ever arrived from a template, a demo or an import, so a song could be edited in every way except gaining a track. A channel-header sized \"+ Add channel\" row now sits under the last channel; clicking it offers a blank channel, or one channel taken from any installed pack template, folded out by the channel types that template lays out. Either way it is a single undo step like anything else. \"With Module\" and \"From Saved Stacks\" from the original request are not here - each is a subsystem of its own and has its own ticket.",
      "tickets": [
        28
      ]
    },
    {
      "title": "One place decides what a new channel looks like",
      "version": "2026.8.63",
      "date": "2026-09-03",
      "area": "Core",
      "type": "Internal",
      "userFacing": false,
      "details": "A channel's empty-but-configured defaults - three quarters mixer level, low-pass on blue, rate-crush on red - lived inline in the one route that had ever created a channel. Now that there are three, they live in ChannelFactory in Core, where they are a model fact rather than a detail of whichever button happened to make the channel. AddSynthChannelEdit is renamed AddChannelEdit, which it had been in practice for a long time: wave channels, module channels and drum-loop conversions all append through it, and it takes a description now so the undo menu says what was actually added."
    },
    {
      "title": "The solution builds again",
      "version": "2026.8.63",
      "date": "2026-09-03",
      "area": "Build",
      "type": "Bug fix",
      "userFacing": false,
      "details": "Building the repository root failed outright. Removing the VST3 subsystem took the project with it but left both its solution entry and tools/Vst3Probe, which referenced it, so the solution could not even restore. Behind that, tools/ServicesProbe had not compiled since the service providers were split into their own projects and it was never given references to them. Both fixed - Vst3Probe is gone, ServicesProbe references all five providers and targets windows because the Splice one does. The release pipeline builds named projects rather than the solution, which is why this went unnoticed."
    },
    {
      "title": "Cerebrum's presets were all playing the same three wavetables",
      "version": "2026.8.63",
      "date": "2026-09-03",
      "area": "Sound engine",
      "type": "Bug fix",
      "userFacing": true,
      "details": "Every one of Cerebrum's presets chose wavetables for its three oscillators, and none of those choices were reaching the synth. The presets set the oscillator's own table parameter, but that parameter is driven by the Osc A/B/Sub Table dials on the panel - and a control that is driven by something else ignores its own stored value entirely. So the whole bank played the module's defaults, Analog Saw and Growl and Sub Triangle, however carefully each preset picked. The same was true of every preset that asked for a high-pass or band-pass filter: they all got a low-pass. Fixed, and the bank now reaches all seventeen wavetables instead of two."
    },
    {
      "title": "Cerebrum's sub oscillator is something you can actually hear",
      "version": "2026.8.63",
      "date": "2026-09-03",
      "area": "Sound engine",
      "type": "Improvement",
      "userFacing": true,
      "details": "The sub sits an octave below the played note, but it was a pure sine in every single preset - its waveform control was pinned at the bottom of its table and nothing ever moved it. An octave-down sine is close to inaudible on a laptop or a phone, because the speaker cannot reproduce its fundamental and a sine offers nothing else. Every preset now picks a waveform for its sub, from nearly-pure where the job is a clean deep foundation to the table's new growl-sub end, whose second and third harmonics are what a small speaker actually renders. Fat Sub drops a further octave, and the bell and metal patches put their sub up at the played pitch where it belongs as a body rather than a floor."
    },
    {
      "title": "Four new Cerebrum presets built around the wavetables",
      "version": "2026.8.63",
      "date": "2026-09-03",
      "area": "Sound engine",
      "type": "New feature",
      "userFacing": true,
      "details": "Table Walker holds a note and lets the wavetables walk - an envelope drags Osc A up through Growl's hard-sync stack while an LFO rocks Osc B through the vowels, with the filter left open and static so what you hear moving is the tables themselves. Three Octave Stack spreads three different saw tables across three octaves at once using the transpose dials. Harmonic Riser climbs the Additive Stack's ladder over four seconds with Metal Bar stretching its modes behind it, a riser made of harmonic motion rather than a filter sweep. Wavetable Vox sings through all five of Vox Choir's vowels across a single held note."
    },
    {
      "title": "Cerebrum's preset bank is levelled and no longer clips",
      "version": "2026.8.63",
      "date": "2026-09-03",
      "area": "Sound engine",
      "type": "Improvement",
      "userFacing": true,
      "details": "Three presets clipped outright and the bank spanned a wide range of loudness, so changing preset was as much a volume change as a sound change. Every preset's output is now set from a measured render at four pitches rather than by ear, landing them all within a few dB of each other with headroom to spare. Bright Epiano has been re-voiced along the way: its attack is genuinely brighter than its sustain again, which is the whole point of a tine piano and had stopped being true."
    },
    {
      "title": "Tests pin the sub transposes and the wavetable spread",
      "version": "2026.8.63",
      "date": "2026-09-03",
      "area": "Sound engine",
      "type": "Internal",
      "userFacing": false,
      "details": "CerebrumTransposeTests measures that the transpose dials move pitch by the semitones they promise, that the sub is already an octave down before any transpose is applied (an offset baked into its pitch wire, and easy to get backwards - reading it as untransposed and setting -12 everywhere drops the bank two octaves), and that no preset leaves its sub on a bare sine. The structural-spread assertion in CerebrumMatrixTests now resolves each oscillator's table through the wire rather than reading the driven parameter, which is why it passed throughout the entire period the bank was using two tables."
    },
    {
      "title": "Every factory wavetable is now genuinely a wavetable",
      "version": "2026.8.63",
      "date": "2026-09-03",
      "area": "Sound engine",
      "type": "Improvement",
      "userFacing": true,
      "details": "The Position knob on a wavetable oscillator scans through a table of waveforms, and half the factory bank had nothing much to scan. Hyper Saw and Sub Triangle barely changed from one end of the knob to the other, and several others did all their changing in the first quarter of its travel, because they were built as a cross-fade FROM A SINE into their real shape - and a sine carries so little energy next to a saw or a pulse that the real shape took over almost immediately. Nine of the seventeen tables have been rebuilt so the whole knob does something: Basic tours sine, triangle, square and saw; Analog Saw ages from a mellow filtered oscillator to a raw bright one; Metallic stretches its partials off the harmonic grid until nothing fuses; Hyper Saw sweeps the detune spread of its three sawtooths from tight to wide; Metal Bar stretches from a tuned strike to a clangorous one; Vox Choir sings through five vowels; and Hollow, Dark Pad and Sub Triangle all travel much further while staying the family they belong to."
    },
    {
      "title": "Oscillators that were meant to be pulses and triangles were sines",
      "version": "2026.8.63",
      "date": "2026-09-03",
      "area": "Sound engine",
      "type": "Bug fix",
      "userFacing": true,
      "details": "Because every wavetable used to begin its position axis as a sine, any module that pinned a table at position zero got a sine no matter what the table was called. That is what Chip's NES and C64 pulse channels were running on, and its NES triangle channel, and both of LadderOne's oscillators, and ChorusPoly's DCO at low pulse-width settings. Pulse Width, Buzz, FM Bell and Additive Stack now sound like their own names at every position - a pulse, a buzz, a bell and a stack - and Chip's triangle channel is pointed at the actual triangle. Those instruments sound substantially fuller as a result; every factory preset on them has been re-levelled so its loudness is unchanged, measured rather than estimated."
    },
    {
      "title": "ChorusPoly's noise layer is audible again",
      "version": "2026.8.63",
      "date": "2026-09-03",
      "area": "Sound engine",
      "type": "Bug fix",
      "userFacing": true,
      "details": "With a real sawtooth in front of it instead of a near-sine, ChorusPoly's oscillators drove their own filter into saturation - and a saturating stage compresses whatever is added to it. The noise layer joins the signal at the filter, so turning it fully up moved the output by under one percent, and with resonance flat it actually made the patch quieter. The oscillators and sub are now trimmed into the filter together, leaving their balance with each other alone, and the noise layer does what its dial says again."
    },
    {
      "title": "Tests pin that a wavetable actually morphs, and sounds like its name",
      "version": "2026.8.63",
      "date": "2026-09-03",
      "area": "Sound engine",
      "type": "Internal",
      "userFacing": false,
      "details": "WavetableMorphTests measures every factory table's spectral travel across its position axis, that the travel is spread across the knob rather than crowded into its first quarter, and that no table (bar Basic and Sub Triangle, where a sine is the point) begins life as a bare sine. These are the three failure modes the bank had actually developed, so each is now a test rather than something to notice by ear. The two Pulse Width tests that measured duty cycle by counting positive samples have been rewritten to measure it in the spectrum instead: a bandlimited narrow pulse is mostly ripple about zero, so the old metric read about 50% whatever the duty was."
    },
    {
      "title": "The wavetable displays follow the controls",
      "version": "2026.8.63",
      "date": "2026-09-03",
      "area": "UI",
      "type": "Bug fix",
      "userFacing": true,
      "details": "Cerebrum's two wavetable scan displays never moved, whatever you turned. They read the oscillator's stored parameters directly - but the moment a control is wired to a parameter, which is the case for anything you can turn, that stored value stops being read and stops being written. So the displays drew the table the synth was built with and stayed there. They now resolve their table, position, wave bank slot and read mode the way the audio graph does, by following the wires, so the picture matches what you are hearing and moves as you turn the knob."
    },
    {
      "title": "Envelope attacks rise properly, and Cerebrum stops clipping",
      "version": "2026.8.63",
      "date": "2026-09-03",
      "area": "Sound engine",
      "type": "Bug fix",
      "userFacing": true,
      "details": "An envelope's attack used the same curve its decay and release do - one that does most of its travel immediately and then creeps. That is right for a stage falling away and wrong for one rising: it made every attack sound fast however long it was set. Measured on Cerebrum, the attack dial at half and at full both reached nine tenths of the way up in about half a second, where they promise two seconds and four. Attacks are now a ramp, so halfway through the attack you are halfway up and the time on the dial means what it says; the curve control still bends it either side of straight. Nothing can ask for a literal zero-length attack any more either - there is a one millisecond floor - because an instant gate is a click, and it was the click. Cerebrum also gains the master output it was the only synth here to lack, without which its own default patch clipped. Every one of its presets now sets its own output level: the bank used to span thirteen decibels, so changing preset was as much a volume change as a sound change, and six of them clipped outright. Cerebrum is also now registered as a factory module like every other shipped instrument, which is what put it in front of the audio tests that found all of this."
    },
    {
      "title": "Controls can say what their numbers mean, and Cerebrum can pick its wavetables",
      "version": "2026.8.63",
      "date": "2026-09-03",
      "area": "Sound engine",
      "type": "New feature",
      "userFacing": true,
      "details": "A knob's readout used to be whatever its port could work out, which for the most common kind of control - a dial that a wire scales onto something real - could only ever be a bare 0 to 1. A module can now say what its controls mean: a display range with a unit, a format like \"Freq: {v} Hz\", or a list of names. It lives on the component rather than on a panel, so the generic Stack face, the module editor and every custom panel all read the same. A control that names its values also detents itself to those names, so the pointer and the label can never disagree. There is also a new math.quantize component for the other case - lowering a continuous knob's resolution so it lands on values that mean something. Cerebrum uses all of it: each oscillator and the sub now have a transpose reading in signed semitones and stepping a semitone at a time, and - finally - a control that picks which of the seventeen factory wavetables they read, by name. That last one had never existed: the wavetable was fixed when the synth was built, so a wavetable synth had no way to change its wavetable."
    },
    {
      "title": "Point an oscillator at a wave bank sample, and choose how it is read",
      "version": "2026.8.63",
      "date": "2026-09-03",
      "area": "Sound engine",
      "type": "New feature",
      "userFacing": true,
      "details": "A wavetable oscillator aimed at a wave bank entry always resynthesised it - hunting for the pitch period at 32 points across the sample and rebuilding a cycle from each. That is the right reading for a recording of an instrument and the wrong one for a file that is already a wavetable: its frames are not all at one pitch, are often not periodic at all, and the period the analysis finds in them means nothing. Cerebrum's oscillators now have a Wave bank read choice - Resynth, or 2048 / 1024 / 4096 frames - so a wavetable file is sliced into its frames instead of being analysed. 2048 is the size Serum writes and what essentially every wavetable distributed for it uses; the other two exist in the wild. A file with more frames than the oscillator holds is sampled evenly across the whole sweep rather than truncated to its first eighth. Resynth stays the default, so every existing patch sounds exactly as it was voiced."
    },
    {
      "title": "Cerebrum shows its wavetables, and wears a soft-synth skin",
      "version": "2026.8.63",
      "date": "2026-09-03",
      "area": "UI",
      "type": "Improvement",
      "userFacing": true,
      "details": "The front page of a synth that calls itself a wavetable synthesizer now actually shows its wavetables: each oscillator has a live scan display with its position, table and warp beneath it, where before the displays were hidden on the Warp tab and the main view had none. Both filters moved together onto the Filter tab, which is where the second one already was. The panel also wears a new Modern skin - dark caps with no bezel, a hairline value arc, and the accent showing only as a lit dot at each pointer tip - which is the soft-synth look rather than the rack-gear one."
    },
    {
      "title": "The remote-control screenshot endpoint crops",
      "version": "2026.8.62",
      "date": "2026-09-02",
      "area": "Desktop",
      "type": "Internal",
      "userFacing": false,
      "details": "GET /screenshot now takes ?x&y&w&h for a rectangle, or ?name=/?text= to crop to an element's bounds, with ?pad= to keep a margin. Coordinates are element-space - the same ones /tree reports and /click takes - so a crop can be aimed straight at something found in the tree without converting for DPI scale. It replaces what had become a routine two-step: capture the whole window, then cut it up afterwards in a throwaway script with the scale factor hard-coded. The MCP server's screenshot tool takes the same arguments."
    },
    {
      "title": "Logging on in the browser opens a small window, not a whole tab",
      "version": "2026.8.62",
      "date": "2026-09-02",
      "area": "Services",
      "type": "Improvement",
      "userFacing": true,
      "details": "The web build sends you to ezmuze central to approve the computer you are on. It used to open the full site in a new tab - nav bar, footer, background video - for a page whose entire job is one question, and it buried the studio tab you came from. It now opens a page built for the purpose, in a window of its own, sized so you can see both at once. Nothing about the sign-in depends on that window: browsers are free to turn it into a tab, or refuse it altogether, and approval is recorded on the server either way - the app is polling for it, and 'Open it again' is there for a blocker or a window closed by accident. The desktop build is unchanged: its link lands in a real browser tab, where being recognisably on ezmuze central with the rest of the site around it is the reassurance rather than the noise."
    },
    {
      "title": "Every built-in instrument panel has been redressed",
      "version": "2026.8.61",
      "date": "2026-09-02",
      "area": "UI",
      "type": "Improvement",
      "userFacing": true,
      "details": "All sixteen shipped module panels now wear a theme, and every one of them has been straightened out. Chip gets the 8-bit look, Korben deliberately keeps the stock one, and the rest are matched to what they are - the electric piano and the drum box in cream and amber, the FM console and the kits racked in black and green, the ladder-filter mono on a black amp panel, the Juno-ish poly in brushed metal, the concert grand in pale plastic. Each panel keeps its own colours, which are the instrument's identity; what changes is the shape of every knob, slider and switch. The explanatory paragraphs are gone - these are instruments, not tutorials, and a front panel labels its controls rather than explaining them (tooltips still do that, and the sample licences stay). Nothing overlaps any more: knobs no longer grow out of the bottom of the box holding them, captions no longer run into the value underneath, and a selector's options no longer draw over each other. A test now checks all of that on every build."
    },
    {
      "title": "Panel themes are files you can share",
      "version": "2026.8.61",
      "date": "2026-09-02",
      "area": "UI",
      "type": "New feature",
      "userFacing": true,
      "details": "The UI editor can import and export a theme as a package - one theme per .ezuitheme file - so a look you like can be sent to somebody else, and somebody else's can be dropped into your panel. An imported theme is embedded into the panel that uses it rather than referenced, along with any artwork it carries, so the project draws correctly on a machine that has never seen that theme; the nine built-in ones stay referenced by name so that improving one improves every panel wearing it. Export works on the built-ins too, which is how you author your own: export the nearest theme, edit the colours in a text editor, and import it back. A package that is wrong is refused with a reason rather than half-read, because a theme that imports mostly leaves you hunting for a bug in your own layout."
    },
    {
      "title": "Five more module panel themes",
      "version": "2026.8.61",
      "date": "2026-09-02",
      "area": "UI",
      "type": "New feature",
      "userFacing": true,
      "details": "Console is a brushed metal chassis with black caps and red arcs. Amp is a black panel with white lettering and knurled caps read against printed tick marks - nothing on it lights up, which is how a real amplifier front panel works. Lab is a pale chassis with black knobs and coloured caps. Neon is near-black plum with magenta rings that glow as they fill. Pixel is chunky stepped bevels, square caps and hard edges throughout, with no curve anywhere on the panel. Nine themes in all now, and the three new control skins that came with them change what carries the reading, not just the colours: a lit collar, a pointer against a printed scale, a glow, or a row of blocks."
    },
    {
      "title": "Module panels can wear a theme",
      "version": "2026.8.61",
      "date": "2026-09-02",
      "area": "UI",
      "type": "New feature",
      "userFacing": true,
      "details": "A module UI panel now picks a theme back in the UI editor, and four ship with it. Studio is the app's own look and follows light and dark like it always did. Soft is a pale plastic panel with raised caps and accent arcs. Rack is a black panel with green LEDs and machined caps in metal bezels. Vintage is a cream chassis with amber accents and dark caps. A theme brings a palette, a lettering face (the hardware ones letter their panels in capitals, like real gear) and, mostly, a different set of knobs, sliders and switches - every control is redrawn, not recoloured. Applying a theme clears the colours already stored on the panel so the theme's can show through; colouring an element afterwards still wins, so a themed panel can keep one red bypass button, and Revert undoes the lot until you save. Everything a control tells you survives the change of clothes: the pointer, the modulation arc and the live-automation marker draw the same on every theme.",
      "tickets": [
        100
      ]
    },
    {
      "title": "The six analytic visualiser effects are authored once, in Slang",
      "version": "2026.8.61",
      "date": "2026-09-02",
      "area": "Build",
      "type": "Internal",
      "userFacing": false,
      "details": "Ribbons, Bars, Radial, Rings, Plasma and Grid used to exist twice: as HLSL in VisRhodiumLiquidCarbon.fx for the DirectX head, and as six hand-written .frag files for the OpenGL and web heads. They are now one VisEffects.slang, from which the content pipeline generates the HLSL DirectX compiles and the GLSL the GL heads compile, so the two can no longer drift apart. VisRhodiumLiquidCarbon.fx and the six .frag files are gone. The two raymarched effects (LiquidCarbon, SpectrumBlob) stay per-backend for now - fxc will not compile the loops Slang lowers them to - which is why the split is where it is: a failure in the raymarchers cannot take the working six down with it. Verified by sweeping all eight effects on DirectX, OpenGL and WebGL2; all eight render identically on each.",
      "tickets": [
        126
      ]
    },
    {
      "title": "The molten-ribbon visualiser reads as a spectrum now",
      "version": "2026.8.61",
      "date": "2026-09-02",
      "area": "UI",
      "type": "Improvement",
      "userFacing": true,
      "details": "Four changes to the LiquidCarbon effect. Its rotation no longer speeds up with the bass - the motion used to lurch with the kick, which fought the shape rather than helping it - and it is now a slow rock about face-on instead of a sweep that spent much of every cycle edge-on, where the shape it was making could not be seen at all. The spectrum spans the screen properly: it used to map the ribbon's entire 40-unit length onto the bands while the camera only ever saw about a quarter of that, so only the middle of the spectrum ever appeared; the left edge of the screen is now the bottom of the spectrum and the right edge the top. And the ribbon's height responds far harder, with a squared term so a kick visibly punches instead of everything drifting together. It reads as an EQ curve with its mirror underneath, which is what it was always trying to be."
    },
    {
      "title": "MojoShader is retired, and a test keeps it that way",
      "version": "2026.8.61",
      "date": "2026-09-02",
      "area": "Build",
      "type": "Internal",
      "userFacing": false,
      "details": "Nothing the OpenGL, Linux or web builds compile goes anywhere near MojoShader any more - the shader translator whose fixed instruction ceiling kept the raymarched visualisers Windows-only for months. Those heads build zero HLSL; everything is Slang or hand-written GLSL. The DirectX build still compiles two HLSL files for the visualiser, but those go to Microsoft's own compiler and never touch the translator. A new test fails if any OpenGL-family head starts building HLSL again, because reintroducing it is three innocuous-looking lines in a content list that nothing else would flag.",
      "tickets": [
        124
      ]
    },
    {
      "title": "Every shader is now authored once, in Slang",
      "version": "2026.8.61",
      "date": "2026-09-02",
      "area": "Build",
      "type": "Internal",
      "userFacing": false,
      "details": "The glyph shader joins the geometry one: both are written in a single file each, and the HLSL DirectX compiles and the GLSL the OpenGL and web builds compile are generated from it during the content build. There is no hand-written shader left anywhere in the app. Finishing it meant finding a bug that only showed on WebGL2: GLSL ES spells a uniform declaration \"uniform highp vec4\" where desktop GLSL says \"uniform vec4\", and the generator matched the literal text - so on the web the constant buffer was never renamed, never uploaded, and every value the glyph shader read came back zero. That turned the text antialiasing into a hard cutoff and ate thin strokes. The geometry shader had hidden it, because its fragment stage reads no values at all. Verified pixel-identical to the previous build on DirectX, desktop OpenGL and in a real browser.",
      "tickets": [
        126
      ]
    },
    {
      "title": "The DirectX build generates its shader from the same Slang source",
      "version": "2026.8.61",
      "date": "2026-09-02",
      "area": "Build",
      "type": "Internal",
      "userFacing": false,
      "details": "The shader that draws every rectangle and waveform is written once, and now BOTH halves are generated from it: the GLSL the OpenGL and web builds compile, and the HLSL DirectX compiles. There is no hand-written shader for it on any platform any more. Getting the DirectX half working needed two transforms in the content pipeline - Slang always wraps a constant buffer's contents in a struct, which has to be flattened or nothing can set a uniform by name, and it refuses to emit two entry points into one file, so they have to be merged before fxc sees them. Verified rendering identically on DirectX, desktop OpenGL and in a real browser, where the web build is pixel-for-pixel what it was before. The glyph shader is deliberately NOT converted: it is correct on both desktop backends but degrades text on WebGL2 for a reason not yet understood, and it stays hand-written until it is.",
      "tickets": [
        126
      ]
    },
    {
      "title": "Shaders are authored once and the OpenGL version is generated",
      "version": "2026.8.61",
      "date": "2026-09-01",
      "area": "Build",
      "type": "Internal",
      "userFacing": false,
      "details": "The shader that draws every rectangle and waveform is now written once, in Slang, and the GLSL the OpenGL and web builds use is generated during the content build - so there is no second file for anyone to keep in step. Getting there meant a new content-pipeline processor in the KNI fork that runs the Slang compiler and SPIRV-Cross, and reads the uniform layout from the compiler's own reflection rather than a hand-written table: those offsets are not what a person guesses, and the hand-written table this replaced had one of them wrong in a way that happened to cancel out. Verified rendering identically on the OpenGL desktop build and in a real browser. See docs/slang-shader-pipeline.md. The glyph shader and the DirectX half are still to convert."
    },
    {
      "title": "Beta builds now stamp the change log with the beta they shipped in",
      "version": "2026.8.60",
      "date": "2026-09-01",
      "area": "Build",
      "type": "Internal",
      "userFacing": false,
      "details": "A change-log entry is authored as \"NEXT\" and the pipeline resolves it to a real version before it compiles anything. Only a RELEASE did that; a beta shipped its change log untouched, so every beta since the channels split carried 86 entries still saying NEXT, and Changelog.Since filters those out - meaning nobody testing a beta had seen a \"What's new\" entry for a fortnight. That was backwards for how this project ships. Features land in betas, a release is a promoted beta and is functionally identical to the one before it, so the build an entry first appears in is a BETA and that is the number it should carry; stamping at promote time backdated a fortnight of work onto the release that merely published it. Both channels stamp now, and deploy-beta.sh pushes the result back to main - which is the half that makes the number stick, because otherwise the next push would re-stamp the same entries with its own number for ever. A beta will not stamp onto a main that moved under it: it compares what it built against the current head and leaves it to the next beta rather than claiming work it does not contain."
    },
    {
      "title": "No shader on the OpenGL or web builds goes through MojoShader any more",
      "version": "2026.8.60",
      "date": "2026-09-01",
      "area": "Build",
      "type": "Internal",
      "userFacing": false,
      "details": "Every OpenGL-family target used to compile our shaders to twenty-year-old bytecode and run them through MojoShader, a translator with a hard instruction ceiling nobody here controls - the reason the raymarched visualisers were DirectX-only for months. The visualiser escaped that earlier today; the last two, the shader that draws every rectangle and waveform and the one that draws every glyph, now have hand-written GLSL twins as well. The OpenGL, Linux and web builds no longer build or ship the HLSL versions at all. DirectX still uses them and always will - it needs real HLSL, and so would an Xbox build - so each of those two shaders now has two sources that must be kept in step, which is written at the top of every one of the files involved. Verified rendering identically on the OpenGL desktop build and in a real browser.",
      "tickets": [
        124
      ]
    },
    {
      "title": "The OpenGL and web builds stop shipping a shader they never load",
      "version": "2026.8.59",
      "date": "2026-09-01",
      "area": "Build",
      "type": "Internal",
      "userFacing": false,
      "details": "VisRhodiumLiquidCarbon.fx is the translated-HLSL version of the six analytic visualiser effects. Those heads load the real-GLSL VisEffectsGL for all of them now, so building it produced an .xnb nothing opens - and it was the last thing besides the two shared UI shaders that went through the HLSL translator on those platforms at all. The DirectX head still builds it and the source is untouched. Also fixes --vis, which never fired on desktop: the desktop head only inspects its first argument for query-style settings, so a bare switch never reached the check."
    },
    {
      "title": "The two raymarched visualisers now work in the browser",
      "version": "2026.8.59",
      "date": "2026-09-01",
      "area": "Web",
      "type": "New feature",
      "userFacing": true,
      "details": "The visualiser had eight effects on the Windows desktop build and six on the web, because the two best ones - the molten ribbon and the reflective blob floating in a cavern - were real raymarchers that the browser build's shader translator could not compile. The web build now compiles those shaders directly instead of translating them, so it has all eight. Verified in a real browser on a real graphics card, not a simulator. The shader source did not have to change at all: the same text that runs on Linux and on the desktop OpenGL build runs in the browser."
    },
    {
      "title": "?vis=1 opens the visualiser on load, and the fps says when it is throttled",
      "version": "2026.8.59",
      "date": "2026-09-01",
      "area": "UI",
      "type": "Improvement",
      "userFacing": false,
      "details": "Two small things for testing. ?vis=1 (or --vis on desktop) opens the visualiser as soon as a song is loaded - it is the only screen reachable solely through a transient toast button, and on the web there is no control API to drive, so a browser test could not otherwise reach it. And the status bar now says \"inactive\" before the fps whenever the window has lost focus: the framework sleeps 20 ms before every frame in that state, which is a hard ~32 fps ceiling that looks exactly like a rendering problem and has cost two separate investigations."
    },
    {
      "title": "The sequencer draws about twice as fast",
      "version": "2026.8.58",
      "date": "2026-09-01",
      "area": "UI",
      "type": "Performance",
      "userFacing": true,
      "details": "Waveforms were 70% of the cost of drawing a frame, and almost all of that was the CPU rebuilding every waveform's vertices from scratch on every frame - moving each one to where its block sits and multiplying in its colour - for a shape that had not changed. Where a waveform goes, what colour it is, which texel it samples and what it is clipped to are now four values handed to the graphics card once per waveform instead of written into every vertex, so copying the cached shape into the frame is a straight block copy. Measured on The Dark, maximised, with the frame rate uncapped: 114 to 210 frames per second, and the drawing half of a frame from 7.4 ms to 3.8 ms. The picture is identical.",
      "tickets": [
        64
      ]
    },
    {
      "title": "A benchmark mode for the remote-control API",
      "version": "2026.8.58",
      "date": "2026-09-01",
      "area": "Desktop",
      "type": "Internal",
      "userFacing": false,
      "details": "POST /benchmark turns VSync off and keeps the window focused for as long as it is on, so a frame-rate reading taken through the control API is a measurement rather than the display refresh rate. Both traps were things a caller had to remember, and the focus one produces a hard ~32 fps ceiling that reads exactly like a render bottleneck - it had already cost two separate investigations. The status bar's fps readout wears the warning accent while the mode is on, so a number read off a screenshot cannot be mistaken for a normal one, and GET /stats reports benchmarkMode alongside it. Without this the #64 geometry work measured as 144 to 144; with it, 114 to 210."
    },
    {
      "title": "Waveform geometry stops being rebuilt every frame",
      "version": "2026.8.57",
      "date": "2026-09-01",
      "area": "UI",
      "type": "Performance",
      "userFacing": false,
      "details": "GustUI f242dd0. The cache that holds a waveform's triangulated vertices had two slots, sized on the reasoning that nothing draws a third distinct key in one pass - true of one block, false of a row, because every clip sharing a waveform shares the cache and two placements of one pattern at different lengths are already three or four live keys. Measured on The Dark, maximised: 83021 waveform draws against 14290 triangulations over 1361 frames, ten and a half misses every frame. Six slots and LRU eviction takes that to zero. Frame time barely moves (the triangulation was 0.18 ms of a 7.6 ms draw) but per-frame allocation drops from 257 KB to 139 KB, which is the half that matters in the browser. A Draw.Waveform.Triangulate telemetry scope now separates the two costs, since Draw.Waveform alone is what hid this."
    },
    {
      "title": "Repeated patterns on an automated channel draw their own waveform",
      "version": "2026.8.57",
      "date": "2026-09-01",
      "area": "Sequencer",
      "type": "Bug fix",
      "userFacing": true,
      "details": "On a channel where automation drives the instrument itself, placing the same pattern at two different bars made both blocks show the same picture - and it was the wrong one for at least one of them, because the two placements sound different and only one of them was actually being drawn. Each placement now gets its own picture, so what you see is what that bar plays. Duplicating a clip was enough to hit it; no trimming or splitting needed. A side effect worth having: songs like The Dark now hold about 59 MB less audio, because renders that could never be played were being kept anyway.",
      "tickets": [
        44
      ]
    },
    {
      "title": "Choose the time-stretch engine your song uses",
      "version": "2026.8.56",
      "date": "2026-09-01",
      "area": "Sound engine",
      "type": "New feature",
      "userFacing": true,
      "details": "Song settings now has a Time-stretch engine chooser under \"Preserve pitch when the tempo changes\". Phase vocoder is cleaner on sustained sounds - pads, vocals, anything held - and costs more CPU; Overlap-add is cheaper and keeps transients punchier, so it often suits drums better. It is a real quality lever on a loop-based tool, because it affects every sound at once. ezmuze 3 let you pick this and studio never has: both engines were already in here, but the choice could only arrive by importing a song that already named one. Picking a different one re-fits every loop, so the song takes a moment to rebuild, and the status line tells you which engine it rebuilt with.",
      "tickets": [
        94
      ]
    },
    {
      "title": "Opening an ezmuze 3 song tells you when it fails",
      "version": "2026.8.56",
      "date": "2026-09-01",
      "area": "UI",
      "type": "Bug fix",
      "userFacing": true,
      "details": "Picking a legacy song could do nothing at all: the picker closed and the app carried on with the song you already had, with no message and nothing to retry. Opening one downloads the packs it uses, and if that download failed - offline, a server having a bad day, a disk filling up - the failure went nowhere and the open just stopped halfway. It now says what went wrong, as it already did when the licence check itself failed. The same guard covers the import that follows, so a legacy file that turns out to be malformed reports that too instead of quietly doing nothing.",
      "tickets": [
        76
      ]
    },
    {
      "title": "Deleting a song from ezmuze central actually deletes it",
      "version": "2026.8.56",
      "date": "2026-09-01",
      "area": "Services",
      "type": "Bug fix",
      "userFacing": true,
      "details": "Songs you had deleted came back - sign in somewhere else and there they all were, including things binned years ago. The cause was not in the app: ezmuze central had no delete of any kind, so \"delete\" was something a client did to its own copy and never told the server about. The server can delete now, and studio uses it: right-click a cloud song in Open or Save and Delete does what it says. It only ever touches your own songs - who you are comes from your sign-in, not from the request. Note this fixes deleting FROM STUDIO; songs already piled up from years of ezmuze 3.0 deletes that never reached the server are still there, and can now be cleared out.",
      "tickets": [
        115
      ]
    },
    {
      "title": "Connecting to ezmuze central says which app is connecting",
      "version": "2026.8.56",
      "date": "2026-09-01",
      "area": "Services",
      "type": "Improvement",
      "userFacing": true,
      "details": "When you link studio to your ezmuze central account, the browser page confirming it could only say \"Return to HEYEMDEE (Microsoft Windows NT 100281200)\" - a computer name and a Windows build number, which tells you nothing about the thing waiting for you. studio now sends its name along with the pairing request, so the page says ezmuze studio is signed in, on that machine. Central reads a missing name as ezmuze 3.0, which is what every request that predates this actually was, so older clients keep describing themselves correctly."
    },
    {
      "title": "The window is named after what you are looking at",
      "version": "2026.8.56",
      "date": "2026-09-01",
      "area": "UI",
      "type": "Improvement",
      "userFacing": true,
      "details": "The title bar - and the browser tab on the web version - used to say only \"ezmuze studio\", so several windows in a taskbar, or several tabs, told you nothing about which was which. It now reads \"ezmuze studio - Sequencer - The Dark\", \"ezmuze studio - Preferences\", and so on, following whichever window is in front.",
      "tickets": [
        123
      ]
    },
    {
      "title": "Right-click a channel for its own menu - including colour, and delete",
      "version": "2026.8.56",
      "date": "2026-09-01",
      "area": "Sequencer",
      "type": "New feature",
      "userFacing": true,
      "details": "Channels had no menu at all, which is why two obvious things were missing. You can now give a channel its own colour (and reset it back to whatever its pack type says), and you can DELETE a channel - which nothing in the app could do before. Deleting one takes any channels grouped under it, so it says how many go and how many clips of music that is, and you can undo it: the order comes back exactly as it was, not just the channels. The menu also carries mute, solo and the automation-lanes toggle, so it is one place for everything you do to a channel. The master channel cannot be deleted, and says so rather than hiding the option.",
      "tickets": [
        30
      ]
    },
    {
      "title": "Signing in to ezmuze central goes to the new website",
      "version": "2026.8.56",
      "date": "2026-09-01",
      "area": "Services",
      "type": "Improvement",
      "userFacing": true,
      "details": "The page that approves your computer when you log on - and the profile and store links the app hands you - now point at temp.ezmuze.co.uk, where the rewritten ezmuze central lives while it is being finished. They go back to www the day it takes over that name."
    },
    {
      "title": "Song folders you can actually create, rename and delete",
      "version": "2026.8.55",
      "date": "2026-09-01",
      "area": "UI",
      "type": "New feature",
      "userFacing": true,
      "details": "Two things were missing. \"New folder\" read the file-name box and told you to type a name in it first - a box that only exists when saving, so in the Open dialog the button could not be used at all. It asks for a name now, in both. And deleting a folder simply failed, because the app tried to delete it as though it were a file. It works, and it asks first - naming what goes with it: \"It holds 2 songs, and they go with it\", or \"It is empty\", or, if the folder could not be read, that there may be songs in there rather than pretending it is empty. Renaming and moving folders already arrived with the song-management work; this finishes the set.",
      "tickets": [
        54
      ]
    },
    {
      "title": "Choose what happens at the end of the song: no loop, loop song, or loop the selection",
      "version": "2026.8.55",
      "date": "2026-09-01",
      "area": "Sequencer",
      "type": "New feature",
      "userFacing": true,
      "details": "A song playing to the end and starting again was never a choice - it was the only thing the engine did. There is now a loop button beside play; clicking it offers the three modes and ticks the one in force, and the button itself keeps that mode's icon so you can see what it is doing at a glance. No loop plays to the end and stops, leaving the playhead where the music stopped rather than snapping back to the top; press play there and it starts again from the beginning. Loop song is what you had. Loop selection plays on until it REACHES the bars you selected and only then repeats them, so starting before the selection plays into it and starting after it runs the song out and catches it on the way round - the playhead is never yanked backwards over music you deliberately went past. Re-drag the selection while it is looping and the loop moves with it. The loop browser's button changed icon to a library, because the repeat arrows now belong to the transport next door.",
      "tickets": [
        25
      ]
    },
    {
      "title": "Deleting a custom module panel now asks first",
      "version": "2026.8.54",
      "date": "2026-09-01",
      "area": "Modules",
      "type": "Bug fix",
      "userFacing": true,
      "details": "The module UI editor's Panel menu had \"Delete selected\" and \"Delete panel\" side by side, with the same icon and labels a word apart. The first removes a shape you can put back; the second destroyed a panel you had built by hand - for every device of that type, in every song - wrote it to disk immediately, and left the editor's own undo unable to recover it. One mis-click and it was gone, with only a status line that faded after four seconds. Deleting a panel now asks, and the question names what actually goes rather than what you clicked. The two items are also separated: the destructive one is last, behind a divider, with a bin icon instead of a second cross.",
      "tickets": [
        69
      ]
    },
    {
      "title": "Beta deploys move their tickets along the board again",
      "version": "2026.8.54",
      "date": "2026-09-01",
      "area": "Build",
      "type": "Bug fix",
      "userFacing": false,
      "details": "deploy-beta.sh sourced lib.sh but never called load_env, so EZMUZE_BUGS_TOKEN was never exported into the environment move-tickets.py reads from. It printed its \"no token, so no tickets were moved\" banner, the || true on that call kept the build green, and nobody reads the log of a green build - so every beta moved its tickets INTO the release queue (prepare.sh, which did have the token) and never out again. 38 tickets were stranded there. prepare.sh, build-head.sh and publish.sh all called load_env; this was the only one that did not. Since load_env is fatal when the token is absent, the same silent failure cannot recur: a misconfiguration now stops the deploy, while a tracker outage still does not."
    },
    {
      "title": "Dialog headlines are no longer shouting",
      "version": "2026.8.54",
      "date": "2026-09-01",
      "area": "UI",
      "type": "Bug fix",
      "userFacing": true,
      "details": "Nine dialogs set their first line in the largest type the app has - the size reserved for top-level section headers like Preferences' own tab headings. The result was a headline 60% bigger than the title bar of the very window it sat in, and a third bigger again than the text underneath it, which is why they read as out of place rather than merely large. They now use the size meant for panel and dialog sub-titles: still a clear step above the body, no longer competing with it. Affects the missing-packs and licence dialogs, the couldn't-open and unsaved-changes prompts, pack syncing, update checks, the autosave recovery prompt and the loading overlay. The About and Credits screens keep the big type, because there it is the top level rather than an introduction to something else.",
      "tickets": [
        121
      ]
    },
    {
      "title": "Opening a legacy song while signed out now offers to sign you in",
      "version": "2026.8.54",
      "date": "2026-09-01",
      "area": "UI",
      "type": "Bug fix",
      "userFacing": true,
      "details": "Legacy songs are built out of licensed loop packs, and ezmuze central is what says which ones are yours - so signed out, the app cannot open one. It said so, and then told you to go to Preferences > Services and log in: directions rather than a way out. You had to close the dialog, navigate three levels in, sign in through the browser, and then go and find the song again - and if you had arrived through a link, there was no song left to find. There is now a \"Log in and open\" button on that dialog. It signs you in and then opens the song, so you never have to go looking for it. Cancelling the sign-in now says so too, instead of leaving you with an empty screen and no idea what happened to your song.",
      "tickets": [
        118
      ]
    },
    {
      "title": "A service whose readiness comes from another one now follows it",
      "version": "2026.8.53",
      "date": "2026-09-01",
      "area": "Services",
      "type": "Bug fix",
      "userFacing": false,
      "details": "Three faults found by driving the new Discord target through the app rather than trusting it. Preferences > Services printed a green \"Ready\" for any service with no sign-in of its own, which was right for the local disk and a contradiction for a service that borrows ezmuze central's account - it said Ready while File > Share greyed the same service out. The Discord target also computed its state once at startup, so signing in to central mid-session left it still saying \"sign in to central\" until a restart. And a destination the server has switched off reported itself ready, which matters because the share picker renders the whole song before calling the target - about ten seconds a minute of music - so it would have spent a full render and only then said no.",
      "tickets": [
        89
      ]
    },
    {
      "title": "Share a finished track to the ezmuze Discord",
      "version": "2026.8.52",
      "date": "2026-09-01",
      "area": "Services",
      "type": "New feature",
      "userFacing": true,
      "details": "File > Share now offers the ezmuze Discord alongside this computer and ezmuze central. It renders the song as an ogg and posts it into #ezm-song-sharing, credited to your ezmuze account with a link to your profile, and carrying any licence credits the song owes. You need to be signed in to ezmuze central, because that sign-in is what the post is attributed to - there is no separate Discord login and nothing to pair. Preferences > Services also carries the invite link, so the app can get you into the Discord in the first place. Ogg only: the upload limit is 8 MB and a four-minute WAV is about 46, so offering WAV would be offering a button that fails.",
      "tickets": [
        89
      ]
    },
    {
      "title": "ezmuze studio now tells you when there is a newer version",
      "version": "2026.8.51",
      "date": "2026-09-01",
      "area": "Desktop",
      "type": "New feature",
      "userFacing": true,
      "details": "Studio is not in any app store, so nothing was keeping anyone current. On launch it now quietly asks the ezmuze CDN what the newest build is, and if you are behind, the welcome screen shows a banner at the top: the new version, when it was released, the version you are on, a few of the changes you would get, and a Download button that takes you straight to the installer for the build you are running (DirectX, OpenGL or Linux). Help > Check for updates has been switched on to do the same thing on demand, and it will also tell you plainly when you are up to date, when it could not reach the internet, or when you are running a development build. A development build is never told it is out of date. Desktop only for now - the web version updates itself as soon as you reload the page.",
      "tickets": [
        93
      ]
    },
    {
      "title": "Manage your songs from the Open dialog: rename, duplicate, move and delete",
      "version": "2026.8.50",
      "date": "2026-09-01",
      "area": "UI",
      "type": "New feature",
      "userFacing": true,
      "details": "The song picker could only open and save, which left organising your own work to whatever file manager your computer gave you - and on the web, to nothing at all. Right-click any song in Open or Save and you now get Open, Rename..., Duplicate..., Move to (listing the folders that are actually there, plus the level above) and Delete. Renaming and duplicating ask for a name with the current one already selected, so replacing it or editing one word are both a single action. Deleting asks first, names the song and the place it is going from, and says plainly that it cannot be undone - there is no trashcan behind it. Actions a particular source cannot do are shown greyed rather than hidden, with a line saying which source: ezmuze central can be saved to but its API offers no rename and no delete, so those two stay off there while This computer, Browser storage and Google Drive do all of it.",
      "tickets": [
        88
      ]
    },
    {
      "title": "Autosave a recovery copy, so a crash can't lose your work",
      "version": "2026.8.49",
      "date": "2026-09-01",
      "area": "Desktop",
      "type": "New feature",
      "userFacing": true,
      "details": "ezmuze now keeps a recovery copy of the song you're working on, written quietly as you go. If it crashes, is killed, or the power goes, the next launch offers to restore exactly what you had — with Restore or Discard. A clean exit or a save removes the copy, so you're only ever offered genuinely unsaved work. This is the other half of the unsaved-changes protection added recently (which only covered a deliberate quit). Desktop for now; the web build is a follow-up.",
      "tickets": [
        114
      ]
    },
    {
      "title": "Wider tempo range",
      "version": "2026.8.49",
      "date": "2026-09-01",
      "area": "Sequencer",
      "type": "Improvement",
      "userFacing": true,
      "details": "Songs can now be set from 10 to 300 BPM, up from 15–200 — wide enough for half-time and double-time work and the faster genres the old ceiling cut off. Applies to new-song defaults and the Song settings tempo alike.",
      "tickets": [
        13
      ]
    },
    {
      "title": "A credits screen",
      "version": "2026.8.49",
      "date": "2026-09-01",
      "area": "UI",
      "type": "New feature",
      "userFacing": true,
      "details": "Help › Credits names who made ezmuze and the third-party work it stands on — KNI, GustUI, the audio codecs, and the CC-BY grand piano — with a thank-you to the community that funds it. Scrollable, and reachable from the Help menu.",
      "tickets": [
        97
      ]
    },
    {
      "title": "Full-screen mode",
      "version": "2026.8.49",
      "date": "2026-09-01",
      "area": "UI",
      "type": "New feature",
      "userFacing": true,
      "details": "ezmuze can run edge to edge with no window border — more room for the grid, and one thing on screen for a long stretch. Toggle it from View › Full screen, with F11, or the new “Full screen” Display preference; it is remembered across launches, so if you leave full-screen on that is how the app comes back. Restores cleanly to your previous window size and position. (Desktop for now; the web build is a follow-up.)",
      "tickets": [
        96
      ]
    },
    {
      "title": "Reopen your last song on launch",
      "version": "2026.8.49",
      "date": "2026-09-01",
      "area": "UI",
      "type": "New feature",
      "userFacing": true,
      "details": "A new Display preference, “Reopen last song on launch” (legacy’s “Remember Song”). With it on, ezmuze skips the welcome screen and loads whatever you last had open — a local file or a cloud song alike. If that song can no longer be opened (a deleted file, a service you’ve signed out of), the welcome screen appears instead, so it never strands you on a blank screen. Off by default.",
      "tickets": [
        95
      ]
    },
    {
      "title": "The File menu no longer has a doubled divider",
      "version": "2026.8.49",
      "date": "2026-09-01",
      "area": "UI",
      "type": "Bug fix",
      "userFacing": true,
      "details": "The File menu had two blank separator entries in a row before Preferences, which drew as a doubled divider line. It’s now a single separator.",
      "tickets": [
        113
      ]
    },
    {
      "title": "Copy the details from a “couldn’t open” error",
      "version": "2026.8.49",
      "date": "2026-09-01",
      "area": "UI",
      "type": "Bug fix",
      "userFacing": true,
      "details": "The “Couldn’t open that song” dialog now has a “Copy details” button that puts the whole error — title, summary and the technical reason — on the clipboard, so it can be pasted straight into a bug report. The text still can’t be selected in place (the UI has no in-place text selection), but it no longer has to be retyped from a screenshot.",
      "tickets": [
        117
      ]
    },
    {
      "title": "The “now playing” toast can be turned off",
      "version": "2026.8.49",
      "date": "2026-09-01",
      "area": "UI",
      "type": "New feature",
      "userFacing": true,
      "details": "The floating panel that appears while a song plays — title, artist and a progress bar — is now a Display preference (“Show ‘now playing’ toast”), on by default. Turn it off and it stays hidden during playback for people who find it in the way.",
      "tickets": [
        105
      ]
    },
    {
      "title": "Hide channel headers while a song plays, for a wider timeline",
      "version": "2026.8.49",
      "date": "2026-09-01",
      "area": "UI",
      "type": "New feature",
      "userFacing": true,
      "details": "A new Display preference, “Hide channel headers while playing”. With it on, starting playback drops the channel header column on the left and hands that width to the timeline, so more bars and more of each clip are on screen for watching a song play back; the headers come straight back the moment you stop. Off by default, and it changes nothing about editing.",
      "tickets": [
        106
      ]
    },
    {
      "title": "Linux head builds again (QuitGuard was missing from its project)",
      "version": "2026.8.47",
      "date": "2026-09-01",
      "area": "Build",
      "type": "Bug fix",
      "userFacing": false,
      "details": "The #45 quit-guard work added QuitGuard.cs to the GL and DX desktop projects but not the Linux one, whose shared Program.cs calls QuitGuard.Install unconditionally. The Linux head therefore failed to compile ('QuitGuard does not exist'), which failed the whole release build - every build since #45 was red for this reason. The file is now linked into the Linux project too; its veto body is DESKTOPDX-only, so on Linux it compiles to the no-op Install."
    },
    {
      "title": "Open a song straight from a link, skipping the welcome screen",
      "version": "2026.8.47",
      "date": "2026-09-01",
      "area": "Web",
      "type": "New feature",
      "userFacing": true,
      "details": "The web edition now understands a deep link that loads one song from ezmuze central the moment the page opens, with no welcome screen in the way: add ?open=central:<file> to the URL, where <file> is the central file name. If you are signed into ezmuze central it opens your own cloud song; signed out, it opens a song from the shared area. Whose folder a link can reach is decided by the ezmuze central API, which resolves it from the caller's own sign-in - so a link cannot yet point at another specific person's PRIVATE song. The song loads through exactly the same importer and pack-licence gate as File > Open."
    },
    {
      "title": "Songs opened from ezmuze central no longer fail with a cryptic parse error",
      "version": "2026.8.47",
      "date": "2026-09-01",
      "area": "Services",
      "type": "Bug fix",
      "userFacing": true,
      "details": "Every song stored on ezmuze central is a compressed legacy .eez, saved under a bare title with no file extension. The open path decided legacy-versus-modern from the extension and, failing that, by peeking inside the file for a known marker - but the marker sits inside the compressed data where the peek could not see it, so a central song was handed to the modern reader, which choked on the very first (compressed) byte with \"0x1F is an invalid start of a value\". It now recognises that compressed-legacy signature directly and sends the song to the legacy importer that knows how to decompress it, the same one the built-in demo songs already used - so central songs open as reliably as the demos do."
    },
    {
      "title": "The sequencer grid, playhead and channel column now fill the whole view",
      "version": "2026.8.47",
      "date": "2026-09-01",
      "area": "Sequencer",
      "type": "Bug fix",
      "userFacing": true,
      "details": "On a song with a handful of channels in a tall window, the timeline used to stop dead at the last channel and the last bar: the grid lines, the playhead and the dark channel column on the left all ended partway down, leaving a bare void below and to the right that no DAW leaves empty. The grid now draws its bars and beats across the full width and height of the viewport, the playhead runs top to bottom of the visible area rather than stopping at the last row, and the channel column's background follows the window instead of being sized once at startup before the window had its real height. The last-bar clamp that clip culling and run labels depend on is kept separate, so only the drawing extends - nothing offscreen is created.",
      "tickets": [
        84
      ]
    },
    {
      "title": "Retired factory modules no longer linger in your module library",
      "version": "2026.8.47",
      "date": "2026-09-01",
      "area": "Modules",
      "type": "Bug fix",
      "userFacing": true,
      "details": "Chip64, EightBit and PokeyTone were three separate retro synths until they were merged into the single Chip module. Their old library entries stayed behind on every install that had ever run the older build, listed under My modules as though you had built them, and nothing removed them - the existing cleanup matched on NAME, which catches a module rebuilt under the same name and misses one that was renamed or merged away, because the old name no longer matches anything. The library now REMEMBERS which modules were shipped ones. An id it has seeded before and is no longer seeding is a retired factory module, and its entry goes - so nobody has to remember to add a line to a list when a module is renamed, which is the maintenance burden that caused a similar bug with the demo list. Your own modules are never at risk: their ids were never seeded, so they are never in that history. The three that predate the record are cleaned by a one-off keyed on their exact ids, so a module you built and happened to name Chip64 is untouched.",
      "tickets": [
        63
      ]
    },
    {
      "title": "Adding a sample no longer hides two of its four failures",
      "version": "2026.8.47",
      "date": "2026-09-01",
      "area": "UI",
      "type": "Bug fix",
      "userFacing": true,
      "details": "Inside one block of code, two ways that adding a sample can fail put their message on a line that fades after four seconds, while the two beside them used a message that stays. The two that faded were a pack you do not have a licence for, and a download that did not arrive - both cases where you then have to work out for yourself why nothing was added. All four stay now. The line that still fades is the Fetching one, which is progress rather than an outcome, and success, which speaks for itself.",
      "tickets": [
        73
      ]
    },
    {
      "title": "Four silent failures now say what went wrong",
      "version": "2026.8.47",
      "date": "2026-09-01",
      "area": "UI",
      "type": "Bug fix",
      "userFacing": true,
      "details": "Four separate paths reported a real failure by writing to a console that a shipped build does not have, so from your side the action simply did nothing. Dropping a file the app cannot read - permissions, a file still being written, a path that is too long - was indistinguishable from dropping it on dead space; it names the file and the reason now. Dropping a file in a format the app does not recognise said nothing at all; it says so now, naming the file, because not recognised about an unnamed file is not something anyone can act on. A pack loop whose audio cannot be decoded left its waveform blank forever with nothing logged and nothing shown - that one had no error handling at all, so the failure vanished into a background task; it reports on the browser-s own status line now, which is the right surface for one row out of a list failing rather than something that interrupts you. And saving a file could not tell a write that FAILED from one you cancelled, because both came back the same - which meant a disk that refused your exported song reported Saved and threw the render away. Those are now different answers, and a failure says why.",
      "tickets": [
        79
      ]
    },
    {
      "title": "The two legacy demo songs now actually ship",
      "version": "2026.8.47",
      "date": "2026-09-01",
      "area": "Build",
      "type": "Bug fix",
      "userFacing": true,
      "details": "Sample song and ezmuze 2.0 were listed in the demos but their files were never committed - they were treated as local fixtures, ignored by version control, and present only on one machine. Every build made from a clean checkout therefore offered two demos that could not open. This is the same fault as the one where a demo shipped for months that nobody could reach, and it was caught by the test written after that: a check that every file the demo service claims to serve is a file that actually ships. The two songs are committed now, and marked as binary content so the bytes that reach a user are the bytes that were committed rather than something with its line endings rewritten in transit."
    },
    {
      "title": "Preferences has a Cancel, and its scale slider stops pretending to be in charge",
      "version": "2026.8.47",
      "date": "2026-09-01",
      "area": "UI",
      "type": "Bug fix",
      "userFacing": true,
      "details": "Preferences offered Apply and Okay and no way out. Having toggled something the only exits were to accept it or to close with the X and be left unsure whether that kept your changes or dropped them. There is a Cancel now, which does what the X always did and says so - it abandons whatever is still pending. It cannot undo an Apply you already pressed, because Apply commits, which is the point of it. As a bonus this also gives the dialog Escape, since a dialog with no cancel button deliberately takes no part in that. Separately, the UI scale slider sat fully lit underneath the Set by system toggle that was overriding it, which reads as broken. It is dimmed while the toggle owns the value now. Still draggable, deliberately: dragging is how you take the value back, and it switches Set by system off for you - so the control has to stay live, it just should not look like it is already in charge.",
      "tickets": [
        85
      ]
    },
    {
      "title": "Removed the screenshot launch harness; the control API supersedes it",
      "version": "2026.8.47",
      "date": "2026-09-01",
      "area": "Build",
      "type": "Internal",
      "userFacing": false,
      "details": "The desktop builds took a --screenshot path plus --scenario, --delay and its own window sizing: launch the app, drive it to a named state, count down some frames, write a PNG and exit. The remote-control API does the same job from a RUNNING instance and does it better - no relaunch per shot, no scenario dispatcher to keep in step with the UI as it changes, and what gets captured is the app you were already looking at rather than a fresh one that has to be steered back to the interesting state. Removed: the flags, StudioOptions.ScreenshotPath/Scenario/DelayFrames/Width/Height, the frame countdown and file write, the scenario dispatcher, and the crash reporter-s special case for suppressing its dialog during a capture. Kept: GET /screenshot and the PNG encoder it shares, --control-port, and --hold-for-click, which is a recording aid rather than a capture tool. Also added POST /action explorer_tab, because driving docked panels by synthetic clicks is unreliable - a collapsed panel has no tab strip in the element tree, so the click fails with nothing to distinguish it from a missed coordinate."
    },
    {
      "title": "The wave browser no longer keeps every pack waveform it has ever drawn",
      "version": "2026.8.47",
      "date": "2026-09-01",
      "area": "UI",
      "type": "Bug fix",
      "userFacing": false,
      "details": "Two caches on the pack-browsing path were never cleared and never removed from: one waveform retained per DISTINCT pack loop whose peaks ever bound, for the life of the browser, so a long browse through a large library grew memory that only closing the panel reclaimed. They are bounded now by the same least-recently-used policy the preview caches got in #78 - the shape was already settled there, so this follows it rather than inventing a second one - with a 16 MB ceiling, a quarter of the audio budget because peaks are far smaller than the audio they describe. Trimming happens after binding, not before, so everything drawn on the current pass has just been touched and the oldest entries really are the ones no visible row is using. GustUI gained a way for a waveform to report its own size, since the thing holding the mipmap is the thing that knows how big it is.",
      "tickets": [
        103
      ]
    },
    {
      "title": "Closing the window no longer throws away unsaved changes",
      "version": "2026.8.47",
      "date": "2026-09-01",
      "area": "Desktop",
      "type": "Bug fix",
      "userFacing": true,
      "details": "Closing the app with unsaved work ended the process in under three seconds and took the changes with it. The four in-app routes that abandon a song - closing the sequencer, New, Open, opening a demo - have asked before doing it for a while now; quitting was the fifth and asked nothing, because nothing in the app was listening for the window closing at all. It now asks the same question, with the same Save, Discard and Cancel, and Cancel really does mean carry on as though nothing happened. Windows shutting down or logging off is deliberately let through rather than blocked - refusing it would just make this the app stopping your reboot. This is the DirectX build only for now: it hosts its window in a way that can be asked to wait, and the other builds cannot be, which is tracked separately. Worth knowing what this does NOT cover, either: it is a question asked when you close the window, so it cannot help if the app crashes, is killed, or the power goes. Saving a recovery copy as you work is the answer to that and is its own piece of work.",
      "tickets": [
        45
      ]
    },
    {
      "title": "Escape closes a dialog, Enter accepts it",
      "version": "2026.8.47",
      "date": "2026-08-31",
      "area": "UI",
      "type": "Improvement",
      "userFacing": true,
      "details": "Every full-screen editor already backed out on Escape, so the app taught you that Escape means get me out of here - and then every dialog ignored it, leaving you to find a mouse target. Escape now presses a dialog-s Cancel or Close, and Enter presses its confirm. Done once for all dialogs rather than seven times over, so anything built later gets it without anybody remembering to. Two details worth knowing. Escape works while you are typing, because mid-word is most of when you want it - and it presses Cancel, so a value you were half-way through editing is discarded rather than committed. Enter does NOT work while you are typing: pressing it after typing a song name reaches the field, not the dialog. Enter also refuses to press a button that deletes, discards, overwrites or replaces, so it can never become a keyboard shortcut for destroying something by accident. Only real dialogs take part: a window with no footer buttons is a view rather than a dialog, so the sequencer and the docked panels are untouched by either key.",
      "tickets": [
        68,
        111
      ]
    },
    {
      "title": "A module UI panel could crash the app while it was open",
      "version": "2026.8.47",
      "date": "2026-08-31",
      "area": "Sound engine",
      "type": "Bug fix",
      "userFacing": true,
      "details": "Opening a module UI panel could take the whole app down with no warning. The panel reads live values out of the audio graph once a frame to move its dials, and for one shape of wiring - an audio-rate input fed by more than one control source - it asked for a per-tick snapshot the engine only ever creates for control-rate inputs. That returned nothing, and nothing was then used as if it were a number. Because this ran from the frame loop rather than from anything the user did, it could fire at any moment the panel happened to be open, which is the worst version of a crash: no action to blame and nothing to avoid. Two changes. The readout no longer asks about audio-rate inputs at all, which is the actual mistake - an audio input is not control modulation, so there was never anything for the panel to show there. And the engine no longer assumes the snapshot exists: where one is missing it contributes nothing instead of crashing, because code on the frame loop should degrade rather than take the process with it.",
      "tickets": [
        112
      ]
    },
    {
      "title": "Export as Ogg Vorbis, about a tenth the size of a WAV",
      "version": "2026.8.47",
      "date": "2026-08-31",
      "area": "Sound engine",
      "type": "New feature",
      "userFacing": true,
      "details": "Save as audio now offers Ogg Vorbis alongside WAV, and picks it by default. A four-minute song that was 46 MB is a little over 5 MB, which is the difference between a file you can send someone and one you cannot - and getting a track onto a phone or up to SoundCloud was half the point of having an export at all. The format is chosen before you press the button, because it decides what the encode does; the choice is remembered for the rest of the session. Files carry the song title and author as tags, so they arrive named properly in a media player. The encoder is ported from legacy ezmuze 3, which used the same library - and porting it turned up two faults that were in the original and would have shipped again unnoticed. The first: the last page of the stream was never written, so every exported file was cut short. On a long track that was a fraction of a second off the end and easy to miss; on a clip of a second or so it meant the file contained no audio at all. The second: the first 23 milliseconds were missing from every file, which is nothing on a track that fades in and is exactly the attack of the kick on a track that does not. Both are fixed and both are now covered by tests that encode audio, decode it back and check it came out whole and in the right place.",
      "tickets": [
        109
      ]
    },
    {
      "title": "Save your finished song as an audio file",
      "version": "2026.8.47",
      "date": "2026-08-31",
      "area": "UI",
      "type": "New feature",
      "userFacing": true,
      "details": "File > Share now offers Save as audio, which renders the whole song and writes it to a .wav you choose - on the desktop through the normal save dialog, in the browser as a download. This is the step that was missing from the end of the whole workflow: it is how a track gets out of ezmuze and onto a phone, into a DAW, or up to SoundCloud, and without it everything made here stayed here. It is a publish TARGET rather than a File menu item, sitting beside Share to ezmuze central, so sending a song somewhere is one dialog with a list of destinations - and saving a video of the song will slot in as another format here rather than as a separate feature. Rendering takes roughly ten seconds per minute of music and happens in the background with a running percentage, so the window stays alive while it works. Two related fixes went with it. The share dialog used to tell you to bounce the song first when a destination wanted audio, which was advice you could not act on, and it then published with no audio attached at all; it renders for you now. And the save dialog no longer insists every file is an ezmuze project - it takes its filter from what is actually being saved. WAV only for the moment: there is no ogg or mp3 encoder in the app yet, so a four-minute song is about 46 MB.",
      "tickets": [
        86
      ]
    },
    {
      "title": "Pack covers come from the server, and every pack says where you stand with it",
      "version": "2026.8.47",
      "date": "2026-08-31",
      "area": "Services",
      "type": "Improvement",
      "userFacing": true,
      "details": "Three things. Cover art is now asked for rather than guessed: the catalogue call carries an images array per product, so the app uses the URL the server gives instead of building a CDN path out of the uppercased product id and keeping a hand-written table of six exceptions for the ones that path missed. Those six rows recorded their own problem - one was filed under an id that was not the product-s own, another sat one digit away from it - and nothing could check any of them. The server knew all six the whole time, including one cover at a path no rule over the id could ever have produced. A thumbnail beside the listed image is still preferred where one exists, since a card is 155 pixels wide, but it is an optimisation with the server-s own URL as the fallback rather than a guess with nothing behind it. Second, a pack you already own now says so: an Owned pill in the same place as the price, in green rather than the accent colour, so one glance over the shelf tells you what you can use and what you would be buying. Third, packs that can no longer be bought are no longer listed. Two of the ten had no purchasable licence at all - every licence marked as not appearing in the store, and no primary licence - which makes them retired: nobody who does not already own one could ever get one, so offering them was a shop window with nothing behind it. A pack you DO own stays visible however retired it is, because you can still use it.",
      "tickets": [
        104
      ]
    },
    {
      "title": "ezmuze central packs are a shelf of covers, with what they cost",
      "version": "2026.8.47",
      "date": "2026-08-31",
      "area": "UI",
      "type": "Improvement",
      "userFacing": true,
      "details": "Browsing ezmuze central in the pattern explorer was a column of identical text rows with the whole panel empty beside it. The top level is now a grid of the real pack COVERS - the same artwork the website shows - with each pack-s own description under it instead of a line about your licence. A pack you do not own wears its price as a badge (in Ʃ, ezmuze central-s own currency), and drilling into one shows what it would cost to own along with a Get this pack button through to the store, so auditioning something and then buying it is one path rather than two. The app cannot take the money itself: ezmuze central has no purchase endpoint, and the website-s basket is not addressable by URL, so the hand-off to the store page is the honest ceiling rather than a checkout that could not complete. Two naming faults went with it. Channel types inside a pack were shown with the product-s four-letter code still attached - Magenta Neuro Pack listed mnp_Bass, mnp_Drums, mnp_Leads - which is a filename convention leaking into the UI, since you have already clicked the pack. They read Bass, Drums and Leads now. And loop packs listed their author underneath, which for most packs is the pack name over again; a second line repeating the first is worse than no second line, so it is dropped when the two match. Finally, a wave source you are not signed in to now offers a sign-in button where it used to show an empty list, which looked like a source with nothing in it rather than one waiting for you.",
      "tickets": [
        104
      ]
    },
    {
      "title": "The demos are shown as cover art, and the Demos source owns the songs it lists",
      "version": "2026.8.47",
      "date": "2026-08-31",
      "area": "UI",
      "type": "Improvement",
      "userFacing": true,
      "details": "Two halves of the same idea. First, a source can now ask to be shown as a GRID OF COVERS instead of a list of rows, and the Demos source asks for it. Rows are right for a filesystem, where the name is the thing and there may be hundreds of them; they are wrong for ten curated songs called Liquid, Butterfly and The Dark, where ten near-identical lines of text is the worst possible way to tell one from another. A card is one click to open - no selecting and then travelling to a footer button - and the grid reflows to however wide you have dragged the dialog. It is a request any source can make rather than a special case for this one, so ezmuze central's songs can ask for the same treatment later, and the cards themselves are the welcome screen's, lifted out and shared rather than written a second time. Second, the Demos service now owns its content outright: one table holds every demo's name, blurb, artwork AND the file its bytes come from, and the service reads those bytes itself. Nothing else in the app names a demo file any more. That closes the last of #107 - the bug where a demo shipped for months that no user could open - because there is no longer a second list anywhere to forget to update. A test now checks the other direction too: every file the service claims to serve is a file that actually ships, so a typo in one of those paths fails the build instead of failing a user who clicks the card.",
      "tickets": [
        108
      ]
    },
    {
      "title": "Demos are a source you can browse, with artwork and what each one shows",
      "version": "2026.8.46",
      "date": "2026-08-31",
      "area": "UI",
      "type": "New feature",
      "userFacing": true,
      "details": "The demos that ship with the app are now a SOURCE in the Open dialog, listed beside This computer and ezmuze central, and the File menu's Open demo submenu is gone. Each demo carries a line saying what it DEMONSTRATES rather than what genre it is - \"Nine voices on one row, chords drawn with per-note bend\" instead of \"Too many chords\" - because somebody scanning the list is deciding what to learn from. This exists because of #107: a demo shipped for months that nobody could open, since the only thing offering demos was a list kept by hand beside a directory of files, and the two drifted. A source enumerates what actually exists, so that cannot happen again; the welcome screen, the Open dialog and the control API now all read one catalogue. Demos that correspond to an ezmuze central product wear the real cover art - ezmuze 2.0 shows the ezmuze 2 edition cover the website uses - through exactly the same resolution the pack templates already used, and the demo cards are portrait now rather than wide and short, because real artwork is 3:4 and a landscape tile threw most of it away. The service OWNS those songs rather than pointing at them: it serves their bytes, and it holds the file paths - the two real legacy .eez ones included, whose paths used to live in a second dictionary somewhere else entirely. Two smaller fixes fell out of it - the picker's \"New folder\" was decided from the FIRST service rather than the one being browsed, so a read-only source still offered it, and hiding it needed the button moved off-screen as well as sized to nothing, because a zero-sized button still draws its label.",
      "tickets": [
        108
      ]
    },
    {
      "title": "The \"Too many chords\" demo is actually offered",
      "version": "2026.8.46",
      "date": "2026-08-31",
      "area": "UI",
      "type": "Bug fix",
      "userFacing": true,
      "details": "too-many-chords.ezproj has shipped since the day it was written - generated by GenerateDemoProjects on every demo refresh and covered by ShippedDemoFreshnessTests like the rest - and no user could ever open it (bug board #107). It was never added to BundledDemos, the one list the File menu and the welcome screen both read, and git history confirms it was never there rather than having been dropped: the song was fine, nothing offered it. It is now listed as \"Nine voices on one row, chords drawn with per-note bend\", which is what it demonstrates - all nine notes sit on a single piano-roll row and every chord in the piece is drawn with per-note pitch bend rather than played. A test now asserts that every shipped .ezproj appears in BundledDemos, so the two halves cannot drift apart again; it was checked by removing the entry and watching it fail, because a guard nobody has seen fail is not a guard.",
      "tickets": [
        107
      ]
    },
    {
      "title": "The light theme is usable",
      "version": "2026.8.46",
      "date": "2026-08-31",
      "area": "UI",
      "type": "Bug fix",
      "userFacing": true,
      "details": "Selecting Light produced an app you could not read: dialog buttons were white on near-white, the menu bar and every channel name vanished, clip labels across the whole timeline were white on light, the mixer meters were black slabs and the welcome screen lost its row striping and every hover (bug board #66). Underneath the symptoms were two causes. First, text colour was CAPTURED rather than bound: GustUI's TVFillSolidColor has taken a Func<Color> since the theme work, which is why backgrounds always followed a palette switch, but TVColor - which carries every text colour - had no equivalent, so a label kept whatever the palette said when its element was built. Those colours were right; they were frozen. TVColor now takes a Func<Color>, and equality treats a live colour as never equal to anything, because the \"assigning an equal colour is a no-op\" optimisation would otherwise compare a live binding against the captured value it was replacing, find them equal at that instant, and silently skip the fix. Second, white is not a text colour, it is a colour that happens to work on dark: the neutral button fill is a dark grey in one palette and a near-white in the other, so 22 hardcoded white labels were unreadable in one of them. Those now take the theme's text colour, while labels on the green and red fills keep white because those stay dark enough in both - the rule is contrast against the actual fill, not banning a literal. Also fixed: secondary text was a fixed mid-grey chosen to sit between the DARK panel and its text, and is now a lerp from whatever surface it is on; the meters gained a MeterWell palette token instead of a hardcoded near-black; and the welcome screen's striping and hover washes were straight-alpha white, invisible on white, and now borrow the body text's hue so they lighten or darken as the palette requires. The upshot is that the theme can now be switched at runtime and the app repaints, rather than needing every window closed and reopened.",
      "tickets": [
        66
      ]
    },
    {
      "title": "Dialog buttons are one size, and the file browser stops splitting its own pair",
      "version": "2026.8.45",
      "date": "2026-08-31",
      "area": "UI",
      "type": "Improvement",
      "userFacing": true,
      "details": "Twenty-three footer buttons across the dialogs used six different sizes for the same role - 130x36, 150x36, 150x40, 190x40, 190x36 and 120x34 - with no shared constant to drift from (bug board #71). They are now ModalButtons.Footer (130x36) unless a label genuinely will not fit, in which case ModalButtons.Wide (190x36). Deliberately two rather than one: \"Add to wave bank\" and \"Import without unlicensed\" do not fit 130, and clipping them to prove a point would be worse than the inconsistency; Wide keeps the same HEIGHT so a row of mixed buttons still lines up and only the width gives way. Two named sizes with a rule for choosing between them is a convention, where six unnamed ones was drift. The welcome screen keeps its own larger buttons and is documented as an exception: its actions are the screen's whole purpose rather than a footer under a form, and its split \"New project\" button needs the width for its chevron. The worst case in the ticket is also fixed - the file browser used to put Save/Open INSIDE the body at a hand-computed offset while its Cancel sat in the modal footer, so the two halves of one decision were in different rows at different sizes and the dialog did not read as having a confirm and a cancel at all. Confirm is a footer button now, beside Cancel and listed first so it lands rightmost like every other dialog; New folder stays in the body because it acts on the folder being looked at rather than being one of the dialog's outcomes.",
      "tickets": [
        71
      ]
    },
    {
      "title": "Settings dropdowns stop looking like confirm buttons",
      "version": "2026.8.45",
      "date": "2026-08-31",
      "area": "UI",
      "type": "Bug fix",
      "userFacing": true,
      "details": "Preferences' selectors - Theme, Chroma key, Waveform render mode - were built with the same green gradient the design guide reserves for primary and confirm actions, so the Display tab showed three green pills of which two chose a value and one committed the dialog, with nothing to tell them apart (bug board #72). Green is a promise that a click COMMITS something, and spending it on a chooser makes it worthless on the button that actually acts; the selectors now take the neutral fill. Their labels also take the theme's text colour rather than a hardcoded white, which the positive fill had been hiding - white on the neutral fill is unreadable under the light theme, the same mistake #66 is open about. Separately, each dropdown's menu was a hardcoded 210 wide against a 168 control, so it hung 42px past the thing it dropped from and the two disagreed about how wide the content was; the menu now takes the control's own width, so widening one widens both. These rows are shared by Preferences, Song settings and New song, so it is three dialogs from one change - which is also why the drift mattered.",
      "tickets": [
        72
      ]
    },
    {
      "title": "Confirm and Cancel are in the same place in every dialog",
      "version": "2026.8.45",
      "date": "2026-08-31",
      "area": "UI",
      "type": "Bug fix",
      "userFacing": true,
      "details": "New project and Song settings put Cancel in the rightmost slot while every other dialog puts the confirm button there, so the same screen position meant \"go ahead\" in Preferences and \"throw it away\" in the two dialogs that create a song and change its tempo (bug board #67). ModalWindowElement lays the FIRST button in its list out RIGHTMOST, and those two were the only ones passing { cancel, okay } instead of the other way round - MissingPacksModal even writes the convention down in a comment. Both now pass { okay, cancel } and land where the rest of the app does. Verified by reading the buttons' positions off the element tree rather than by eye: New song now reads Cancel(x=585), Create(x=735) and Song settings Cancel(x=585), Okay(x=735), where before the two were exactly swapped.",
      "tickets": [
        67
      ]
    },
    {
      "title": "Chroma key the sequencer while it plays",
      "version": "2026.8.45",
      "date": "2026-08-31",
      "area": "Sequencer",
      "type": "New feature",
      "userFacing": true,
      "details": "Preferences > Display > \"Chroma key while playing\" turns the sequencer's empty background into a flat key colour DURING PLAYBACK, so a screen recording can be keyed out and the loops run over your own image or video (bug board #49). Two choices rather than one: green #00B140 and magenta #FF00FF. Which works is a property of the editor doing the keying and of the footage behind it - many tools key magenta automatically, and green footage such as foliage or green lighting fights a green key - so offering both costs one extra option and saves anyone whose material fights the other. Neither is one of the app's own colours: CategoryEffects and AccentModPositive are both pale mint (95,224,160), which a keyer would pull along with the background and take the automation accents with it. The whole field goes one FLAT colour: the per-row lanes normally carry a 6% lerp toward their channel's hue, which is right for editing and fatal for keying, since every row would be a slightly different shade and pulling one would leave the others as coloured bands across the footage. Verified by sampling rendered pixels rather than by eye - empty space reads exactly (0,177,64) and (255,0,255). On only while playing, because this is a filming mode and not a theme; selecting a key and staying stopped changes nothing. Blocks do pick up a green or magenta cast, since the sequencer's surfaces are deliberately semi-transparent so the background video can show through - they are far enough from the key colour not to be pulled by it, but they are tinted.",
      "tickets": [
        49
      ]
    },
    {
      "title": "A channel with content off-screen says so",
      "version": "2026.8.45",
      "date": "2026-08-31",
      "area": "Sequencer",
      "type": "New feature",
      "userFacing": true,
      "details": "A channel whose only clips sit further along the song looked exactly like an empty one, so it was easy to delete work you simply could not see (bug board #47). The channel header now shows a small dot when the channel holds content and none of it is in view. Deliberately NOT \"this channel is not empty\", which would light up on nearly every row of a real song and therefore mean nothing - this appears exactly when the timeline is misleading you, which is the moment before the mistake, and the useful guarantee is the absence: no dot means the row really is as empty as it looks. It is recomputed on every rebind, which is also what runs on scroll, so it appears and disappears as the view moves rather than being decided once when the row was built; and the visible test is the same one the block binder culls with, so the dot cannot disagree with what is actually drawn. It sits in the gap on the row's second line, between the pan slider and the meter, because the first line has no free space: at 160 wide it is accent, label, mute, solo and meter edge to edge. The first attempt put it there anyway at 6px and half-transparent - a Color multiplied by a float scales its ALPHA as well, so \"55% brightness\" meant 55% opacity - and it was drawn underneath the mute button, reporting the right size in the element tree while being invisible on screen. A marker whose job is to stop someone deleting work they cannot see does not get to be subtle.",
      "tickets": [
        47
      ]
    },
    {
      "title": "The status bar's readouts stop jittering",
      "version": "2026.8.45",
      "date": "2026-08-31",
      "area": "UI",
      "type": "Improvement",
      "userFacing": true,
      "details": "fps, frame time and memory were one right-aligned string with pipes between them, so whenever any of them gained or lost a digit ALL of them slid sideways - fps dropping from 144 to 99, or a frame range going from (3.6-11.8) to (3.9-35.8), moved the memory figure too, several times a second (bug board #23). The eye reads movement as meaning and none of that movement meant anything. Each reading now sits in its own box, sized once from the widest value it can ever hold, so a digit changing moves nothing but that reading's own tail. The widths are measured from worst-case templates rather than guessed at, because a reserve picked by eye is exactly what the bar had before. Text is left-aligned inside each box so the label is pinned and only the digits after it move; right-aligning would pin the tail and slide the word, which reads worse since the eye tracks \"mem\" rather than the last digit. The pipe separators are gone - three boxes with space between them are already three columns. This does NOT add the hovering realtime graph the ticket also asks for; that is a separate and much larger piece of work.",
      "tickets": [
        23
      ]
    },
    {
      "title": "\"What's new\" now waits until you dismiss it",
      "version": "2026.8.45",
      "date": "2026-08-31",
      "area": "UI",
      "type": "New feature",
      "userFacing": true,
      "details": "The release notes on the welcome screen showed once and were then gone for good, whether or not you had read them (bug board #27). The last-run version was advanced the moment the change log finished loading, so launching the app to get straight to a song spent that release's notes without anyone seeing them, and there was no way back. There is now a Dismiss button under the list, and the marker only moves when it is clicked - until then the notes come back on every launch. An unread notification should nag; the cost of nagging is one click. The button is 130x36 with the neutral fill, matching the app's other secondary buttons rather than inventing a seventh size while #71 is open about there already being six, and its label takes its colour from the theme rather than a hardcoded white, which is the mistake #66 is about. A dev build can dismiss the section for the session but that is not remembered, because it has no real version to record and writing \"dev\" into the marker would poison the next real update.",
      "tickets": [
        27
      ]
    },
    {
      "title": "Browsing samples no longer grows memory forever",
      "version": "2026.8.44",
      "date": "2026-08-31",
      "area": "UI",
      "type": "Performance",
      "userFacing": true,
      "details": "Every sample you previewed in the wave browser was decoded and then kept for as long as the browser lived - two dictionaries, one for the audio and one for the drawn peaks, that nothing ever removed from. A long sample-hunting session, which is exactly what that panel is for, climbed until it reached the out-of-memory path (bug board #78). Both are now bounded together by a 64 MB budget on the decoded audio, evicting least-recently-used first, where a use counts a READ as well as a write - so a preview you keep coming back to outlives one you touched once, and whatever is currently on screen stays at the recent end of the order rather than being evicted and immediately re-fetched. The peaks are evicted with the audio they belong to: they share a key and are written by the same line, so budgeting only the audio would have left the smaller cache growing without limit - the same bug, just slower. Evicting interrupts nothing, because playback and a drop each hold their own reference to the samples; dropping ours only means the next preview of that item fetches again. Both caches now also report themselves to the memory readout, which they did not before: an out-of-memory that cannot be attributed to the thing most likely to have caused it is very hard to act on, and this was invisible in a readout that accounted for 320 MB of 1298. Worth knowing for anyone chasing the rest: a two-second stereo preview is only about 353 KB, so this bounds the largest NAMED cache rather than all of the growth a browsing session shows - the remainder is waveform geometry and row elements, which is a separate problem.",
      "tickets": [
        78
      ]
    },
    {
      "title": "On a small window the sequencer keeps its title bar and menus",
      "version": "2026.8.42",
      "date": "2026-08-31",
      "area": "UI",
      "type": "Bug fix",
      "userFacing": true,
      "details": "Resized rather than maximised - around 900x620 - the sequencer's own title bar sat entirely ABOVE the visible area and the top half of File, Edit, View and Help was sliced off, so the song name was unreadable and the window could not be closed from its own chrome (bug board #65). GustUI's keep-a-window-on-screen clamp pulls a modal back when its bottom edge falls outside the window, and it did that unconditionally: the sequencer is 420 high with a floor on its minimum size, the window had 376, and the clamp pulled it up by the whole 44px of overflow. Both edges cannot be satisfied when a window is taller than the space it is in, so the top now wins - that is where the title bar and menu bar are, and losing the bottom of a scrolling timeline costs far less than losing the controls. The clamp meant to catch this already existed a few lines below and never fired, which is worth knowing for anything similar: it tested a copy of the position taken BEFORE the clamps ran, so it still saw the old value. The horizontal pair reads the live position and has never had the bug. Fixed in GustUI so every window gets it, not just the sequencer.",
      "tickets": [
        65
      ]
    },
    {
      "title": "A change log entry says which bug-board tickets it closes, and the release moves them",
      "version": "2026.8.42",
      "date": "2026-08-31",
      "area": "Build",
      "type": "Internal",
      "userFacing": false,
      "details": "Entries can now carry \"tickets\": [83] - the bug-board numbers that entry closes - and the release pipeline walks those tickets along bugs.ezmuze.studio on the release's behalf, so nobody has to remember to. tools/release/move-tickets.py is called from the one place that knows each step actually happened: prepare.sh once the build has a version (the ticket goes to the column keyed in-progress, which the board labels \"In release queue\"), deploy-beta.sh after the beta CDN swap (in beta testing), and publish.sh after the live swap on a promotion (shipped). Three rules, each because the obvious version is wrong. It is NEVER fatal: a tracker outage must not fail a release, which is deliberately the opposite of the register step in publish.sh that gates one - a build nobody can file a bug against must not reach users, while a ticket in the wrong column blocks nobody. It only ever moves tickets FORWARD: beta builds on every push and matches entries still saying NEXT, so without an ordering check the first beta after a release would drag its shipped tickets back to the release queue and the board would march backwards on a green build. And it leaves rejected and on-hold alone, because those are decisions somebody made on purpose and a stale id in a change log must not overturn one. Entries match on the release's version OR NEXT, because the change log is stamped partway through a release and this runs on both sides of that; once a release stamps them back to main they match neither, which is what stops a ticket being re-announced on every push forever. The field is invisible to users - Changelog.Since ignores it - and absent means an empty list, not null, since most entries have none. One trap found on the first live run, before this shipped: load_env() sourced /etc/ezmuze-release.env but never EXPORTED the token, so it existed as a shell variable only. Every consumer up to now read it by expanding it into a curl argument, which works fine on a shell variable, so nothing had ever noticed; the first child process to need it got an empty environment, said so in one calm line, and the build went green with the board untouched. The token is exported now, and a missing one is shouted rather than mentioned when there are actually tickets waiting to move - a configuration error that leaves no trace is worse than one that fails. The run after that got further and found the second half: the board answered the move with 403. Reads on it are public, so the release token sails through every GET and fails only on the write - it can register a version but has no `manage` scope, which is what moving a ticket needs. A 401 or 403 on a write is now called out as the credential rather than printed as a bare HTTP error, because that failure otherwise reads as a flaky board. Until the release token is given `manage`, or pointed at one that has it, the moves will keep being refused and the pipeline will keep saying so loudly."
    },
    {
      "title": "Opening or saving to a drive lists its contents again",
      "version": "2026.8.41",
      "date": "2026-08-31",
      "area": "Services",
      "type": "Bug fix",
      "userFacing": true,
      "details": "Pressing Up from a folder on a drive root left the file browser showing an empty drive with \"Could not find file 'C:\\Cyotek.Drawing.BitmapFont.dll'\" under it, and because the browser remembers the last folder it then REOPENED there every time - so Save as could land you somewhere with nothing listed and no way to finish the save (bug board #83). On Windows \"C:\\\" and \"C:\" are different places: the first is the drive root, the second is drive-RELATIVE and means wherever the process happens to be on that drive, which in a shipped build is the folder holding the executable. The browser's Up button does plain string maths on the path - deliberately, because it is shared with services like ezmuze central whose \"folders\" are a flat metadata field and which have no drive letters at all - and taking the parent of \"C:\\Program Files\" left the bare \"C:\". That was then listed as the install folder, whose filenames do not exist at the root of C:, hence the app naming one of its own DLLs. Resolving a bare drive letter to its root is a filesystem rule, so it now happens in the local-disk storage service where the other filesystem rules live, which also means the already-remembered bad path heals itself with nothing to migrate. Note neither Path.IsPathRooted nor Path.GetFullPath spots this - the first returns true for \"C:\" and the second resolves it to the drive-relative directory, which is the bug rather than the fix. Separately, a single unreadable file no longer empties an entire folder listing: the failure used to discard every entry gathered before it, and now that one file is skipped and the rest of the folder still lists.",
      "tickets": [
        83
      ]
    },
    {
      "title": "The Liquid demo plays its chords on one polyphonic channel",
      "version": "2026.8.40",
      "date": "2026-08-31",
      "area": "Sound engine",
      "type": "Improvement",
      "userFacing": true,
      "details": "The Liquid showcase built its four-note epiano chord out of four separate channels - \"Epiano Voice 1\" through \"Voice 4\" - each an identical Cerebrum holding one note (bug board #37). That was a workaround for an engine that used to be monophonic per channel, and the engine has not been that for some time: MaxVoices exists and is tested. A demo is something people read to learn the app, so a demo teaching a workaround for a limitation that no longer exists is worse than no demo. It is now one \"Epiano\" channel with four voices playing one \"Chords\" pattern, which is three fewer channels and three fewer Cerebrum instances doing one instrument's job. The per-voice level taper the four channel volumes used to carry is folded into the note velocities, so the voicing sits exactly as before - measured rather than assumed: rendered offline, the mix moves from -23.2 to -23.0 dBFS RMS with the peak 1.1 dB LOWER than before. The demo's own polyphony test got stronger in the process: it used to measure four fundamentals from four channels, which only ever proved there were four channels, and now measures all four from ONE."
    },
    {
      "title": "A save that cannot be written now says so instead of crashing",
      "version": "2026.8.40",
      "date": "2026-08-31",
      "area": "Desktop",
      "type": "Bug fix",
      "userFacing": true,
      "details": "Saving to a local file was the one save route with no error handling anywhere up its stack, so a read-only file, a full disk, a disconnected network drive, or a file held open by a sync client or a second copy of the app turned Ctrl+S into an unhandled exception and a crash report (bug board #74). The keystroke you press to protect your work was the one that lost the session. It now reports the failure as a message that stays until dismissed and points at Save As - and deliberately does NOT mark the song as saved, so the song stays modified and the unsaved-changes guard still stands between you and losing it. This is the same contract the cloud save path already had; the local path simply never got it."
    },
    {
      "title": "The playhead keeps time in songs that are not 4/4",
      "version": "2026.8.40",
      "date": "2026-08-31",
      "area": "Sequencer",
      "type": "Bug fix",
      "userFacing": true,
      "details": "The sequencer's playhead was still computing its speed as BPM/240 - which is the 4/4 assumption written out, 240 being 60 times 4 (bug board #75). SongTiming exists to replace exactly that constant and both audio bridges had been converted to it; this one was missed. So in a 3/4 song the playhead ran four thirds too fast and in 6/8 it ran at double speed, walking away from the bar lines it is meant to be pointing at. Newly reachable rather than theoretical, because the new-song dialog now asks for a time signature up front. At 4/4 the arithmetic is identical to before, so nothing changes for a 4/4 song."
    },
    {
      "title": "Status bar readouts no longer collide on a narrow window",
      "version": "2026.8.40",
      "date": "2026-08-31",
      "area": "UI",
      "type": "Bug fix",
      "userFacing": true,
      "details": "The build identity on the left and the frame stats on the right both sat in fixed 600-unit boxes, and the right one was parked at a fixed offset from the right edge - so on a window under about 840 units they overlapped each other, and the new transient-message slot landed on top of both. All three are now measured and placed against the actual text: the stats line is right-ALIGNED across the bar rather than parked at an offset, and the message takes whatever gap is left between the two, with breathing room either side. When that gap is too narrow to read a message in, the message is dropped rather than squeezed - these are echoes of something already on screen, so losing one costs nothing, while overlapping text costs the readouts as well."
    },
    {
      "title": "The sequencer uses the whole window",
      "version": "2026.8.40",
      "date": "2026-08-31",
      "area": "Sequencer",
      "type": "Improvement",
      "userFacing": true,
      "details": "The timeline sat 10 units in from each side and stopped 8 short of the bottom (bug board #35). A screen that draws all its own chrome does not also need a margin: the gutter, the ruler, the scrollbars and the status bar underneath all draw their own edges already, so the border was not breathing room - at default zoom it was about a quarter of a bar of timeline given away down each side, permanently. The bottom sliver went too. It was there so the horizontal scrollbar would not sit flush against the modal's bottom edge, but that edge is the status bar and it draws its own top border, so the scrollbar already had a line to sit against and the sliver was only ever a dark band under it."
    },
    {
      "title": "The four app heads stop reporting 61 annotations as mistakes",
      "version": "2026.8.40",
      "date": "2026-08-31",
      "area": "Build",
      "type": "Internal",
      "userFacing": false,
      "details": "Partial work on bug board #32. The four head projects - both desktop heads, the Linux head and the web head - were set to <Nullable>disable</Nullable> while the shared sources they compile are written with nullable annotations. Under `disable` every one of those annotations is reported as CS8632, \"the annotation should only be used in a #nullable context\": 61 warnings all saying the same thing, that the annotation you wrote does nothing. Switching those four to <Nullable>annotations</Nullable> makes them mean what they were written to mean without switching on the flow analysis, which is a much larger and more delicate piece of work. A head build goes from about 175 warnings to about 17. The rest is recorded on the ticket rather than claimed: a full rebuild still reports 423 across every repo it compiles, of which 136 are the pinned KNI fork and are not ours to change, 22 are GustUI, and 262 are this repo - 182 of those in Ezmuze.Services.Splice alone and almost all of them nullable-flow warnings that each need a real decision about whether the value can actually be null."
    },
    {
      "title": "The web boot screen shows the right logo, and real progress",
      "version": "2026.8.40",
      "date": "2026-08-31",
      "area": "Web",
      "type": "Bug fix",
      "userFacing": true,
      "details": "The page you wait on while the web build loads showed ezmuzeLogo_Small.png - the LEGACY diamond mark, with its alt text still reading \"Kni\" - and three dots drifting past in a <marquee>, which conveyed nothing beyond the page not having given up (bug board #41). The mark is now the studio one, drawn as an SVG generated from the same geometry LogoMark.cs uses in the app: its measured wave control points, wave gap and brand colours, so the first thing you see is the same mark as the last thing you saw. And the bar is real progress rather than an animation - Blazor WebAssembly already publishes its boot progress to the page as CSS custom properties, so a determinate bar and a percentage need no JavaScript at all. When no figure has been published yet, which is what a fully cached load looks like, the bar sweeps instead: a determinate bar sitting at zero reads as broken."
    },
    {
      "title": "The add-module menu is organised, and built-ins no longer pretend to be yours",
      "version": "2026.8.40",
      "date": "2026-08-31",
      "area": "Modules",
      "type": "Improvement",
      "userFacing": true,
      "details": "Adding a device to a stack offered three groups - Generators with 3 items, Effects with 9, and \"My modules\" with 29 - and that last group was almost everything, including Chip64, Korben, Trap kit and YDP Grand, none of which the user had built (bug board #26). It was one heading answering four different questions, so it answered none of them. The menu now has: Generators and Effects (the basic building blocks, unchanged), Instruments and Processors (the modules that ship with the app, split by what they are), \"In this song\" (the definitions this song already carries, which is the most useful group when building on a demo), and \"My modules\" for what the user has actually saved. Each is sorted by name - the old flat list of twenty-nine was in no order at all - and an empty group is left out entirely. Two things had to be fixed underneath. The stock effect rack never set its IsEffect flag, so that flag described only Korben and was useless for telling an instrument from a processor, which is exactly what the menu needed. And the shipped modules are now recognised by the id actually written to the library rather than by name, because for several of them the catalogue's name and the definition's own name disagree, and a name match quietly filed those as the user's."
    },
    {
      "title": "Menu icons are no longer bigger than the menu text",
      "version": "2026.8.39",
      "date": "2026-08-31",
      "area": "UI",
      "type": "Bug fix",
      "userFacing": true,
      "details": "Every icon in every menu was drawn a third larger than the label beside it - 24 against the label's 18 - so the pair read as an oversized picture with the words as an afterthought (bug board #40). A symbol font is type, and two things on one line want one size, so the glyph now takes the label's size: the same symbol family, at the menu font's size, rather than the symbol font wholesale with its own default of 24 that suits an icon standing alone. The icon COLUMN stays 24 wide, because that is alignment rather than type and narrowing it would pull every label left for nothing. Both glyph and label now take the same inset from the top of the row, so they sit on one line - while the glyph was taller it was also riding 2px high, which showed as a wobble down the column once the sizes matched. Submenu chevrons had the same mismatch and get the same fix."
    },
    {
      "title": "Far fewer toasts — routine messages moved to the status bar",
      "version": "2026.8.39",
      "date": "2026-08-31",
      "area": "UI",
      "type": "Improvement",
      "userFacing": true,
      "details": "Deleting a time selection raised two toasts back to back, \"Deleted the selection\" and \"Pattern waveforms ready\", for an edit whose whole result was already on screen (bug board #42). The cause was structural rather than a few chatty call sites: StatusBar.ShowTransient used to write a line into the status bar, was repointed at the toast stack, and its roughly 160 call sites were deliberately left alone - so every routine echo in the app became a toast in a single edit, about 160 of them against some 18 genuine error toasts. The line that decides is not how important a message is but whether the user can already SEE it. Routine echoes of what you just did - \"Deleted 3 clips\", \"Undid: delete selection\", \"4 points selected\" - now go back to the status bar, in a new slot in its centre where nothing else lives; they hold for four seconds and fade. Toasts are left for what happened where you were not looking, and sticky toasts for failures, which is what they were always good at. Progress readouts got their own rule: the pattern-waveform bar now appears only once that work has run for a second and a half, since a bar that appears and vanishes inside one second is pure flicker, and it leaves silently instead of announcing \"Pattern waveforms ready\" - nobody asked for that render and nothing was waiting on it. Audio decode still says \"Audio ready\", because playback genuinely is blocked until it is."
    },
    {
      "title": "Closing a song with unsaved work now asks first",
      "version": "2026.8.38",
      "date": "2026-08-31",
      "area": "UI",
      "type": "Bug fix",
      "userFacing": true,
      "details": "Closing the sequencer threw the song away silently, and there was no way back (bug board #43). Nor did starting a new song, opening another, or loading a demo. The app had no idea a song had been modified at all - there was no dirty state anywhere to hang a prompt on - so the first half of this is the tracking and the second is the dialog. The saved point is now recorded as the EDIT the undo history would revert next at the moment of the save, held by reference: that survives the history's capacity trim shifting every index, and it makes undo exact rather than approximate, so undoing back to the state you last wrote genuinely reads as unmodified again and closes without a word. Song settings are marked by hand, since those four writes are the app's only user-visible edit that is not an undoable command, and so is a coalesced edit - a knob drag absorbed into the edit already on the stack moves the song without moving the cursor. All four routes out of a song now ask, with three answers: Save (then continue), Discard, or Cancel, which is also what the dialog's own X means. It never appears for a song with nothing to lose. NOT yet covered: quitting the app from the OS window's close button, which has no interception point in the app at all - raised separately."
    },
    {
      "title": "Clip names no longer lose their last letter",
      "version": "2026.8.37",
      "date": "2026-08-31",
      "area": "Sequencer",
      "type": "Bug fix",
      "userFacing": true,
      "details": "Every clip label in the sequencer was drawn one glyph short, in every song: \"Liquid bass (roots)\" read as \"Liquid bass (roots\", \"Plink Tail\" as \"Plink Tai\" (bug board #34). Two causes stacked. The label box is fitted to the measured width of the name, and a 2px hairline gap - which exists so two adjacent same-named clips never touch and read as one run-on word - was then subtracted from that box AFTER the fitting. So a name that fit comfortably still had 2px taken out of its own width, and a thin trailing glyph like a closing bracket or an l fell outside the clip rectangle entirely. The gap is now reserved from the RUN's width before the box is fitted, so it still sits between neighbouring labels and never inside one. Underneath that, a second, smaller shortfall: the measurement sums glyph ADVANCES, and ink can reach slightly past its own advance, so a box fitted to the sum exactly still shaved the outer curve off a closing bracket. The fit now carries a hairline of slack for that overhang."
    },
    {
      "title": "Trimming or splitting a clip no longer scrambles its waveform",
      "version": "2026.8.36",
      "date": "2026-08-31",
      "area": "Sequencer",
      "type": "Bug fix",
      "userFacing": true,
      "details": "Deleting a section out of a clip left both surviving halves drawing a waveform that was not the audio in them - a dense unrecognisable smear where a moment earlier there had been legible transients (bug board #38). The block draws a pattern's cached waveform tiled across its width, and the tiling only ever counted WHOLE tiles: a clip whose length was not an exact multiple of its pattern's crammed the entire cached image into whatever width was left over. Trimming or splitting a clip is precisely how a clip stops being a whole number of tiles, which is why deleting a section was the way to see it - but it was never really about deleting, and any such clip was drawn wrong. A 14-bar clip of a 4-bar pattern, placed by hand, had the same fault. Nothing needed re-rendering to fix it: the renderer starts a pattern at the clip's own start bar and plays forward, so a clip shorter than its pattern plays the pattern's BEGINNING, and the right picture was always the leading fraction of peaks that were already computed and cached. GustUI's WaveformElement (and MiniPianoRollElement, which tiles identically, so the note preview and the waveform never disagree about where a clip ends) now takes a LastTileFraction: tile widths come from the fractional span rather than the rounded-up count, so full tiles keep their width, and the final one draws only its share of the source. Its baked-geometry cache went from one slot to two, because a block with full tiles plus one partial tile alternates between two keys and a single slot would have re-triangulated both on every frame - the exact cost that cache exists to avoid."
    },
    {
      "title": "The release pipeline's test gate had never actually run the tests",
      "version": "2026.8.36",
      "date": "2026-08-31",
      "area": "Build",
      "type": "Bug fix",
      "userFacing": false,
      "details": "Three faults stacked into one silent hole. test.sh runs dotnet test piped into tail, and the container's bash had no pipefail, so the exit status reported was tail's -- always zero. The build image is built FROM the SDK 10 image, which carries only the 10.0 runtime, while tests/Ezmuze.Tests targets net8.0, so dotnet test aborted before running a single test with 'Microsoft.NETCore.App 8.0.0 not found'. And the first fault hid the second, so every release this pipeline has ever cut reported passing tests without executing one. Fixed by running the container command under bash -o pipefail and installing the .NET 8 runtime (and ASP.NET Core 8) into the build image; rolling forward onto 10 was rejected because it would test a runtime the app does not ship on. With the gate working, 1661 tests pass and three failed: all three ContentFreshnessTests, and only on Linux. ContentManifest hashed raw file bytes, but .gitattributes gives the content sources text=auto, so git stores LF and checks out CRLF on Windows -- meaning a manifest generated on a developer machine could only ever match on Windows, and every Linux build saw EzmuzeStudioDesktopContent.mgcb and VisRhodiumLiquidCarbon.fx 'change' when nobody had touched them. Compute now normalises CRLF to LF before hashing for known text extensions (.fx, .mgcb, .glslfx, .frag, .vert), an allowlist rather than a guess because normalising a binary would corrupt the hash this exists to make trustworthy, and the manifests were regenerated. They now match the LF blobs git stores, which is what the build checks out, so the test finally means the same thing on both platforms. Separately, prepare.sh's stale-container sweep matched any container from another run, which killed the other workflow's compiler the first time a beta build and a promote ran at once; it now also requires the container to be over an hour old, since no build step comes close to that and each job already sweeps its own immediately."
    },
    {
      "title": "promote.yml can dry-run a release",
      "version": "2026.8.35",
      "date": "2026-08-31",
      "area": "Build",
      "type": "Improvement",
      "userFacing": false,
      "details": "gh workflow run promote.yml -f dry_run=true builds and stages everything a real release would and publishes none of it: it runs the version step, the AOT web build, every integrity check and publish.sh's own checks against the staged output, then stops before the register, the CDN copy and the live swap. It removes the run marker too, so publish.yml finds nothing to publish even though the build workflow succeeded, and it burns no version number because nothing is registered or written to the CDN for the next derivation to see. The release half is otherwise only testable by shipping, which is a bad way to discover it is broken. It leaves a ~600 MB stage behind, which prepare.sh sweeps after a day."
    },
    {
      "title": "The new-song preferences broke the web head",
      "version": "2026.8.34",
      "date": "2026-08-31",
      "area": "Build",
      "type": "Bug fix",
      "userFacing": false,
      "details": "The five NewSong*Key constants in UserPreferences and the three pendingNewSong* locals in PreferencesModal were declared inside #if DESKTOPGL, next to the render-ahead and control-API ones that genuinely are desktop-only, but every use of them is unconditional: the UserPreferences properties read through BrowserStore, the preference rows are built unconditionally, and NewSongModal has no guards at all. So the three desktop heads compiled and only the web head failed, with CS0103 on every use. Fixed by moving the declarations out of the guard, which is the direction the rest of the feature already implies; nothing else changed and both heads were rebuilt to confirm. Worth remembering that the test suite cannot catch this: it builds Ezmuze.Core and Ezmuze.Audio, while Ezmuze.Studio.Shared is a shared projitems compiled only by the heads themselves, so a guard mistake in shared UI code is invisible until a head is built."
    },
    {
      "title": "Versions are YEAR.RELEASE.BETA, and the CDN splits into beta/ and release/",
      "version": "2026.8.34",
      "date": "2026-08-31",
      "area": "Build",
      "type": "Internal",
      "userFacing": false,
      "details": "Versioning was CalVer YYYY.M.N, where the middle number was the calendar month. It is now YEAR.RELEASE.BETA: a beta build steps the last component, a promote steps the middle and resets the last, and a new year starts from Y.0.0 - so the first build of 2027 is 2027.0.1 and its first release is 2027.1.0. The year is still the year, the middle now counts releases in that year and can pass 12, and a release always ends in .0 while a beta never does, which tells the two apart at a glance with no suffix to read. Derivation no longer looks at the month at all. Everything already minted up to 2026.8.32 is left alone and stays correct, because Changelog.Compare parses three integers and compares them component-wise, so 2026.10.0 is newer than 2026.9.0 and 2027.0.1 is newer than 2026.12.9; no logic there had to change, but the tuple was renamed from (Year, Month, Patch) to (Year, Release, Build) because those names had started to lie. Alongside it the CDN was reorganised: cdn.ezmuze.co.uk/ezmuzeStudio now holds beta/ and release/ rather than a flat list of versions, with the twenty pre-split version folders moved into beta/ and changelog.json staying at the top level where the site and any updater can read it without knowing a version number. Betas are not pruned and are managed by hand, at roughly 300 MB a push; the release prune never touches them. Version derivation reads both directories, because moving the historical releases into beta/ would otherwise have thrown the entire high-water mark away and left the bug tracker as the only register - which is the single-register failure the CDN half exists to catch."
    },
    {
      "title": "New songs ask for a tempo and a time signature before they start",
      "version": "2026.8.34",
      "date": "2026-08-31",
      "area": "UI",
      "type": "New feature",
      "userFacing": true,
      "details": "Starting a new song used to give you 120 BPM in 4/4 called \"Untitled\", every time, with no say in it - the only way to even find that out was to start one and go looking in Song settings. New project now opens a small dialog first: name, tempo, time signature, and whether loops keep their pitch when the tempo changes. It offers exactly what Song settings offers, so nothing you pick here is something you cannot change later. Cancel puts you back where you were and creates nothing. Preferences has a new \"New songs\" tab holding what that dialog opens pre-filled with, so if you always write at 174 in 7/8 you set it once. There is also a switch there to turn the dialog off entirely, which gives you back exactly what happened before - one click, straight into the sequencer, using your defaults."
    },
    {
      "title": "NewSongFactory: the blank song has one testable answer",
      "version": "2026.8.34",
      "date": "2026-08-31",
      "area": "Core",
      "type": "Internal",
      "userFacing": false,
      "details": "What a new song starts as was four literals inline in EzmuzeStudioGame.MenuNewProjectSong - in the UI assembly, where no test can reach it, so \"does a new song actually come out at the tempo you asked for?\" was a question only a human clicking could answer. Moved to Ezmuze.Core as NewSongSettings plus NewSongFactory.Build, with the mastering chain passed in as a callback because that chain is built from Ezmuze.Audio's Korben module and Core does not depend on Audio. Settings are sanitised on the way in as well as out of storage, since preferences are text files a user can edit and a hand-typed 9/7 at 4000 BPM should land at the factory rather than somewhere further down: the tempo clamps to the legacy import contract's 15-200 and an unsupported signature falls back to 4/4. SongTiming gained IsSupported so \"which signatures are real\" has one answer. Master volume (0.75) and length (8 bars) stay constants there, deliberately unexposed. Twelve tests cover it."
    },
    {
      "title": "A push goes to beta; releasing is a deliberate promote",
      "version": "2026.8.32",
      "date": "2026-08-31",
      "area": "Build",
      "type": "Internal",
      "userFacing": false,
      "details": "The web AOT is about 14 of the 15 minutes a release takes and every push was paying it. build.yml now builds the web head with RunAOTCompilation=false and deploys to beta.ezmuze.studio, which drops a push to roughly 8 minutes, bounded by win-x64-dx rather than the AOT. The new promote.yml is the same pipeline with EZMUZE_CHANNEL=release, run by hand from the Actions tab or with gh workflow run promote.yml, and it is the only workflow publish.yml follows. So a push is no longer a release: only promotion derives a version, resolves NEXT in the change log, writes a versioned CDN directory, swaps ezmuze.studio and registers with the bug tracker. That also ends version numbers advancing for commits nobody shipped. The cost of skipping AOT is real and was already measured: the interpreter runs this app at 8-15 fps against the 60 that AOT locks, on an app whose whole job is real-time audio and a 60 fps sequencer. So deploy-beta.sh stamps a visible banner into index.html saying exactly that. The banner goes in at deploy time rather than build time because it is a property of the deployment, and because doing it there means a production build can never carry it; the stamp is idempotent so re-deploys do not stack. Beta answers whether something works, never whether it is fast or whether the audio holds - the desktop heads are native and built identically on both channels, so they are unaffected. One environment variable, EZMUZE_CHANNEL, decides all three differences (no AOT, no change-log stamp, a -beta+sha InformationalVersion), which is what lets both workflows share every script and the same four-head matrix. Flipping the AOT flag between runs is safe only because actions/checkout does git clean -ffdx, so no obj directory survives from a build of the other channel - the stale-obj hazard the csproj warns about, now guaranteed by the checkout rather than by care."
    },
    {
      "title": "Release builds run without a memory or swap limit",
      "version": "2026.8.32",
      "date": "2026-08-31",
      "area": "Build",
      "type": "Internal",
      "userFacing": false,
      "details": "The build containers were capped at 4g, and 8g for the web AOT, with --memory-swap set equal to --memory. Both are gone. The cap was sized for a 4 vCPU / 7 GB box whose 2 GB of swap was already fully consumed by long-idle services, where one OOM kill had already landed and the kernel picks its victim by score, so the casualty could as easily have been ezmuze.studio, the api or the proxy as the compiler. The box is 8 vCPU / 23 GB now with about 16 GB available and a swapfile holding 10 MiB of 2 GB, and four parallel heads peak well inside that. EZMUZE_BUILD_MEM still re-caps without a code change since this box has changed capacity twice already; unset means unlimited. Recorded while removing it because it would otherwise mislead whoever reinstates a cap: --memory-swap never applied on this host at all. Ubuntu does not set swapaccount=1 on the kernel cmdline, so Docker accepted the flag, dropped it, and emitted 'WARNING: No swap limit support' where nobody looks; docker inspect showed MemorySwap -1, meaning unlimited, and memory.memsw.limit_in_bytes did not exist, while --memory itself applied exactly. The comment asserting the containers could not swap was false for as long as it existed."
    },
    {
      "title": "The welcome screen no longer spills out of its own window",
      "version": "2026.8.32",
      "date": "2026-08-31",
      "area": "UI",
      "type": "Bug fix",
      "userFacing": true,
      "details": "On the first launch after an update, the demo cards could run straight out of the bottom of the welcome dialog and onto the background behind it, with the Open/New/Close buttons drawn over the top of them, and no scrollbar. It looked intermittent because it depended on timing: the dialog is built as the app starts, and the \"What's new\" list arrives a moment later when the change log finishes loading. On a warm start the log was already there and everything fitted; when it landed late, the dialog had already decided how tall it was and never reconsidered. It now grows to fit whatever arrives, and falls back to a scrollbar when the window is genuinely too short for all of it."
    },
    {
      "title": "VerticalStackElement re-measures children that never write their SizeTrait",
      "version": "2026.8.32",
      "date": "2026-08-31",
      "area": "UI",
      "type": "Bug fix",
      "userFacing": false,
      "details": "Root cause of the welcome screen spilling out of its modal. The stack re-laid out on two signals: a child added or removed (the Update poll added 2026-08-18 for the Kill path), and a child writing its SizeTrait. A child using SizeFitsChildren does neither - ElementExtensions.GetSize measures it live and leaves CachedSizeTrait untouched for the life of the element - so it can grow silently. AboutModal wraps the whole welcome body in exactly such a container, so when the change log landed after the modal was built, the stack kept reporting its old height; ModalWindowElement sized the modal from that stale number AND read the same stale number when deciding whether it had overflowed, which is why there was no clipping and no scrollbar rather than one or the other. The Update poll now also remembers the child sizes it laid out against and recalculates when a live GetSize disagrees - per child, since two children changing by equal and opposite amounts leave the total unchanged while everything below the first is misplaced. Position and size writes are now skipped when the value has not moved, so a pass that can run every frame does not fire listeners every frame. Measured on the sequencer with a demo loaded afterwards: 142 fps, tree update 0.30ms, and the timeline rebound 21 times across 4100 update calls, so nothing churns."
    },
    {
      "title": "Selecting a range of bars moved to the right mouse button",
      "version": "2026.8.31",
      "date": "2026-08-31",
      "area": "Sequencer",
      "type": "Improvement",
      "userFacing": true,
      "details": "2026.8.30 put the sequencer's new time selection on a LEFT drag over empty timeline, which meant panning had to move out of the way to make room. It is a right drag now, and everything the left button used to do it does again: move a clip, drag its edges, pan empty timeline, click the ruler to seek and drag it to scrub. Right-drag the ruler to select across every channel. The change also fixes a hole the first version had - a left drag could not start a selection on top of a clip, because that gesture moves the clip, so a channel packed edge to edge was one you could not start a selection on at all. A right drag starts anywhere, clips included. A right CLICK that does not move is left alone, ready for the right-click menu that comes with the ripple tools. Middle-drag still pans as well, which is the one pan that works while the pointer is over a clip."
    },
    {
      "title": "The bar selection is a soft wash that breathes, with no outline",
      "version": "2026.8.31",
      "date": "2026-08-31",
      "area": "UI",
      "type": "Improvement",
      "userFacing": true,
      "details": "The selection was drawn as a translucent blue box with a brighter blue border. The border was wrong at that scale: it turned every covered channel into an outlined object of its own, so a range across six channels read as six boxes rather than one range, and over a clip it competed with the clip's own edges. It is one flat tint now. Losing the outline cost it some definition over a busy waveform, so instead of simply making it more opaque - which would have hidden the clips it covers - it breathes: about five points of opacity, in and out over roughly two and a half seconds. Movement is what makes it findable; the opacity stays low enough to read the waveforms straight through. The breathe is deliberately not locked to the tempo, because it marks a selection rather than a musical event."
    },
    {
      "title": "GustUI dispatches the right mouse button as a full drag too",
      "version": "2026.8.31",
      "date": "2026-08-31",
      "area": "UI",
      "type": "Internal",
      "userFacing": false,
      "details": "2026.8.30 gave GustUI the middle button as a press/held/release trio with its own capture slot. The right button still had only the press edge it has always had (OnRightClickTrait), which is not enough to draw a marquee with, so it now has OnRightMousePress/HeldDown/Release and a CapturedRightElement slot of its own alongside the middle button's. Three separate slots rather than one shared one, for the reason the second one existed: the buttons drive unrelated gestures on the SAME element in the sequencer - right marquee, middle pan, left clip move - and sharing would let starting any one silently cancel another. The OnRightClickTrait dispatch is deliberately left exactly where it was, after the left button's capture early-return, so no existing consumer of it changes behaviour; an element declaring both simply hears about the press twice, which is what a context menu that also wants to know about dragging needs. The remote-control API's /mousedown takes {\"right\":true} to match its {\"middle\":true}."
    },
    {
      "title": "The DX head builds its own service plugins instead of borrowing the GL head's",
      "version": "2026.8.30",
      "date": "2026-08-31",
      "area": "Build",
      "type": "Bug fix",
      "userFacing": false,
      "details": "Ezmuze.Studio.Desktop.DX referenced only Ezmuze.Services, the shared contract, and none of the five plugin projects, while the GL and Linux heads reference all of them with ReferenceOutputAssembly=false so they build without being linked against. ServicePlugins.targets copies each plugin out of its OWN bin/<config>/<tfm> directory under Condition=Exists(...), so in the DX head's tree nothing ever built them, every copy silently no-op'd, and there was no warning either: the item list was non-empty, it was the files that were missing. It nonetheless worked for as long as the DX head has existed, because the release built the GL head first IN THE SAME WORKING TREE and left the DLLs lying there for the DX copy to find. A shared checkout was doing the work a project reference should have, so no shipped DX build was ever actually missing its services. Giving each head its own workspace, which is what building them in parallel means, removed the accident and the release failed on 'win-x64-dx has no service plugins' -- the integrity check in build-head.sh earning its place on the first run that could expose this. Fixed by giving DX the same five references, and by making the targets file fail rather than shrug: it now filters to the plugins that exist, copies those, and raises an Error if that came to nothing. The per-file Exists() filter has to stay because the Linux head legitimately omits Splice (net8.0-windows), but all-absent is never legitimate for any of the three heads that import the file, and the old Warning for it could not fire and would have been invisible in a 2000-line publish log if it had."
    },
    {
      "title": "Drag across the sequencer to select a range of bars",
      "version": "2026.8.30",
      "date": "2026-08-31",
      "area": "Sequencer",
      "type": "New feature",
      "userFacing": true,
      "details": "Dragging over empty timeline now draws a selection instead of panning the view. It snaps to bar lines, and holding Shift drags it off the grid - the same thing Shift already does when you drag a clip. Drag in any direction: right-to-left and bottom-to-top end up the same selection as the opposite drag. It spans as many channels as you drag over, and skips any automation lanes you have expanded, since those have their own point selection. Dragging the bar ruler at the top selects across every channel at once; a plain click there still moves the playhead exactly as before. Pressing Delete removes what the selection covers, as ONE undo step no matter how many clips it touched - and clips are not all-or-nothing, so a clip hanging over the edge of the selection is trimmed back to it rather than wiped, and a selection sitting inside a single clip splits that clip in two. Escape drops the selection. Panning by dragging has moved to the middle mouse button; the wheel and the scrollbars are unchanged. This is the first half of the feature: copy, cut, paste, the ripple tools, the right-click menu and holding a modifier to build up several selections at once are still to come."
    },
    {
      "title": "GustUI dispatches the middle mouse button, with its own capture slot",
      "version": "2026.8.30",
      "date": "2026-08-31",
      "area": "UI",
      "type": "Internal",
      "userFacing": false,
      "details": "GustUI knew about the left button (press/held/release with pointer capture) and the right button (a press edge only). The middle button was not dispatched at all, so the sequencer's timeline pan had nowhere to move to once a left drag became the marquee. InputManager now has OnMiddleMousePress/OnMiddleMouseHeldDown/OnMiddleMouseRelease and a CapturedMiddleElement slot deliberately separate from the left button's: the two drive unrelated gestures, and sharing one slot would let starting either silently cancel the other. It is processed before the left button's capture early-return, so a middle drag keeps getting frames while something else holds the left capture. PushPointerEdge and the remote-control API's /mousedown both learned the middle button too ({\"middle\":true}), so a pan is still scriptable from outside the app."
    },
    {
      "title": "CompositeEdit, TimeSelection and a deep clip copy",
      "version": "2026.8.30",
      "date": "2026-08-31",
      "area": "Core",
      "type": "Internal",
      "userFacing": false,
      "details": "Three additions to Ezmuze.Core behind the sequencer's time selection. CompositeEdit runs several edits as one undo step, reverting them backwards - deleting a selection can remove a dozen clips and trim two more, and pushing those separately would mean a dozen presses of Ctrl+Z through a half-deleted song. TimeSelection is the selection itself, normalised on construction and stored in TICKS rather than bars so Shift-dragging off the grid is not thrown away at the door; TimeSelectionMath decides, for each clip a selection meets, whether it is deleted whole, trimmed at either end, or split. LoopClipCopy is the deep copy a split needs: QuarterTriggerFx and Automation are indexed per quarter of A BAR - a repeating pattern, not a timeline - so both halves keep exactly what the whole clip was playing, but they must not keep the same objects. Unit-tested, including that a composite apply-then-revert leaves the song byte-identical."
    },
    {
      "title": "The four heads build in parallel",
      "version": "2026.8.30",
      "date": "2026-08-31",
      "area": "Build",
      "type": "Internal",
      "userFacing": false,
      "details": "build.yml is now prepare -> test -> a four-way matrix (web, win-x64-dx, win-x64-gl, linux-x64-gl) -> finalize, with fail-fast so one broken head cancels the rest instead of spending twelve more minutes on an AOT for a release that cannot ship. Four self-hosted runners back it, because a runner executes one job at a time and with a single runner a matrix is strictly slower than a loop. The first Actions release measured the serial cost honestly and it was not where the old docs said: tests 31s, win-x64-gl 4m38s, win-x64-dx 5m21s, linux-x64-gl 36s, web AOT 12m13s, total 23m19s. The two Windows heads are ten minutes between them, not the three the docs claimed, which is what makes parallelising worth roughly ten minutes rather than five; the floor is tests plus the AOT, about 13 minutes. release.sh's successor build.sh is split into prepare.sh, test.sh, build-head.sh and finalize.sh with the common parts in lib.sh. Two bugs that only exist under concurrency were designed out rather than discovered: the build image is now built once in prepare, since four jobs on a cold box would otherwise race to build the same 2 GB image; and container labels carry the run and job id, because the old sweep matched every container labelled ezmuze-build and would have killed a sibling's compiler mid-build. The container memory caps were re-derived for the upgraded box (8 vCPU / 23 GB, up from 4 / 7): the three desktop heads stay at their proven 4g and only the web AOT rises, to 8g, because with four containers at once the sum is what matters and 3x4g + 8g is about all the free memory there is."
    },
    {
      "title": "The release pipeline runs on GitHub Actions",
      "version": "2026.8.29",
      "date": "2026-08-31",
      "area": "Build",
      "type": "Internal",
      "userFacing": false,
      "details": "A push to main now runs .github/workflows/build.yml on a self-hosted runner on the VPS; on success that triggers publish.yml, which registers the version and ships it. This replaces a systemd timer polling git ls-remote every minute and one 508-line release.sh, now split into tools/release/build.sh (compile and stage, visible to nobody) and publish.sh (register, CDN, web swap, change-log stamp-back). The move was not about the trigger — a one-minute poll was fine — but about cancellation, and it deleted four mechanisms that existed only to hand-roll it: the timer and poller, the whole-release flock with poll.sh's careful reasoning about not recording a sha while a release holds the lock, the three hand-placed superseded()/give_way() checkpoints, and the last-sha state file that stopped the change-log stamp commit triggering another release (a push authenticated with GITHUB_TOKEN does not trigger workflows, so that one needs nothing at all). Publishing is a separate workflow rather than a second job, and that is the design: build.yml is cancelled the instant a newer commit lands, publish.yml can never be cancelled, so 'a version registered with no artefacts behind it' goes from a rule living in a comment to something the structure forbids. Two things the old lock covered now need naming. A cancelled build is killed mid-docker-run, so containers are labelled and swept rather than left holding the memory cap. And version derivation can now race a publish that is already in flight and would read a tracker and a CDN that neither know about it yet — so the lock file survives, for exactly that: build.sh takes it around derivation, publish.sh holds it for its whole run. The build also now compiles the commit that triggered it, where release.sh reset to whatever origin/main had become by the time it checked out. A CI workflow was removed in 2026.8.11 because the release ran the tests anyway; this is the release itself moving, not that coming back. The lock is a stopgap: docs/release-pipeline.md sketches the reservation that should replace it, where the tracker hands out a number atomically and takes it back if the build is cancelled, which would retire both this lock and the CDN register. See docs/github-actions-runner.md for installing and operating the runner."
    },
    {
      "title": "Changelog versions are stamped by the build server, not guessed",
      "version": "2026.8.29",
      "date": "2026-08-31",
      "area": "Build",
      "type": "Internal",
      "userFacing": false,
      "details": "Entries are now authored as \"version\": \"NEXT\" and resolved by release.sh to the real CalVer before it compiles anything, so every artefact ships the true number; the one-line change is then pushed back to main as the release's last act. Guessing was the old rule and it lost entries outright: the number could only be guessed at authoring time, a release takes ~25 minutes and one is often already in flight, and Changelog.Since only shows entries strictly newer than the version last run — so a guess at or below what shipped was invisible for ever, while guessing high merely showed a note a release late. An unresolved NEXT is safe in the meantime because Since filters on TryParseVersion, which is now pinned by a test rather than left as an incidental property. Three details carry the risk: the substitution is anchored on the key so the word NEXT in prose is untouched and the result is re-parsed as JSON before being written; the push-back resets to the current head and re-applies, because main may have moved and the build checkout is shallow; and the release records its own commit in the poller's state file so the stamp does not itself trigger another release, but only when main had not moved, since otherwise someone else's commit is in the same range and burying it unbuilt would be worse than an extra build. A failed push-back never fails the release. This entry is itself the first NEXT."
    },
    {
      "title": "A release gives way to a newer commit instead of finishing work nobody wants",
      "version": "2026.8.29",
      "date": "2026-08-31",
      "area": "Build",
      "type": "Improvement",
      "userFacing": false,
      "details": "The poller defers rather than interrupts, so two pushes a minute apart cost about fifty minutes, of which the first twenty-five built a commit already superseded. release.sh now checks whether main has moved past the commit it is building, at three points: after the tests, before the web AOT (about fifteen of the twenty-five minutes sit after that line, so it is where giving way actually saves time) and immediately before registering the version. Cooperative abandonment at named points rather than a kill, for two reasons. Registration is what /api/versions reports and what What's new and bug filing key off, so a run killed after registering but before publishing would leave a version with no artefacts behind it — hence no checkpoint after that line. And every build step is a docker run --rm, so killing the script would leave the container running to completion anyway, still holding a memory cap this box cannot spare. Abandoning needs no bookkeeping: the poller records the sha before the attempt, so main having moved is the condition its next tick already tests. An unreachable remote answers 'not superseded' on purpose, so a network blip cannot throw away a nearly finished build."
    },
    {
      "title": "Changing the tempo no longer changes the key",
      "version": "2026.8.28",
      "date": "2026-08-30",
      "area": "Sound engine",
      "type": "New feature",
      "userFacing": true,
      "details": "Loops are fitted to the bar, so moving a song's tempo has to do something to them — and until now that something was a plain resample, which speeds a loop up the way playing a record faster does: it raises the pitch. The engine could always avoid that, by time-stretching instead, but the switch for it had never been exposed anywhere and was off by default. It is now a row in File > Song settings, directly under the tempo it governs, and it is ON for new songs: dragging a BPM should change how fast a song goes, not what key it is in. Existing songs keep whatever they were saved with, so nothing you have already made will sound different. Turn it off if you want the old record-speed behaviour, which is still the right answer for some material."
    },
    {
      "title": "Song.Normalize removed; it had never been read",
      "version": "2026.8.28",
      "date": "2026-08-30",
      "area": "Core",
      "type": "Internal",
      "userFacing": false,
      "details": "Normalize was declared on Song, imported faithfully from legacy .eez files and round-tripped by the serialiser, and consulted by absolutely nothing — not the renderer, not the master chain, not any view. In legacy ezmuze it normalised at render time; here the master chain's limiter does that job, so the field had been inert for the whole life of this codebase while still reading, in Song.cs, like a live setting. Removed rather than documented, on the principle that an option nothing reads is not a feature waiting to be wired up but a false statement about what the app does. No format version bump: the serialiser skips unknown members, so every song ever saved still loads and simply drops the value, and a test pins that rather than leaving it as an incidental property of the current options. Ezmuze.Legacy's LegacySongData keeps its own copy, since that type is a faithful record of the legacy FILE format, which does contain the field. Also flips Song.TimeStretch to default true, which is the user-facing half above; NaiveSongRendererTests.ShorterLoopPcm_IsStretchedToBarGrid now pins TimeStretch:false explicitly, because it is about the naive nearest-sample fit and was previously relying on the default to get it."
    },
    {
      "title": "Songs can be in 3/4, 6/8 and nine other time signatures",
      "version": "2026.8.27",
      "date": "2026-08-30",
      "area": "Sequencer",
      "type": "New feature",
      "userFacing": true,
      "details": "Every song used to be in 4/4, and not because that was chosen — the number four was simply written into the code in four separate places, in the audio engine, in the note timing and in both grid views, with nowhere to change it. A song now carries its own time signature, and File > Song settings is where you set it, along with the tempo. That dialog is new too: until now there was no way to change a song's BPM at all, so a new song was stuck at 120 and an imported one kept whatever it arrived with. Twelve signatures are offered, from 2/4 through 7/4 and 3/8 through 12/8. The ruler follows: its gridlines now land on beats rather than on eighths of a bar (which only lined up with the beat in 4/4), and zooming in labels positions as bar:beat instead of the decimal 1.125 it used to show. Changing the tempo or the signature rebuilds the song, since bar length changes and every loop has to be re-fitted to it. Existing songs are unaffected — they load as 4/4 and play exactly as they did."
    },
    {
      "title": "Song format v22: the time signature is stored rather than assumed",
      "version": "2026.8.27",
      "date": "2026-08-30",
      "area": "Core",
      "type": "Internal",
      "userFacing": false,
      "details": "Song gains BeatsPerBar and BeatUnit, and the four scattered literals that encoded 4/4 now read them through the new Ezmuze.Core.SongTiming: the renderer's bar-length maths, PatternSignals' note/beat domain, PatternBeats.PerBar behind the piano-roll and drum-lane grids, and the Bpm / 240.0 in both audio bridges (the 240 being 60 x 4, with the 4 being exactly the assumption in question). BPM stays quarter-notes-per-minute whatever the signature, matching the DAW convention and every song already on disk, so a 4/4 song's timing is bit-identical to the arithmetic it replaced — a test pins that against the old literals rather than deriving them, so a future refactor has to disagree with it rather than follow it. LoopClip.QuartersPerBar is deliberately left alone: that is how a legacy audio loop divides into quarters of itself, a property of the loop file, not a claim about bars. The format bump is not merely additive in effect — a v22 file read by a v21 build would play a 3/4 song at 4/4, putting every clip after the first in the wrong place, which is what the version stamp exists to refuse."
    },
    {
      "title": "Only one submenu opens at a time",
      "version": "2026.8.26",
      "date": "2026-08-30",
      "area": "UI",
      "type": "Bug fix",
      "userFacing": true,
      "details": "Menus could end up with two submenus open side by side, overlapping each other into something unreadable. It took a particular path to trigger, which is why it seemed to come and go: hover an item until its submenu appears, move sideways INTO that submenu, then come back and hover a different item. The first submenu stayed behind and the second opened alongside it. The reason is that an item only ever finds out the pointer has left it once, and it deliberately spends that one notification declining to close while you are moving into the submenu — after which nothing ever asked again. Submenus now belong to the menu level they open from rather than to the individual row, so opening one closes whichever was already open beside it, and closing a menu takes the whole tail of nested submenus with it however deep it goes."
    },
    {
      "title": "bug-board: guess the changelog version high, and re-read it before pushing",
      "version": "2026.8.25",
      "date": "2026-08-30",
      "area": "Build",
      "type": "Internal",
      "userFacing": false,
      "details": "The skill said to guess the version as the highest in /api/versions plus one. That is not enough, and it silently lost two entries in one session: an entry at or below what shipped counts as already released and never appears in What's new. Two ordinary things beat the guess. A release takes about 25 minutes, so one is often already in flight while the work is being done and lands before the push. And every push gets its own release, so splitting a fix and a follow-up commit across two pushes burns two version numbers rather than one — which is exactly what happened here. Now: guess plus TWO, re-read /api/versions immediately before pushing rather than trusting the number read at the start, and prefer one push per ticket. Also records why this is the safe direction to be wrong in: an over-guess just shows the note one release late, an under-guess hides it for good."
    },
    {
      "title": "The sequencer fills its window, with one scrollbar instead of two",
      "version": "2026.8.24",
      "date": "2026-08-30",
      "area": "Sequencer",
      "type": "Bug fix",
      "userFacing": true,
      "details": "Two things were wrong along the bottom and right edges of the sequencer. A black band sat under the timeline, clipping the last channel row mid-row and wasting space that could have been more sequencer — the layout was subtracting the status bar's height a second time, from a window that had already set it aside. And there were genuinely two vertical scrollbars stacked almost exactly on top of each other in the corner, of which the outer one moved, resized and did nothing at all, because the window had wrapped the sequencer in a scroll view it never needed. Both are gone: the sequencer now runs down to the status bar, and the only scrollbar on screen is the one that scrolls it."
    },
    {
      "title": "ModalWindowElement scroll promotion measures the authored body, not the stretched one",
      "version": "2026.8.24",
      "date": "2026-08-30",
      "area": "UI",
      "type": "Internal",
      "userFacing": false,
      "details": "Two independent causes behind bug #20. (1) SequencerView.ComputeRowHeight subtracted StatusBarHeight from a size that already excluded it: every caller passes modal.GetSize(), BuildUi sets modal.BottomInset = StatusBar.Height, and TimelineTop is modal.ContentTop. Correct when this measured window.GetSize(); the 2026-08-16 free-resize change moved every caller to the modal and left the subtraction behind. Cost 24px of dead band, and the const is now deleted. (2) GustUI's RefreshScrollMode promotes a body into a VerticalScrollElement once it outgrows MaxModalHeight, but PositionContent stretches a non-fit-to-content body to the full client area every frame, so the measurement read back this class's own output from the previous frame — necessarily modal-sized, hence always over the cap. The sequencer's transparent 10x10 placeholder was wrapped in a viewport whose rail overlapped its real scrollbar by 10 of 12 pixels while scrolling nothing. Fixed by measuring the height the CALLER authored, captured once at construction (and per tab on adopt, restored on activate), for non-fit-to-content modals only; fit-to-content keeps measuring live, since its content genuinely arrives late. Deliberately not \"don't promote fill-available windows\" — a device panel is authored at max(420, 70% of window) and really does need to scroll on a short window; suppressing it there squeezed its content, which is how the first attempt at this was caught."
    },
    {
      "title": "The sequencer keeps following the song while a device panel is open",
      "version": "2026.8.22",
      "date": "2026-08-30",
      "area": "Sequencer",
      "type": "Bug fix",
      "userFacing": true,
      "details": "Opening a device's panel from the Stack froze the sequencer while the song carried on playing: the playhead stopped moving, the view stopped scrolling to follow it, and the channel meters sat still — so the one moment you most want to watch the arrangement, tweaking a synth as it plays, was the one moment you could not. Closing the panel made it jump back to where the song had got to. Device panels are windows you work alongside the arrangement, not sheets that cover it, and the sequencer now keeps running underneath them. Editors that genuinely do fill the screen, like the piano roll, still pause it, because there is nothing to see behind those."
    },
    {
      "title": "FullScreenModalElement.AnyOpen, and the sequencer update gate that asks it",
      "version": "2026.8.22",
      "date": "2026-08-30",
      "area": "UI",
      "type": "Internal",
      "userFacing": false,
      "details": "EzmuzeStudioGame.Update dispatched per-frame view logic through an else-if chain whose final else was the only caller of sequencer.Update, so ANY open view froze the sequencer's view logic. That was right while every branch was a FullScreenModalElement — an opaque sheet with nothing visible beneath — but ModuleUiView and ModuleUiEditorView became ModalWindowElements (real, resizable, dockable windows) and the chain never learned about the conversion. The sequencer is now gated on a new GustUI static, FullScreenModalElement.AnyOpen, rather than on a hand-listed set of views: the sheet-using views are a shrinking group (KorbenView is already on its way out) and a list would silently rot at each conversion, whereas AnyOpen goes permanently false when the last sheet is converted and the gate can then be deleted outright. Note this was never about GustUI's own element traversal, which runs regardless — Element.Update recurses from the root every frame — but about the host's view OBJECTS, which are plain classes GustUI does not know about. The old comment's other justification, that pending rebuilds run when the modal closes, no longer applied either: the needsChannelRebuild/ReopenSequencer teardown it named is gone, replaced by in-place refresh."
    },
    {
      "title": "A skill for working the bug board",
      "version": "2026.8.20",
      "date": "2026-08-30",
      "area": "Build",
      "type": "Internal",
      "userFacing": false,
      "details": "`.claude/skills/bug-board` runs a ticket from intake to beta testing: reproduce the unconfirmed bugs before moving any of them, then — once no bugs are left — put each unconfirmed feature request to the user with an effort estimate, approved to Backlog and rejected to Rejected, since a feature request cannot be reproduced and never belongs in Confirmed. Then propose the three most important from Confirmed and Backlog and ask which to take, state the plan and wait for a yes before writing anything, fix, commit, watch the release build and hand over to beta testing. Reads the tracker over its REST API; the manager token stays outside this repo."
    },
    {
      "title": "Closing the now-playing panel no longer jams the transport",
      "version": "2026.8.20",
      "date": "2026-08-30",
      "area": "Sequencer",
      "type": "Bug fix",
      "userFacing": true,
      "details": "Stopping a song by closing the now-playing panel left the toolbar showing Pause and the spacebar doing nothing, so you had to press play twice to start again. The sequencer now checks the engine every frame instead of relying on each action to report itself, which also covers anything else that stops playback from outside the toolbar."
    },
    {
      "title": "Text no longer shows through panels and dialogs",
      "version": "2026.8.20",
      "date": "2026-08-30",
      "area": "UI",
      "type": "Bug fix",
      "userFacing": true,
      "details": "Labels from behind a dialog could paint straight through it — knob captions and clip names appearing on top of a solid panel. The renderer drew every shape first and then every piece of text, so a panel could not cover another element's label however far in front it was. It now draws strictly in order, so what is in front covers what is behind."
    },
    {
      "title": "Notifications no longer stop the song",
      "version": "2026.8.20",
      "date": "2026-08-30",
      "area": "UI",
      "type": "Bug fix",
      "userFacing": true,
      "details": "The now-playing panel was dropped once four notifications had arrived during playback, and losing it was read as you closing it — which stopped the song. It is now pinned: it holds its corner, notifications stack above it, and only closing it yourself stops playback."
    },
    {
      "title": "The now-playing panel replaces the playing bar",
      "version": "2026.8.20",
      "date": "2026-08-30",
      "area": "UI",
      "type": "Improvement",
      "userFacing": true,
      "details": "The white strip along the bottom is gone. What is playing now appears as a panel in the corner with the song title, the artist, a live visualiser and a progress bar showing how far through the whole song you are — so starting halfway through starts the bar at half. It sits at half opacity so the timeline reads through it, a full-screen button opens the visualiser, and its close button stops playback."
    },
    {
      "title": "Drawing a frame costs far less setup",
      "version": "2026.8.20",
      "date": "2026-08-30",
      "area": "UI",
      "type": "Performance",
      "userFacing": false,
      "details": "Five things forced the renderer to stop and submit mid-frame: raw triangle drawing (waveforms and MPE bend curves, once per element), every clipped container, additive glow regions, and the now-playing visualiser's offscreen pass. All now batch or run before the frame. On a maximised song a frame went from 17 forced submissions to 2, and drawing from 1.81 ms to about 1.55 ms."
    },
    {
      "title": "Removed the SpriteBatch-era rendering scaffolding",
      "version": "2026.8.20",
      "date": "2026-08-30",
      "area": "Core",
      "type": "Internal",
      "userFacing": false,
      "details": "GustUI has drawn everything through its geometry batch for a while, but the machinery built around SpriteBatch survived: NativeElement/VideoElement/DrawOOPManager, TVBlurFill and the three render-target-clone methods serving it, per-batch GraphicsDevice state that the batch sets again per segment, and the GPU scissor rect, which nothing read because clipping is per-vertex. Element.IsOverlay went too — thirteen call sites that existed only to defeat the reordering, all of which already carried the correct Depth. The frame profiler now reports segments (draw calls) instead of sprite and string counters that had been reporting zero."
    },
    {
      "title": "See what's new each time you update",
      "version": "2026.8.14",
      "date": "2026-08-30",
      "area": "UI",
      "type": "New feature",
      "userFacing": true,
      "details": "After an update, the welcome screen now lists the changes since the version you last ran — each tagged by area and kind, with the full details on hover. Nothing is shown on a fresh install or a dev build; the list appears the first time you launch a newer version."
    },
    {
      "title": "Change log infrastructure",
      "version": "2026.8.14",
      "date": "2026-08-30",
      "area": "Core",
      "type": "Internal",
      "userFacing": false,
      "details": "Added wwwroot/changelog.json plus Ezmuze.Core/Changelog.cs (parse + CalVer comparison + since-last-run filtering, unit-tested), a ChangelogService that loads it through the same HttpClient as the rest of wwwroot, and a last-run version stored in user preferences. LocalContent.targets ships the file with desktop builds and release.sh copies it to the CDN."
    },
    {
      "title": "ezmuze studio now runs on Linux",
      "version": "2026.8.13",
      "date": "2026-08-30",
      "area": "Desktop",
      "type": "New feature",
      "userFacing": true,
      "details": "A native Linux desktop build (DesktopGL) alongside the Windows ones. File dialogs use kdialog/zenity and the clipboard uses SDL2. The drag-hover-onto-timeline affordance is not available on Linux yet; dropping a file into the wave bank still works."
    },
    {
      "title": "Release pipeline builds and publishes the Linux head",
      "version": "2026.8.13",
      "date": "2026-08-30",
      "area": "Build",
      "type": "Internal",
      "userFacing": false,
      "details": "release.sh publishes Ezmuze.Studio.Desktop.Linux self-contained for linux-x64 and ships it as a .tar.gz under cdn.ezmuze.co.uk/ezmuzeStudio/<version>/linux-x64-gl/. DesktopGL native libs are copied into x64/ by hand (publish does not carry the package's AfterBuild copy). GitHub Actions was removed — the release runs the tests before every build already."
    }
  ]
}
