{
  "$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 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.",
      "tickets": []
    },
    {
      "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\".",
      "tickets": []
    },
    {
      "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 (Splice and 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, Splice) 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 Splice service and the drag-hover-onto-timeline affordance are 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."
    }
  ]
}
