From 530bbd7584f6bf97bafeeda43908216c426d6690 Mon Sep 17 00:00:00 2001 From: jonathanwardle1992-beep Date: Fri, 6 Nov 2026 20:16:20 +0000 Subject: [PATCH 1/7] Add CREATE THRESHOLD SLICES for chopping a trim on its rises. The sample editor can hand a module short absolute peaks. The module finds the rises and proposes slice starts; the stock confirm still writes the markers. Co-authored-by: Cursor --- docs/module-guides/system.md | 1 + sdk/drafts/threshold-slices/CHANGELOG.json | 13 ++ sdk/drafts/threshold-slices/LICENSE | 21 ++ sdk/drafts/threshold-slices/README.md | 55 +++++ sdk/drafts/threshold-slices/TESTING.md | 30 +++ sdk/drafts/threshold-slices/device_font.c | 105 +++++++++ sdk/drafts/threshold-slices/device_font.h | 13 ++ sdk/drafts/threshold-slices/device_module.c | 211 ++++++++++++++++++ sdk/drafts/threshold-slices/manifest.py | 16 ++ .../threshold-slices/octamod.module.json | 114 ++++++++++ .../presentation/thumbnail.svg | 10 + sdk/drafts/threshold-slices/runtime.json | 6 + sdk/drafts/threshold-slices/test_threshold.c | 88 ++++++++ sdk/drafts/threshold-slices/threshold.c | 72 ++++++ sdk/drafts/threshold-slices/threshold.h | 22 ++ sdk/machines/octatrack/HOOKS.md | 2 +- sdk/machines/octatrack/SAMPLE_EDITOR.md | 4 +- sdk/machines/octatrack/events.json | 2 +- .../octatrack/include/modwerk_events.h | 2 +- .../octatrack/include/modwerk_sample_editor.h | 17 +- .../module-adapters/sample_editor_board.c | 38 +++- .../module-adapters/sample_editor_board.h | 1 + .../module-adapters/sample_editor_menu.c | 4 +- .../module-adapters/sample_editor_service.c | 36 +++ .../module-adapters/sample_editor_service.h | 8 + .../test_sample_editor_board.c | 19 ++ .../module-adapters/test_sample_editor_menu.c | 4 +- .../test_sample_editor_service.c | 26 ++- sdk/tests/test_threshold_slices.py | 31 +++ 29 files changed, 959 insertions(+), 12 deletions(-) create mode 100644 sdk/drafts/threshold-slices/CHANGELOG.json create mode 100644 sdk/drafts/threshold-slices/LICENSE create mode 100644 sdk/drafts/threshold-slices/README.md create mode 100644 sdk/drafts/threshold-slices/TESTING.md create mode 100644 sdk/drafts/threshold-slices/device_font.c create mode 100644 sdk/drafts/threshold-slices/device_font.h create mode 100644 sdk/drafts/threshold-slices/device_module.c create mode 100644 sdk/drafts/threshold-slices/manifest.py create mode 100644 sdk/drafts/threshold-slices/octamod.module.json create mode 100644 sdk/drafts/threshold-slices/presentation/thumbnail.svg create mode 100644 sdk/drafts/threshold-slices/runtime.json create mode 100644 sdk/drafts/threshold-slices/test_threshold.c create mode 100644 sdk/drafts/threshold-slices/threshold.c create mode 100644 sdk/drafts/threshold-slices/threshold.h create mode 100644 sdk/tests/test_threshold_slices.py diff --git a/docs/module-guides/system.md b/docs/module-guides/system.md index b5ed95f26..21a3e86c3 100644 --- a/docs/module-guides/system.md +++ b/docs/module-guides/system.md @@ -29,6 +29,7 @@ interpreting edit timing; trig masks and advancing DSP frames do not prove audio - Gate recording by the source being edited. A playback track and its recorder are independent; recording into another buffer must not automatically block editing an unrelated file or inactive buffer. Verify the actual source ownership and writer boundary. - A queue being empty, a dirty flag or an unchanged slot counter does not establish ownership. Cover complete writer lifetimes, nested calls, failed replacements, recorder/allocator work and delayed completion before admitting access. - Copy views for the UI. Validate on opening and explicit intent rather than polling live metadata every display tick; retain a busy confirmation intent only while its source and owner remain valid, and cancel it on Back or changed controls. +- An edit that needs the waveform reads absolute peaks through the base's bounded read, a few frames at a time on the UI task, and proposes starts only. The base still owns confirmation and the marker write. No sample pointer is kept. - Use the machine's metadata-writer boundary while preserving frame/DMA deadlines. A loader lock is not proof of exclusion against PCM or recorder writers. Refuse heavy work from unsuitable interrupt contexts. - A final state check still races with a higher-priority recorder unless the commit is ordered against it. Keep table copies and whole-table comparisons outside any short commit guard, and measure its duration on the unit. - Establish native menu-pointer lifetime before contributing rows. Copied labels, callbacks and selection storage can outlive a closed menu; retire all references before reclaiming module memory. Check typed descriptor alignment on the target, not only the host. diff --git a/sdk/drafts/threshold-slices/CHANGELOG.json b/sdk/drafts/threshold-slices/CHANGELOG.json new file mode 100644 index 000000000..f68ff12ef --- /dev/null +++ b/sdk/drafts/threshold-slices/CHANGELOG.json @@ -0,0 +1,13 @@ +[ + { + "version": "0.1.0-experimental", + "date": "2026-11-06", + "changes": [ + "AED SLICE gains CREATE THRESHOLD SLICES, under DELETE ALL SLICES and above CREATE SLICE GRID.", + "Turn the threshold and the minimum gap. Slices start where the level rises, a short moment before each hit.", + "YES asks the usual CREATE SLICES or REPLACE SLICES question. NO leaves the slices alone.", + "Known issue: the row needs a base that can read the waveform. An older base shows NO READ and writes nothing.", + "Known issue: not yet tried on a unit. A long sample takes a while to scan." + ] + } +] diff --git a/sdk/drafts/threshold-slices/LICENSE b/sdk/drafts/threshold-slices/LICENSE new file mode 100644 index 000000000..fe0a27018 --- /dev/null +++ b/sdk/drafts/threshold-slices/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 jonathanwardle1992-beep + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/sdk/drafts/threshold-slices/README.md b/sdk/drafts/threshold-slices/README.md new file mode 100644 index 000000000..13eebd0bf --- /dev/null +++ b/sdk/drafts/threshold-slices/README.md @@ -0,0 +1,55 @@ +# Threshold Slices + +Version: `0.1.0-experimental` · author: @jonathanwardle1992-beep · draft. + +## Overview + +CREATE THRESHOLD SLICES chops the current trim where the level rises. It is on the audio editor SLICE menu, under DELETE ALL SLICES and above CREATE SLICE GRID. With Beat Slicer installed, this row sits above CREATE BEAT GRID. + +A slice starts about 2 ms before a rise through the threshold and ends at the next start. The last slice ends at the trim. The first start stays at the trim, so the audio before the first hit is still a slice. + +## Controls + +Select the sample, open AED, press AMP for SLICE, then YES, and choose CREATE THRESHOLD SLICES. + +| Control | Behavior | +| --- | --- | +| Encoder A | Threshold, 10 to 100, as a percent of the loudest point in the trim. Higher keeps fewer hits. | +| Encoder B | Minimum gap: 10, 20, 50, 100, 200 or 400 ms. | +| Encoder C | L+R uses the louder side. LEFT and RIGHT use that side only. | +| YES | When the scan has finished, opens the stock CREATE SLICES? or REPLACE SLICES? question. Markers change only after that second YES. | +| NO | Close the popup. Nothing is written. | + +The right side shows the scan as a percent, then CREATE or REPLACE, or EMPTY, SHORT, OVER64, CHECK, WAIT, REFUSED or NO READ. EMPTY means no rise cleared the threshold. OVER64 means the result would pass 64 slices; raise the threshold or the gap. NO READ means this base cannot hand the module the waveform yet. + +## Usage + +1. Stop recording into the buffer you are editing. Playback of the pattern can continue. +2. Open the popup and set the threshold and the gap. A drum loop often starts well at 40 and 50 ms. +3. Press YES, then YES at the stock question. Play the slices with slice mode on. DELETE SLICE and DELETE ALL SLICES clear them the usual way. + +### Quick tutorial: chop a loop on its hits + +1. Load a Flex or Static loop, open AED, press AMP for SLICE, then YES, and choose CREATE THRESHOLD SLICES. +2. Leave the threshold at 40 and the gap at 50 ms. Wait until the right side says CREATE or REPLACE and shows a slice count. +3. Press YES, then YES at the stock question. Trigger the slices. Press NO at the question if you want the old slices left alone. + +## Compatibility and limitations + +- OS 1.40C and a Modwerk base whose sample editor can copy peaks. The module installs beside Beat Slicer. Card firmware cannot include it. +- At most 64 slices. A result past that is refused. +- Replacing slices can change the slice numbers parameter locks point at, on every track that uses the sample. +- A start on a rise can still click. Nothing is saved on its own. +- Not yet tried on a unit, on the MKI, or after a project save and a power cycle. + +## Tests and measurements + +Host scan of synthetic peaks: `python -m unittest sdk.tests.test_threshold_slices`. The base host fixtures cover the peak read. No cycle measurement and no unit trial yet. See [TESTING.md](TESTING.md). + +## Authorship and licences + +Original module by @jonathanwardle1992-beep, MIT ([LICENSE](LICENSE)). The popup lettering is adapted from Beat Slicer's device font by @twwwy, MIT. No Elektron firmware, extracted routines or tables, factory samples, memory dumps or built firmware images are included. + +## Screens and audio + +No screen capture yet. The popup is drawn on the unit; the pictures belong with the first trial. diff --git a/sdk/drafts/threshold-slices/TESTING.md b/sdk/drafts/threshold-slices/TESTING.md new file mode 100644 index 000000000..f21f5107f --- /dev/null +++ b/sdk/drafts/threshold-slices/TESTING.md @@ -0,0 +1,30 @@ +# Threshold Slices testing + +## Commands and exact revision + +Draft written against `eaaf60caf0d050cc158e2b281207a913183e813b`. No device trial is tied to that commit. + +- `python -m unittest sdk.tests.test_threshold_slices` — PASS, 2026-11-06, host gcc. Spikes, silence, 64-slice refusal, a held rise and a short trim. +- The sample-editor host fixtures (service, board, menu, actions) were compiled with the same gcc and each printed PASS, including the new peak read. + +Not run yet: a ColdFire package build, a base build that contains the peak read, and a load on a unit. + +## Hardware and audio quality + +Not tested. No MKI or MKII run. No audio capture. + +## Stock flows + +Not tested. The menu row is added in the creation group under DELETE ALL SLICES and above CREATE SLICE GRID. Stock rows, their callbacks and the remembered stock index are left to the base. DELETE SLICE and DELETE ALL SLICES are unchanged. Not compared on a unit. + +## Performance + +Not measured. The scan runs on the UI task while the popup is open, at most 8 reads of 32 frames a tick. Closed, the module does not read. + +## Resources + +Unmeasured. + +## Hardware + +Untested. diff --git a/sdk/drafts/threshold-slices/device_font.c b/sdk/drafts/threshold-slices/device_font.c new file mode 100644 index 000000000..0d66039bd --- /dev/null +++ b/sdk/drafts/threshold-slices/device_font.c @@ -0,0 +1,105 @@ +/* SPDX-License-Identifier: MIT */ +/* Popup lettering adapted from Beat Slicer's device font (MIT, twwwy). */ +#include "device_font.h" +static const unsigned char letters[26][7]={ + {14,17,17,31,17,17,17},{30,17,17,30,17,17,30},{14,17,16,16,16,17,14}, + {30,17,17,17,17,17,30},{31,16,16,30,16,16,31},{31,16,16,30,16,16,16}, + {14,17,16,23,17,17,14},{17,17,17,31,17,17,17},{31,4,4,4,4,4,31}, + {1,1,1,1,17,17,14},{17,18,20,24,20,18,17},{16,16,16,16,16,16,31}, + {17,27,21,21,17,17,17},{17,25,21,19,17,17,17},{14,17,17,17,17,17,14}, + {30,17,17,30,16,16,16},{14,17,17,17,21,18,13},{30,17,17,30,20,18,17}, + {15,16,16,14,1,1,30},{31,4,4,4,4,4,4},{17,17,17,17,17,17,14}, + {17,17,17,17,17,10,4},{17,17,17,21,21,21,10},{17,17,10,4,10,17,17}, + {17,17,10,4,4,4,4},{31,1,2,4,8,16,31} +}; +static const unsigned char digits[10][7]={ + {14,17,19,21,25,17,14},{4,12,4,4,4,4,14},{14,17,1,2,4,8,31}, + {30,1,1,14,1,1,30},{2,6,10,18,31,2,2},{31,16,16,30,1,1,30}, + {14,16,16,30,17,17,14},{31,1,2,4,8,8,8},{14,17,17,14,17,17,14}, + {14,17,17,15,1,1,14} +}; +void ts_device_text_at(unsigned char *frame,uint32_t x,uint32_t y,const char *s){ + if(!frame || !s || y>57)return; + for(;*s && x+5<=124;x+=6,++s){ + const unsigned char *glyph=0; + if(*s>='A' && *s<='Z')glyph=letters[(unsigned)*s-'A']; + if(*s>='0' && *s<='9')glyph=digits[(unsigned)*s-'0']; + for(uint32_t row=0;row<7;++row){ + uint32_t bits=glyph?glyph[row]:0; + if(*s==':' && (row==2 || row==5))bits=4; + if(*s=='.' && row==6)bits=4; + if(*s=='-' && row==3)bits=14; + if(*s=='/')bits=1u<<(row*4u/6u); + for(uint32_t column=0;column<5;++column)if(bits&(16u>>column)) + frame[(x+column)*8u+7u-(y+row)/8u]|=(unsigned char)(1u<<((y+row)%8u)); + } + } +} + +static void pixel(unsigned char *frame,uint32_t x,uint32_t y,unsigned invert) +{ + if(x>=128 || y>=64)return; + unsigned char *p=&frame[x*8u+7u-y/8u],bit=(unsigned char)(1u<<(y%8u)); + if(invert)*p^=bit;else *p|=bit; +} +void ts_device_clear(unsigned char *frame,uint32_t x,uint32_t y,uint32_t w,uint32_t h) +{ + if(!frame || !w || !h || x>=128 || y>=64 || w>128-x || h>64-y)return; + /* Clear packed column bytes, rather than revisiting a byte for each pixel. */ + uint32_t last=y+h-1u; + for(uint32_t band=y/8u;band<=last/8u;++band) { + uint32_t low=band==y/8u?y%8u:0u,high=band==last/8u?last%8u:7u; + unsigned char keep=(unsigned char)~((255u<>(7u-high))); + for(uint32_t column=x;column59)return; + for(;*s && x+3<=124;x+=4,++s) { + const unsigned char *glyph=0; + if(*s>='A' && *s<='Z')glyph=alpha[(unsigned)*s-'A']; + if(*s>='0' && *s<='9')glyph=digit[(unsigned)*s-'0']; + for(uint32_t row=0;row<5;++row) { + uint32_t bits=glyph?glyph[row]:0; + if(*s==':' && (row==1 || row==3))bits=2; + if(*s=='.' && row==4)bits=2; + if(*s=='-' && row==2)bits=7; + if(*s=='/')bits=1u<<(row/2u); + for(uint32_t column=0;column<3;++column) + if(bits&(4u>>column))pixel(frame,x+column,y+row,0); + } + } +} +void ts_device_box(unsigned char *frame,uint32_t x,uint32_t y,uint32_t w,uint32_t h) +{ + if(!frame || !w || !h || x>=128 || y>=64 || w>128-x || h>64-y)return; + for(uint32_t i=x;i=128 || y>=64 || w>128-x || h>64-y)return; + for(uint32_t i=0;i=128 || y>=64 || w>128-x || h>64-y)return; + for(uint32_t i=x;i +void ts_device_text_at(unsigned char *, uint32_t, uint32_t, const char *); +void ts_device_box(unsigned char *, uint32_t, uint32_t, uint32_t, uint32_t); +void ts_device_invert(unsigned char *, uint32_t, uint32_t, uint32_t, uint32_t); +void ts_device_small_text(unsigned char *, uint32_t, uint32_t, const char *); +void ts_device_clear(unsigned char *, uint32_t, uint32_t, uint32_t, uint32_t); +void ts_device_dashed_box(unsigned char *, uint32_t, uint32_t, uint32_t, uint32_t); +#endif diff --git a/sdk/drafts/threshold-slices/device_module.c b/sdk/drafts/threshold-slices/device_module.c new file mode 100644 index 000000000..6a46bf1ad --- /dev/null +++ b/sdk/drafts/threshold-slices/device_module.c @@ -0,0 +1,211 @@ +/* SPDX-License-Identifier: MIT + * CREATE THRESHOLD SLICES. Instrument access is the shared sample-editor API. + * Includes are relative to sdk/drafts/threshold-slices/. + */ +#include "../../runtime/loader/modwerk_module.h" +#include "../../machines/octatrack/include/modwerk_sample_editor.h" +#include "threshold.h" +#include "device_font.h" + +#define YES 0x31 +#define NO 0x32 +#define UP 0x33 +#define DOWN 0x20 +#define READS_PER_TICK 8u +static const uint32_t levels[10]={10,20,30,40,50,60,70,80,90,100}; +static const uint32_t gaps[6]={10,20,50,100,200,400}; +static const struct modwerk_sample_editor_api *editor; +static const struct modwerk_sample_editor_read_api *reader; +static struct modwerk_sample_view view; +static struct modwerk_sample_grid proposal; +static struct ts_scan scan; +static int32_t block[MODWERK_SAMPLE_READ_FRAMES]; +static uint32_t got_frames; +static uint32_t token,active,level=3,gap=2,channel,reply,unsupported,pending_confirm; +static int encoder_counts[3]; +static uint32_t consumed[2]; +#define COUNTS_PER_STEP 4 + +static void close_editor(void) +{ + if(active && editor)editor->cancel(token); + active=0;token=0;editor=0;reader=0;pending_confirm=0;unsupported=0; + encoder_counts[0]=encoder_counts[1]=encoder_counts[2]=0; +} +static int readable(void) +{ + return active && reader && !unsupported && view.frames && view.trim_start=2u)return; + for(step=0;stepMODWERK_SAMPLE_READ_FRAMES?MODWERK_SAMPLE_READ_FRAMES:remain; + if(ask>1u && (ask&1u))ask--; + /* The base reads pairs. The last frame of the file has no partner, so + * count it as silence and let the scan finish. */ + if(ask==1u && scan.cursor+1u>=view.frames) { + int32_t quiet=0; + ts_scan_feed(&scan,scan.cursor,&quiet,1u); + continue; + } + why=reader->read(token,scan.cursor,ask,channel,block,MODWERK_SAMPLE_READ_FRAMES,&got_frames); + if(why==MODWERK_SAMPLE_BUSY){reply=MODWERK_SAMPLE_BUSY;return;} + reply=MODWERK_SAMPLE_OK; + if(why==MODWERK_SAMPLE_STALE){close_editor();return;} + if(why!=MODWERK_SAMPLE_OK || !got_frames){scan.phase=2;scan.status=TS_FAILED;return;} + ts_scan_feed(&scan,scan.cursor,block,got_frames); + } +} +static void confirm_when_ready(void) +{ + uint32_t i; + if(!readable() || scan.phase<2u || scan.status!=TS_OK || scan.count<2u){pending_confirm=0;return;} + proposal.count=scan.count; + for(i=0;ireplace(token,&proposal); + pending_confirm=reply==MODWERK_SAMPLE_BUSY; + if(reply==MODWERK_SAMPLE_CONFIRMING){active=0;token=0;editor=0;reader=0;} +} +void ts_device_open(uint32_t opened,const struct modwerk_sample_editor_api *api) +{ + close_editor(); + if(!opened || !api || api->abi!=MODWERK_SAMPLE_EDITOR_ABI || api->bytescopy || !api->confirm || !api->cancel || !api->replace)return; + editor=api;token=opened;active=1;reply=MODWERK_SAMPLE_OK; + if(api->bytesread){unsupported=1;reader=0;return;} + if(editor->copy(token,&view)!=MODWERK_SAMPLE_OK){close_editor();return;} + restart(); +} +const struct modwerk_sample_action ts_sample_action={ + MODWERK_SAMPLE_EDITOR_ABI,MODWERK_SAMPLE_ACTION_BYTES,0x54534c31u,0,ts_device_open, + "CREATE THRESHOLD SLICES",{0,0,0} +}; +void module_tick(struct modwerk_runtime_api *api) +{ + if(pending_confirm && readable() && scan.phase>=2u)confirm_when_ready(); + else pull(); + api->value=0x54530000u | (active?0x100u:0u) | (pending_confirm?0x200u:0u) | (reply<<10) | scan.status; +} +static int step_value(int current,int delta,int count) +{ + int next=current+delta; + if(next<0)next=0; + if(next>=count)next=count-1; + return next; +} +static int adjust(int encoder,int delta) +{ + if(encoder==0)level=(uint32_t)step_value((int)level,delta,(int)(sizeof levels/sizeof levels[0])); + else if(encoder==1)gap=(uint32_t)step_value((int)gap,delta,(int)(sizeof gaps/sizeof gaps[0])); + else channel=(uint32_t)step_value((int)channel,delta,3); + restart();return 1; +} +int module_enc(int encoder,int delta) +{ + int *counts,steps; + if(!active)return 0; + if(encoder>=3 && encoder<6)return 1; + if(encoder<0 || encoder>=6){close_editor();return 0;} + if(unsupported)return 1; + pending_confirm=0; + counts=&encoder_counts[encoder]; + if((*counts>0 && delta<0) || (*counts<0 && delta>0))*counts=0; + steps=delta/COUNTS_PER_STEP; + *counts+=delta%COUNTS_PER_STEP; + steps+=*counts/COUNTS_PER_STEP; + *counts%=COUNTS_PER_STEP; + return steps?adjust(encoder,steps):1; +} +int module_key(int code,int pressed) +{ + uint32_t mask,bank; + if(code<0 || code>=64)return 0; + mask=1u<<((unsigned)code%32u);bank=(unsigned)code/32u; + if(!pressed) { + if(!(consumed[bank]&mask))return 0; + consumed[bank]&=~mask;return 1; + } + if(consumed[bank]&mask)return 1; + if(!active)return 0; + if(code!=YES && code!=NO && code!=UP && code!=DOWN){close_editor();return 0;} + consumed[bank]|=mask; + if(code==NO){close_editor();return 1;} + if(code==UP || code==DOWN || unsupported)return 1; + if(scan.phase<2u)return 1; + pending_confirm=1;confirm_when_ready();return 1; +} +static void text(char *out,const char *in) +{while(*in)*out++=*in++;*out=0;} +static char *number(char *out,uint32_t value) +{ + if(value>=100)*out++=(char)('0'+value/100u); + if(value>=10)*out++=(char)('0'+value/10u%10u); + *out++=(char)('0'+value%10u);*out=0;return out; +} +static void center(unsigned char *frame,uint32_t x,uint32_t width,uint32_t y,const char *value) +{ + uint32_t n=0,pixels;while(value[n])++n; + pixels=n?4u*n-1u:0; + ts_device_small_text(frame,x+(pixels=2u)return 100u; + if(!span || scan.cursorspan)into=span; + pct=into*50u/span;return scan.phase?pct+50u:pct; +} +void module_draw(unsigned char *frame) +{ + char line[32],*at; + if(!active || !frame)return; + ts_device_clear(frame,3,1,122,62); + ts_device_text_at(frame,4,4,"THRESHOLD SLICES"); + ts_device_invert(frame,4,3,120,9); + ts_device_dashed_box(frame,6,15,81,38); + ts_device_dashed_box(frame,33,15,1,38); + ts_device_dashed_box(frame,60,15,1,38); + ts_device_dashed_box(frame,6,34,81,1); + center(frame,7,26,18,"THR"); + center(frame,34,25,18,"GAP"); + center(frame,61,26,18,"CH"); + center(frame,6,81,56,"YES:OK NO:BACK"); + number(line,levels[level]);center(frame,7,26,26,line); + number(line,gaps[gap]);center(frame,34,25,26,line); + center(frame,61,26,26,channel==1?"L":channel==2?"R":"L+R"); + if(unsupported)text(line,"NO READ"); + else if(!view.frames || (view.trim_end<=view.trim_start))text(line,"EMPTY"); + else if(scan.phase<2u) { + at=number(line,progress());text(at,"%"); + } else if(scan.status==TS_TOO_MANY)text(line,"OVER64"); + else if(scan.status==TS_SHORT)text(line,"SHORT"); + else if(scan.status==TS_EMPTY)text(line,"EMPTY"); + else if(scan.status!=TS_OK)text(line,"CHECK"); + else if(reply==MODWERK_SAMPLE_BUSY)text(line,"WAIT"); + else if(reply!=MODWERK_SAMPLE_OK)text(line,"REFUSED"); + else if(scan.count<2u)text(line,"EMPTY"); + else text(line,view.count?"REPLACE":"CREATE"); + right(frame,120,20,line); + at=number(line,scan.phase>=2u && scan.status==TS_OK?scan.count:0);text(at," SLICE"); + right(frame,120,31,line); + ts_device_box(frame,3,1,122,62); +} diff --git a/sdk/drafts/threshold-slices/manifest.py b/sdk/drafts/threshold-slices/manifest.py new file mode 100644 index 000000000..dc61c7b9f --- /dev/null +++ b/sdk/drafts/threshold-slices/manifest.py @@ -0,0 +1,16 @@ +"""THRESHOLD SLICES: CREATE THRESHOLD SLICES in the AED SLICE menu. + +A C runtime module. The base owns the menu row, the source checks, the bounded +peak read, the stock confirmation and the marker writes. The package holds the +detector and the popup. It has no card form. +""" +from remix.schema import Category, Proof, Kind, Module + +MODULE = Module( + name="threshold-slices", key="THRESHOLD", kind=Kind.CF_PATCH, + doc="Slice a sample where the level rises through a threshold.", + category=Category.FIXES, author="jonathanwardle1992-beep", + author_url="https://github.com/jonathanwardle1992-beep", + proof=Proof.UNTESTED, + proof_note="Host scan test in sdk/tests/test_threshold_slices.py. No hardware run yet.", +) diff --git a/sdk/drafts/threshold-slices/octamod.module.json b/sdk/drafts/threshold-slices/octamod.module.json new file mode 100644 index 000000000..c8f3710cf --- /dev/null +++ b/sdk/drafts/threshold-slices/octamod.module.json @@ -0,0 +1,114 @@ +{ + "schemaVersion": 2, + "id": "threshold-slices", + "key": "THRESHOLD", + "name": "Threshold Slices", + "version": "0.1.0-experimental", + "category": "system", + "author": { + "github": "jonathanwardle1992-beep", + "credits": [ + "Original threshold detector and AED SLICE action.", + "Popup lettering adapted from Beat Slicer's device font by twwwy, MIT." + ] + }, + "nativeManifest": "manifest.py", + "presentation": { + "label": "THRESHOLD", + "family": "Sample workflow", + "summary": "Slice a sample where the level rises through a threshold you set.", + "overview": "CREATE THRESHOLD SLICES sits on the audio editor SLICE menu, under DELETE ALL SLICES and above CREATE SLICE GRID. It finds rises in the trim and writes slice starts through the stock create or replace question.", + "highlights": [ + "Threshold sets how strong a rise must be, relative to the loudest point in the trim.", + "Gap is the shortest time between two slice starts.", + "Each slice runs to the next start. The last slice ends at the trim.", + "Left, right, or whichever is louder." + ], + "usage": [ + "Select the sample, open AED, press AMP for SLICE, then YES, and choose CREATE THRESHOLD SLICES.", + "Turn encoder A for the threshold and encoder B for the minimum gap. Encoder C picks the channel.", + "Wait until the popup shows CREATE or REPLACE, press YES, then YES at the stock question." + ] + }, + "controls": [ + { + "name": "THRESHOLD", + "default": 3, + "count": 10, + "doc": "Percent of the trim's own peak. A higher value keeps only the stronger hits.", + "labels": ["10", "20", "30", "40", "50", "60", "70", "80", "90", "100"] + }, + { + "name": "GAP", + "default": 2, + "count": 6, + "doc": "Minimum time between slice starts.", + "labels": ["10 MS", "20 MS", "50 MS", "100 MS", "200 MS", "400 MS"] + }, + { + "name": "CHANNEL", + "default": 0, + "count": 3, + "doc": "Which side of a stereo sample the rises are taken from.", + "labels": ["L+R", "LEFT", "RIGHT"] + } + ], + "compatibility": { + "firmware": "1.40C", + "location": "Flex / Static", + "conflicts": [], + "uiClaims": ["screen:CREATE THRESHOLD SLICES"], + "limitations": [ + "Needs a Modwerk base that provides the sample-editor peak read. An older base shows NO READ and writes nothing.", + "At most 64 slices. A finer result is refused and the old slices stay.", + "Each slice ends at the next start. The last ends at the trim. The start sits about 2 ms before the rise.", + "Recording into the buffer being edited must be stopped. Other playback may continue.", + "Replacing slices can change the slice numbers that parameter locks use.", + "Not yet tried on a unit. A scan is not a promise of click-free playback." + ], + "effectId": null + }, + "resources": { + "recorded": "Not measured", + "storage": { + "label": "Storage", + "display": "Not measured", + "value": null, + "unit": "bytes", + "method": "unmeasured", + "conditions": "One ColdFire package. No DSP memory. The base owns the slice table it writes.", + "source": "TESTING.md" + }, + "processing": { + "label": "CPU load", + "display": "Not measured", + "value": null, + "unit": "us", + "method": "unmeasured", + "conditions": "The scan runs on the UI task, a few frames at a time, only while the popup is open.", + "source": "TESTING.md" + } + }, + "access": { + "location": "AED → AMP (SLICE) → YES → CREATE THRESHOLD SLICES", + "steps": [ + "Select a Flex or Static sample, or a finished recorder buffer. Stop recording into that buffer.", + "Open AED, press AMP for SLICE, then YES, and choose CREATE THRESHOLD SLICES.", + "Set the threshold, the gap and the channel. When the popup shows CREATE or REPLACE, press YES, then YES at the stock question." + ], + "screenshots": [] + }, + "tests": { + "report": "TESTING.md", + "summary": "Host scan of synthetic peaks. The sample-editor read is covered by the base host fixtures. No unit trial yet.", + "hardwareStatus": "untested", + "evidenceRevision": "eaaf60caf0d050cc158e2b281207a913183e813b", + "gates": [] + }, + "license": { + "spdx": "MIT", + "file": "LICENSE", + "declaration": "Original threshold detector. Popup lettering adapted from Beat Slicer's MIT device font. No Elektron firmware, extracted routines, tables, samples or dumps." + }, + "media": [] +} diff --git a/sdk/drafts/threshold-slices/presentation/thumbnail.svg b/sdk/drafts/threshold-slices/presentation/thumbnail.svg new file mode 100644 index 000000000..af33e7b03 --- /dev/null +++ b/sdk/drafts/threshold-slices/presentation/thumbnail.svg @@ -0,0 +1,10 @@ + + + + + + + + + THRESHOLD SLICES + diff --git a/sdk/drafts/threshold-slices/runtime.json b/sdk/drafts/threshold-slices/runtime.json new file mode 100644 index 000000000..48037b463 --- /dev/null +++ b/sdk/drafts/threshold-slices/runtime.json @@ -0,0 +1,6 @@ +{ + "schema": 1, + "contribute": [ + { "to": "ot_sample_editor_actions", "data": "ts_sample_action", "order": 40 } + ] +} diff --git a/sdk/drafts/threshold-slices/test_threshold.c b/sdk/drafts/threshold-slices/test_threshold.c new file mode 100644 index 000000000..acce92b35 --- /dev/null +++ b/sdk/drafts/threshold-slices/test_threshold.c @@ -0,0 +1,88 @@ +/* SPDX-License-Identifier: MIT */ +#include "threshold.h" +#include +static int fail; +#define CHECK(x) do { if(!(x)){ printf("FAIL %u\n",(unsigned)__LINE__); fail=1; return; } } while(0) +static void feed_all(struct ts_scan *s, const int32_t *peaks, uint32_t n) +{ + uint32_t guard=0; + while(s->phase<2u && guardcursor, ask; + ++guard; + if(at>=n){ts_scan_feed(s,at,0,0);break;} + ask=n-at>7u?7u:n-at; + if(ts_scan_feed(s,at,peaks+at,ask))break; + } +} +static void spikes(void) +{ + int32_t peaks[1000]; + struct ts_scan scan; + uint32_t i; + for(i=0;i<1000;++i)peaks[i]=0; + peaks[200]=peaks[500]=peaks[800]=1000; + ts_scan_begin(&scan,0,1000,1000,50,0); + feed_all(&scan,peaks,1000); + CHECK(scan.phase==2 && scan.status==TS_OK && scan.count==4); + CHECK(scan.starts[0]==0 && scan.starts[1]==198 && scan.starts[2]==498 && scan.starts[3]==798); +} +static void silence(void) +{ + int32_t peaks[200]; + struct ts_scan scan; + uint32_t i; + for(i=0;i<200;++i)peaks[i]=0; + ts_scan_begin(&scan,0,200,44100,40,50); + feed_all(&scan,peaks,200); + CHECK(scan.status==TS_EMPTY && scan.count==0); +} +static void too_many(void) +{ + enum { N=7000 }; + static int32_t peaks[N]; + struct ts_scan scan; + uint32_t i; + for(i=0;itrim_start=trim_start;s->trim_end=trim_end;s->rate=rate?rate:44100u; + s->percent=percent;s->gap_ms=gap_ms;s->pad=s->rate*2u/1000u; + s->minimum=s->level=s->peak=0;s->phase=0;s->cursor=trim_start;s->count=0; + s->status=TS_OK;s->armed=1;s->have_prev=0;s->previous=0; + for(i=0;istarts[i]=0; + if(trim_start>=trim_end || trim_end-trim_start<64u){s->phase=2;s->status=TS_SHORT;} +} +static void finish_empty(struct ts_scan *s) +{s->phase=2;s->status=TS_EMPTY;s->count=0;} +static void begin_hits(struct ts_scan *s) +{ + uint32_t gap=s->gap_ms*(s->rate/1000u)+(s->gap_ms*(s->rate%1000u))/1000u; + s->minimum=gap>64u?gap:64u; + s->level=(uint32_t)(((uint64_t)s->peak*s->percent)/100u); + s->cursor=s->trim_start;s->have_prev=0;s->armed=1;s->previous=0; + if(!s->peak || !s->level){finish_empty(s);return;} + s->count=1;s->starts[0]=s->trim_start;s->phase=1; +} +static void add_hit(struct ts_scan *s, uint32_t frame) +{ + uint32_t start=frame>s->pad?frame-s->pad:0u, previous=s->starts[s->count-1u]; + if(starttrim_start)start=s->trim_start; + if(startminimum) { + if(frameminimum)return; + start=frame; + } + if(start>=s->trim_end || s->trim_end-start<64u)return; + if(s->count>=TS_MAX){s->phase=2;s->status=TS_TOO_MANY;return;} + s->starts[s->count++]=start; +} +unsigned ts_scan_feed(struct ts_scan *s, uint32_t at, const int32_t *peaks, uint32_t count) +{ + uint32_t i; + if(s->phase>=2u)return 1; + if(at!=s->cursor || (count && !peaks)){s->phase=2;s->status=TS_FAILED;return 1;} + if(s->cursor>=s->trim_end) { + if(!s->phase)begin_hits(s); + else if(s->phase==1u) { + s->phase=2; + if(s->status==TS_OK && s->count<2u)finish_empty(s); + } + return s->phase>=2u; + } + for(i=0;iphase<2u;++i) { + int32_t sample=peaks[i]<0?0:peaks[i]; + uint32_t frame=s->cursor; + if(!s->phase){if((uint32_t)sample>s->peak)s->peak=(uint32_t)sample;} + else { + if(s->have_prev && s->armed && (uint32_t)s->previous<=s->level && (uint32_t)sample>s->level) + add_hit(s,frame); + if((uint32_t)sample<=s->level/2u)s->armed=1; + else if((uint32_t)sample>s->level)s->armed=0; + s->previous=sample;s->have_prev=1; + } + s->cursor=frame+1u; + if(s->cursor>=s->trim_end) { + if(!s->phase)begin_hits(s); + else if(s->phase==1u) { + s->phase=2; + if(s->status==TS_OK && s->count<2u)finish_empty(s); + } + return s->phase>=2u; + } + } + return s->phase>=2u; +} diff --git a/sdk/drafts/threshold-slices/threshold.h b/sdk/drafts/threshold-slices/threshold.h new file mode 100644 index 000000000..613a00289 --- /dev/null +++ b/sdk/drafts/threshold-slices/threshold.h @@ -0,0 +1,22 @@ +/* SPDX-License-Identifier: MIT */ +#ifndef THRESHOLD_SLICES_H +#define THRESHOLD_SLICES_H +#include +#define TS_MAX 64u +enum { TS_OK, TS_EMPTY, TS_TOO_MANY, TS_SHORT, TS_FAILED }; +/* Two passes over absolute peaks, one frame each, from trim start. + * Pass one keeps the peak. Pass two places a start a fixed pad before each + * rise through peak * percent / 100, held off for the longer of 64 frames + * and the gap. The first start is the trim start. A 65th hit refuses. */ +struct ts_scan { + uint32_t trim_start, trim_end, rate, percent, gap_ms, pad, minimum, level, peak; + uint32_t phase, cursor, count, status, armed, have_prev; + int32_t previous; + uint32_t starts[TS_MAX]; +}; +void ts_scan_begin(struct ts_scan *, uint32_t trim_start, uint32_t trim_end, + uint32_t rate, uint32_t percent, uint32_t gap_ms); +/* peaks[i] is frame `at + i`. `at` must be the cursor. Returns 1 when the + * scan has finished. A zero count does nothing until the cursor is at the end. */ +unsigned ts_scan_feed(struct ts_scan *, uint32_t at, const int32_t *peaks, uint32_t count); +#endif diff --git a/sdk/machines/octatrack/HOOKS.md b/sdk/machines/octatrack/HOOKS.md index 24de86388..5ea7f2176 100644 --- a/sdk/machines/octatrack/HOOKS.md +++ b/sdk/machines/octatrack/HOOKS.md @@ -55,7 +55,7 @@ next base build, which renames them; the base loads them by those paths. | `ev_arp_setup_knob` | take | 0x4007a2ec | an encoder turns on MIDI ARP SETUP (FUNC+AMP on a MIDI track), before stock edits LEN, KEY or an arp note | `struct modwerk_take, jmp entry, claims(nothing)` | The taker's entry gets every register and the stack as at the site: (%sp) the return address, 4(%sp) the encoder (2 LEN, 5 KEY, 6 the arp note editor), 8(%sp) the delta. It takes the turn by returning to the caller with rts (stock edits nothing), or hands it to stock by replaying the stock tail below; it must not jump into the site's bytes. A taker that stores into the pattern or the Part writes the bank and its battery-backed copy and sets the edited flags as stock's writers do. | 10-29: a machine claiming its own tracks (claims set); 30-49: a machine's bundled feature; 50-69: a standalone feature module. The first record that claims the call takes it. | the UI task (ARP SETUP's input layer): short, no firmware call that can block | | `ev_arp_setup_text` | take | 0x4007a1ec | MIDI ARP SETUP is about to draw a setup value (d3 the slot: 2 LEN, 5 KEY) | `struct modwerk_take, jmp entry, claims(d3)` | The taker's entry gets every register and the stack as at the site: d3 the slot, d4 the track's value, a3 the slot's descriptor (a3@ the text function f(buffer, value), a3@(48) the drawer or 0 for 0x400479b4), a2 and d2 the place, d6 the surface. It draws the value itself, normally by calling the slot's drawer as stock does with a text function of its own, and goes on at 0x4007a216 with every register but d0-d1/a0-a1 as it found them; or it hands the value to stock by replaying the stock tail below. It must not jump into the site's bytes. | 10-29: a machine claiming its own tracks (claims set); 30-49: a machine's bundled feature; 50-69: a standalone feature module. The first record that claims the call takes it. | the UI task (the drawer): short, no firmware call that can block | | `ev_trig_muted` | call | none: a module calls the base's `modwerk_raise_ev_trig_muted` through a word the loader fills | a machine that starts a note from a trig itself, before stock binds the track's voice (ev_voice_bind), asks which tracks' new trigs a module drops now; no stock site: the machine calls the base's modwerk_raise_ev_trig_muted(), which returns every handler's answer OR'd, 0 with none | `unsigned f(void)` | Return the tracks whose new trigs this module drops now: bit t for track t+1, bits 0-7; higher bits are ignored. Every handler runs. They are the tracks whose ev_voice_bind this module drops (1) in the same frame: muted or soloed-out tracks while one of its trig-dropping modes is on, less those whose trigs it lets through (ev_mute_keep). The asking machine starts no new note on these tracks and lets a note already sounding finish; with no answer (0) it plays every trig, as stock does. d2-d7/a2-a6 must be kept. | lower first, then module id; every handler runs and the answers are OR'd, so the order does not change the answer. 10-90. | where the machine starts its note: the frame interrupt (the renderer the packer calls at 0x4000d43e) or a task: short, no firmware call that can block | -| `ot_sample_editor_actions` | table | none | the shared AED SLICE action menu lists admitted sample-editor contributions; selection opens the package with a fresh base-owned source token | `64-byte record` | Contribute one 64-byte struct modwerk_sample_action, ABI 1, flags/reserved zero and inline printable label. A nonzero action key is globally unique. The base validates the whole initialized record and entry against its exact published owner, copies its label into resident menus, and retains no module pointer in stock UI. Source capture, stock confirmation and marker publication belong to the base; owner/source/writer changes retire or refuse access before mutation. Actual waveform-read dispatch remains blocked until idle and complete metadata revalidation. Flex and Static file slots and inactive recorder buffers are supported during pattern playback. Recording blocks only the recorder buffer being edited, not unrelated files or buffers. No PCM access, automatic sample/project save or slice Undo. Older bases refuse the longer hook list. | lower first, then module id: 50-90 for independent editor actions. | the UI task; entry is nonblocking and receives copied settings through the base API | +| `ot_sample_editor_actions` | table | none | the shared AED SLICE action menu lists admitted sample-editor contributions; selection opens the package with a fresh base-owned source token | `64-byte record` | Contribute one 64-byte struct modwerk_sample_action, ABI 1, flags/reserved zero and inline printable label. A nonzero action key is globally unique. The base validates the whole initialized record and entry against its exact published owner, copies its label into resident menus, and retains no module pointer in stock UI. Source capture, stock confirmation and marker publication belong to the base; owner/source/writer changes retire or refuse access before mutation. Actual waveform-read dispatch remains blocked until idle and complete metadata revalidation. Flex and Static file slots and inactive recorder buffers are supported during pattern playback. Recording blocks only the recorder buffer being edited, not unrelated files or buffers. A bounded read copies absolute peaks into caller memory and no sample pointer crosses. No automatic sample/project save or slice Undo. Older bases refuse the longer hook list. | lower first, then module id: 50-90 for independent editor actions. | the UI task; entry is nonblocking and receives copied settings through the base API | | `ot_machine_starts` | table | none | the source START handler is selected in the frame interrupt, before native arguments and the optional trig gate | `16-byte record` | Requires a base advertising ot_machine_starts. ABI 1 machine descriptors keep their 80-byte extent. A machine with MODWERK_MACHINE_START must contribute exactly one version-1 record naming its descriptor; malformed, absent, duplicate or DSP-unpaid ownership refuses START and silences its previous voice until an accepted START. All legacy machines and unowned tracks retain the selected stock/module fallback, including FM Synth. Absent known PROTECT machines never receive stock START with custom controls. Tables publish masked, and the frame interrupt plus existing paused-context retention holds descriptors, callbacks and the selected fallback through the call. This is not a task-call lease. Module removal follows the existing machine/Part/UI and paused-stack rules. No changes to FM Synth or its FLEX START table cell. | lower first, then module id; records match descriptor identity, duplicate ownership is refused. | frame interrupt only: bounded, no blocking, preserve d2-d7/a2-a6 using the C ABI | ## Reviewed subscribers diff --git a/sdk/machines/octatrack/SAMPLE_EDITOR.md b/sdk/machines/octatrack/SAMPLE_EDITOR.md index 200775626..6d7fffbe6 100644 --- a/sdk/machines/octatrack/SAMPLE_EDITOR.md +++ b/sdk/machines/octatrack/SAMPLE_EDITOR.md @@ -41,7 +41,9 @@ The target descriptor test reads the registry index instead of assuming 32. and row writes preserve frame/DMA interrupts. The 0.1.65 candidate briefly excludes level 5 only for a recorder's final state/controls/trim check and count store, with DMA still enabled; physical timing remains unqualified. - PCM analysis, playback processing and implicit saves remain out of scope. + Playback processing and implicit saves remain out of scope. A module may + copy absolute peaks through the optional read tail, in short steps, and + propose slice starts. No sample pointer crosses the API. - `sample_editor_menu.c` and `sample_editor_hooks.s`: native menu/selection and waveform-worker boundaries, built with `--sample-editor`. The beta base sets it; a base built without it leaves the stock editor sites alone and refuses diff --git a/sdk/machines/octatrack/events.json b/sdk/machines/octatrack/events.json index 035bbdfab..b2c4bd5df 100644 --- a/sdk/machines/octatrack/events.json +++ b/sdk/machines/octatrack/events.json @@ -1793,7 +1793,7 @@ "declaration": "#include \"modwerk_sample_editor.h\"", "when": "the shared AED SLICE action menu lists admitted sample-editor contributions; selection opens the package with a fresh base-owned source token", "context": "the UI task; entry is nonblocking and receives copied settings through the base API", - "contract": "Contribute one 64-byte struct modwerk_sample_action, ABI 1, flags/reserved zero and inline printable label. A nonzero action key is globally unique. The base validates the whole initialized record and entry against its exact published owner, copies its label into resident menus, and retains no module pointer in stock UI. Source capture, stock confirmation and marker publication belong to the base; owner/source/writer changes retire or refuse access before mutation. Actual waveform-read dispatch remains blocked until idle and complete metadata revalidation. Flex and Static file slots and inactive recorder buffers are supported during pattern playback. Recording blocks only the recorder buffer being edited, not unrelated files or buffers. No PCM access, automatic sample/project save or slice Undo. Older bases refuse the longer hook list.", + "contract": "Contribute one 64-byte struct modwerk_sample_action, ABI 1, flags/reserved zero and inline printable label. A nonzero action key is globally unique. The base validates the whole initialized record and entry against its exact published owner, copies its label into resident menus, and retains no module pointer in stock UI. Source capture, stock confirmation and marker publication belong to the base; owner/source/writer changes retire or refuse access before mutation. Actual waveform-read dispatch remains blocked until idle and complete metadata revalidation. Flex and Static file slots and inactive recorder buffers are supported during pattern playback. Recording blocks only the recorder buffer being edited, not unrelated files or buffers. A bounded read copies absolute peaks into caller memory and no sample pointer crosses. No automatic sample/project save or slice Undo. Older bases refuse the longer hook list.", "order": "lower first, then module id: 50-90 for independent editor actions.", "subscribers": [], "orderEvidence": "The beta base carries the service (--sample-editor). No released module contributes yet; Beat Slicer's CREATE BEAT GRID is the first.", diff --git a/sdk/machines/octatrack/include/modwerk_events.h b/sdk/machines/octatrack/include/modwerk_events.h index 4af2d87ca..cb0cc8e28 100644 --- a/sdk/machines/octatrack/include/modwerk_events.h +++ b/sdk/machines/octatrack/include/modwerk_events.h @@ -178,7 +178,7 @@ struct modwerk_midi_generator { typedef unsigned modwerk_ev_trig_muted(void); /* Raise it: define `modwerk_ev_trig_muted *raise_ev_trig_muted` as a zero word in your image (initialized data, not bss), list "ev_trig_muted" under "raise" in runtime.json and call through it; every handler's answer, OR'd. */ -/* ot_sample_editor_actions (table, contribute {"to": "ot_sample_editor_actions", "order", "data"}): the shared AED SLICE action menu lists admitted sample-editor contributions; selection opens the package with a fresh base-owned source token. The UI task; entry is nonblocking and receives copied settings through the base API. Contribute one 64-byte struct modwerk_sample_action, ABI 1, flags/reserved zero and inline printable label. A nonzero action key is globally unique. The base validates the whole initialized record and entry against its exact published owner, copies its label into resident menus, and retains no module pointer in stock UI. Source capture, stock confirmation and marker publication belong to the base; owner/source/writer changes retire or refuse access before mutation. Actual waveform-read dispatch remains blocked until idle and complete metadata revalidation. Flex and Static file slots and inactive recorder buffers are supported during pattern playback. Recording blocks only the recorder buffer being edited, not unrelated files or buffers. No PCM access, automatic sample/project save or slice Undo. Older bases refuse the longer hook list. */ +/* ot_sample_editor_actions (table, contribute {"to": "ot_sample_editor_actions", "order", "data"}): the shared AED SLICE action menu lists admitted sample-editor contributions; selection opens the package with a fresh base-owned source token. The UI task; entry is nonblocking and receives copied settings through the base API. Contribute one 64-byte struct modwerk_sample_action, ABI 1, flags/reserved zero and inline printable label. A nonzero action key is globally unique. The base validates the whole initialized record and entry against its exact published owner, copies its label into resident menus, and retains no module pointer in stock UI. Source capture, stock confirmation and marker publication belong to the base; owner/source/writer changes retire or refuse access before mutation. Actual waveform-read dispatch remains blocked until idle and complete metadata revalidation. Flex and Static file slots and inactive recorder buffers are supported during pattern playback. Recording blocks only the recorder buffer being edited, not unrelated files or buffers. A bounded read copies absolute peaks into caller memory and no sample pointer crosses. No automatic sample/project save or slice Undo. Older bases refuse the longer hook list. */ #define MODWERK_OT_SAMPLE_EDITOR_ACTIONS 36 #include "modwerk_sample_editor.h" diff --git a/sdk/machines/octatrack/include/modwerk_sample_editor.h b/sdk/machines/octatrack/include/modwerk_sample_editor.h index c5f4b76a1..c5daa9849 100644 --- a/sdk/machines/octatrack/include/modwerk_sample_editor.h +++ b/sdk/machines/octatrack/include/modwerk_sample_editor.h @@ -1,7 +1,8 @@ /* SPDX-License-Identifier: MIT * Proposed shared Octatrack sample-editor interface. Not released capability. * The base owns source access, confirmation and publication; packages own their - * algorithms and controls. No PCM or instrument pointer crosses this API. + * algorithms and controls. No sample pointer crosses this API. The optional + * read tail copies bounded absolute peaks into caller memory. */ #ifndef MODWERK_SAMPLE_EDITOR_H #define MODWERK_SAMPLE_EDITOR_H @@ -15,7 +16,8 @@ enum modwerk_sample_reply { MODWERK_SAMPLE_OK, MODWERK_SAMPLE_BUSY, MODWERK_SAMPLE_STALE, MODWERK_SAMPLE_UNSUPPORTED, MODWERK_SAMPLE_INVALID, MODWERK_SAMPLE_PRESERVATION, MODWERK_SAMPLE_CONFIRMING, - MODWERK_SAMPLE_APPLIED, MODWERK_SAMPLE_NO_CHANGE, MODWERK_SAMPLE_FAULT + MODWERK_SAMPLE_APPLIED, MODWERK_SAMPLE_NO_CHANGE, MODWERK_SAMPLE_FAULT, + MODWERK_SAMPLE_READ_FAILED }; struct modwerk_slice_record { uint32_t start, end, loop; }; struct modwerk_sample_view { @@ -49,6 +51,17 @@ struct modwerk_sample_editor_api { * confirm() above offers preservation-only edits. */ unsigned (*replace)(uint32_t token, const struct modwerk_sample_grid *); }; +/* Optional tail. The ABI-1 struct above keeps its size. bytes covers this + * tail when the base provides it. read copies absolute peaks for frames + * inside the captured trim: channel 0 is the louder of the pair, 1 is left, + * 2 is right. count is at most MODWERK_SAMPLE_READ_FRAMES. BUSY means the + * same request can be tried on a later tick. No sample pointer is returned. */ +#define MODWERK_SAMPLE_READ_FRAMES 32u +struct modwerk_sample_editor_read_api { + struct modwerk_sample_editor_api base; + unsigned (*read)(uint32_t token, uint32_t frame, uint32_t count, uint32_t channel, + int32_t *peaks, uint32_t cap, uint32_t *got); +} __attribute__((aligned(4))); /* One typed contribution to the proposed base-owned action table. The record * is 64 bytes and four-byte aligned on ColdFire; label is inline, printable, * NUL + zero padding. ColdFire C otherwise permits two-byte struct alignment. diff --git a/sdk/machines/octatrack/module-adapters/sample_editor_board.c b/sdk/machines/octatrack/module-adapters/sample_editor_board.c index 4f6caed01..042d0540a 100644 --- a/sdk/machines/octatrack/module-adapters/sample_editor_board.c +++ b/sdk/machines/octatrack/module-adapters/sample_editor_board.c @@ -1,7 +1,8 @@ /* SPDX-License-Identifier: MIT * Native 1.40C base adapter. The source build checks every owned seam against * the private stock image. Enabled only by the private sample-editor option. - * Flex/Static and inactive recorder metadata only; no PCM access. + * Flex/Static and inactive recorder metadata. A bounded read copies absolute + * peaks through the stock pair readers; no sample pointer leaves the base. */ #include "sample_editor_board.h" #ifdef MW_SAMPLE_BOARD_HOST @@ -220,7 +221,24 @@ static unsigned publish(void *unused,const struct mw_sample_capture *before,cons return matches(settings,v->count,v->records) && READ(0x400ceb3cu)==selected? (stale?MODWERK_SAMPLE_STALE:MODWERK_SAMPLE_NO_CHANGE):MODWERK_SAMPLE_FAULT; } -static const struct mw_sample_backend backend={0,lock,unlock,ready,owner,capture,publish}; +static int read_pair(void *unused,uint32_t position,int32_t *left,int32_t *right) +{ + (void)unused; + if(!audio_preemptible())return 0; + const struct modwerk_sample_view *v=&mw_sample_editor.before.view; + if(position>=v->frames || v->frames-position<2u)return 0; +#ifdef MW_SAMPLE_BOARD_HOST + int mw_sample_host_read_pair(uint32_t,int32_t *,int32_t *); + return mw_sample_host_read_pair(position,left,right); +#else + /* Pair readers identified by the sample-editor research: Flex, then Static. + * Two frames per call, the count that research ran on hardware. */ + typedef int (*reader)(uint32_t,uint32_t,uint32_t,int32_t *,int32_t *); + reader native=(reader)(v->kind?0x40095984u:0x40093064u); + return native(v->slot,position,2u,left,right)>=0; +#endif +} +static const struct mw_sample_backend backend={0,lock,unlock,ready,owner,capture,publish,read_pair}; void mw_sample_editor_bootstrap(void) { if(!mw_sample_editor.initialized)(void)mw_sample_editor_init(&mw_sample_editor,&backend); @@ -262,9 +280,25 @@ static unsigned api_replace(uint32_t token,const struct modwerk_sample_grid *gri unsigned why=buffer(grid,sizeof *grid,0)?mw_sample_editor_replace(&mw_sample_editor,token,grid):MODWERK_SAMPLE_INVALID; modwerk_machine_unlock(saved);return why; } +static unsigned api_read(uint32_t token,uint32_t frame,uint32_t count,uint32_t channel, + int32_t *peaks,uint32_t cap,uint32_t *got) +{ + uint32_t saved=modwerk_machine_lock(); + unsigned why=MODWERK_SAMPLE_INVALID; + if(count && count<=MODWERK_SAMPLE_READ_FRAMES && + buffer(peaks,count*(uint32_t)sizeof *peaks,1) && buffer(got,sizeof *got,1)) + why=mw_sample_editor_read(&mw_sample_editor,token,frame,count,channel,peaks,cap,got); + modwerk_machine_unlock(saved);return why; +} const struct modwerk_sample_editor_api mw_sample_editor_api={ MODWERK_SAMPLE_EDITOR_ABI,sizeof(struct modwerk_sample_editor_api),api_copy,api_confirm,api_cancel,api_replace }; +static const struct modwerk_sample_editor_read_api mw_sample_editor_read_api={ + {MODWERK_SAMPLE_EDITOR_ABI,sizeof(struct modwerk_sample_editor_read_api), + api_copy,api_confirm,api_cancel,api_replace},api_read +}; +const struct modwerk_sample_editor_api *mw_sample_editor_public_api(void) +{return &mw_sample_editor_read_api.base;} void mw_sample_owner_changing(const struct runtime_dsp *from) { uint32_t saved=modwerk_machine_mask(); diff --git a/sdk/machines/octatrack/module-adapters/sample_editor_board.h b/sdk/machines/octatrack/module-adapters/sample_editor_board.h index c8c7a8edf..188267ef3 100644 --- a/sdk/machines/octatrack/module-adapters/sample_editor_board.h +++ b/sdk/machines/octatrack/module-adapters/sample_editor_board.h @@ -5,6 +5,7 @@ #include "sample_editor_actions.h" extern struct mw_sample_editor mw_sample_editor; extern const struct modwerk_sample_editor_api mw_sample_editor_api; +const struct modwerk_sample_editor_api *mw_sample_editor_public_api(void); void mw_sample_editor_bootstrap(void); int mw_sample_owner_current_locked(const struct runtime_owner *); void mw_sample_owner_changing(const struct runtime_dsp *); diff --git a/sdk/machines/octatrack/module-adapters/sample_editor_menu.c b/sdk/machines/octatrack/module-adapters/sample_editor_menu.c index 0d53764fd..5fe2ecac5 100644 --- a/sdk/machines/octatrack/module-adapters/sample_editor_menu.c +++ b/sdk/machines/octatrack/module-adapters/sample_editor_menu.c @@ -268,11 +268,11 @@ void mw_sample_ui_tick(void) uintptr_t base; if(why==MODWERK_SAMPLE_OK && image(&pending.owner,&base) && mw_sample_owner_current_locked(&pending.owner)) { #ifdef MW_SAMPLE_MENU_HOST - mw_sample_menu_host_open_action(base+pending.action.entry_offset,token,&mw_sample_editor_api); + mw_sample_menu_host_open_action(base+pending.action.entry_offset,token,mw_sample_editor_public_api()); #else void (*open)(uint32_t,const struct modwerk_sample_editor_api *)= (void (*)(uint32_t,const struct modwerk_sample_editor_api *))(base+pending.action.entry_offset); - open(token,&mw_sample_editor_api); + open(token,mw_sample_editor_public_api()); #endif modwerk_machine_unlock(saved);return; } diff --git a/sdk/machines/octatrack/module-adapters/sample_editor_service.c b/sdk/machines/octatrack/module-adapters/sample_editor_service.c index 497035398..83e8a32f4 100644 --- a/sdk/machines/octatrack/module-adapters/sample_editor_service.c +++ b/sdk/machines/octatrack/module-adapters/sample_editor_service.c @@ -201,6 +201,42 @@ unsigned mw_sample_editor_confirm(struct mw_sample_editor *e,uint32_t token,cons {return confirm(e,token,g,0);} unsigned mw_sample_editor_replace(struct mw_sample_editor *e,uint32_t token,const struct modwerk_sample_grid *g) {return confirm(e,token,g,1);} +static uint32_t magnitude(int32_t value) +{return value<0?(uint32_t)(~value)+1u:(uint32_t)value;} +static int32_t louder(uint32_t channel,int32_t left,int32_t right) +{ + uint32_t l=magnitude(left),r=magnitude(right); + if(channel==1u)return (int32_t)l; + if(channel==2u)return (int32_t)r; + return (int32_t)(l>r?l:r); +} +unsigned mw_sample_editor_read(struct mw_sample_editor *e,uint32_t token,uint32_t frame, + uint32_t count,uint32_t channel,int32_t *peaks,uint32_t cap,uint32_t *got) +{ + if(!binding(e) || !peaks || !got || channel>2u || !count || + count>MODWERK_SAMPLE_READ_FRAMES || count>cap)return MODWERK_SAMPLE_INVALID; + const struct mw_sample_backend *b=e->backend;uint32_t saved=b->lock(b->context); + unsigned why=current(e,token); + if(why==MODWERK_SAMPLE_OK && e->state!=MW_SAMPLE_OPEN)why=MODWERK_SAMPLE_BUSY; + if(why==MODWERK_SAMPLE_OK && !b->read_pair)why=MODWERK_SAMPLE_UNSUPPORTED; + if(why==MODWERK_SAMPLE_OK) { + const struct modwerk_sample_view *v=&e->before.view; + if(frametrim_start || frame>=v->trim_end || count>v->trim_end-frame)why=MODWERK_SAMPLE_INVALID; + else { + uint32_t wrote=0; + while(wrote=v->frames || !b->read_pair(b->context,pos,left,right))break; + peaks[wrote++]=louder(channel,left[0],right[0]); + if(wroteserial && why!=MODWERK_SAMPLE_BUSY)e->state=MW_SAMPLE_CLOSED; + b->unlock(b->context,saved);return why; +} unsigned mw_sample_editor_answer(struct mw_sample_editor *e,uint32_t token,unsigned yes) { if(!binding(e))return MODWERK_SAMPLE_UNSUPPORTED; diff --git a/sdk/machines/octatrack/module-adapters/sample_editor_service.h b/sdk/machines/octatrack/module-adapters/sample_editor_service.h index b3862a859..16c4ae5b6 100644 --- a/sdk/machines/octatrack/module-adapters/sample_editor_service.h +++ b/sdk/machines/octatrack/module-adapters/sample_editor_service.h @@ -35,6 +35,10 @@ struct mw_sample_backend { * Recorder extent/trim/PCM changes must never be rolled back. No disk save, * PCM mutation, normalizing old rows or user history is permitted. */ unsigned (*publish)(void *, const struct mw_sample_capture *, const struct mw_sample_edit *); + /* Optional. NULL means read returns UNSUPPORTED. Called with the same lock + * held, below the frame interrupt, for two frames at position. 0 fails the + * read and writes nothing. It must not retain the pointers. */ + int (*read_pair)(void *, uint32_t position, int32_t *left, int32_t *right); }; enum mw_sample_state { MW_SAMPLE_CLOSED, MW_SAMPLE_OPEN, MW_SAMPLE_PROMPT }; struct mw_sample_editor { @@ -51,6 +55,10 @@ unsigned mw_sample_editor_begin(struct mw_sample_editor *, const struct runtime_ unsigned mw_sample_editor_copy(struct mw_sample_editor *, uint32_t token, struct modwerk_sample_view *); unsigned mw_sample_editor_confirm(struct mw_sample_editor *, uint32_t token, const struct modwerk_sample_grid *); unsigned mw_sample_editor_replace(struct mw_sample_editor *, uint32_t token, const struct modwerk_sample_grid *); +/* Copies up to MODWERK_SAMPLE_READ_FRAMES absolute peaks from the captured trim. + * The caller owns peaks and got. A short *got still returns OK. */ +unsigned mw_sample_editor_read(struct mw_sample_editor *, uint32_t token, uint32_t frame, + uint32_t count, uint32_t channel, int32_t *peaks, uint32_t cap, uint32_t *got); /* Invoked only by the base's verified stock confirmation dispatch, with the * token captured when THAT prompt was shown. Revalidate source/owner on YES. * NO just cancels. BUSY retains the prompt for a later revalidated attempt; diff --git a/sdk/machines/octatrack/module-adapters/test_sample_editor_board.c b/sdk/machines/octatrack/module-adapters/test_sample_editor_board.c index 941aac3c2..cfd58ad17 100644 --- a/sdk/machines/octatrack/module-adapters/test_sample_editor_board.c +++ b/sdk/machines/octatrack/module-adapters/test_sample_editor_board.c @@ -112,6 +112,11 @@ void mw_sample_menu_retire_locked(const struct runtime_dsp *from) {(void)from;if(!masked)bad=1;++retired;} void mw_sample_host_refresh(void) {if(!locked || masked)bad=1;++refreshes;} +int mw_sample_host_read_pair(uint32_t position,int32_t *left,int32_t *right) +{ + if(!locked || masked)bad=1; + left[0]=left[1]=(int32_t)position;right[0]=right[1]=0;return 1; +} static uint32_t setup(unsigned kind,unsigned slot) { used=masked=locked=bad=writes=refreshes=retired=metadata_reads=frame_services=0;live=1; @@ -527,5 +532,19 @@ int main(void) mw_sample_owner_changing(&from); CHECK(retired==1 && mw_sample_editor_api.copy(token,view)==MODWERK_SAMPLE_STALE && !writes); CHECK(!masked && !locked && !bad); + { + const struct modwerk_sample_editor_api *pub=mw_sample_editor_public_api(); + const struct modwerk_sample_editor_read_api *ext=(const void *)pub; + int32_t *peaks=(int32_t *)((uint8_t *)extent+4096); + uint32_t *got=(uint32_t *)((uint8_t *)extent+4096+128); + uint32_t settings=setup(1,0); + (void)settings; + CHECK(pub->bytes==sizeof(struct modwerk_sample_editor_read_api) && pub->bytes>sizeof *pub); + CHECK(mw_sample_editor_begin(&mw_sample_editor,&owner,&token)==MODWERK_SAMPLE_OK); + CHECK(ext->read(token,0,4,0,peaks,4,got)==MODWERK_SAMPLE_OK && *got==4); + CHECK(peaks[0]==0 && peaks[1]==0 && peaks[2]==2 && peaks[3]==2); + storage=0;CHECK(ext->read(token,0,4,0,peaks,4,got)==MODWERK_SAMPLE_BUSY); + CHECK(!masked && !locked && !bad); + } puts("PASS: both native metadata paths, buffer ownership, atomic rollback and fail-closed gates");return 0; } diff --git a/sdk/machines/octatrack/module-adapters/test_sample_editor_menu.c b/sdk/machines/octatrack/module-adapters/test_sample_editor_menu.c index a5afb17c4..4f3923580 100644 --- a/sdk/machines/octatrack/module-adapters/test_sample_editor_menu.c +++ b/sdk/machines/octatrack/module-adapters/test_sample_editor_menu.c @@ -93,7 +93,7 @@ static unsigned publish(void *p,const struct mw_sample_capture *before,const str ++writes;source.view.count=edit->count;memcpy(source.view.records,edit->records,sizeof edit->records); return MODWERK_SAMPLE_APPLIED; } -static const struct mw_sample_backend backend={0,lock,unlock,ready,owner,capture,publish}; +static const struct mw_sample_backend backend={0,lock,unlock,ready,owner,capture,publish,0}; void mw_sample_editor_bootstrap(void) {if(!mw_sample_editor.initialized && !mw_sample_editor_init(&mw_sample_editor,&backend))bad=1;} static unsigned api_copy(uint32_t t,struct modwerk_sample_view *v){return mw_sample_editor_copy(&mw_sample_editor,t,v);} @@ -102,6 +102,8 @@ static unsigned api_replace(uint32_t t,const struct modwerk_sample_grid *g){retu static void api_cancel(uint32_t t){mw_sample_editor_cancel(&mw_sample_editor,t);} const struct modwerk_sample_editor_api mw_sample_editor_api={MODWERK_SAMPLE_EDITOR_ABI,sizeof mw_sample_editor_api, api_copy,api_confirm,api_cancel,api_replace}; +const struct modwerk_sample_editor_api *mw_sample_editor_public_api(void) +{return &mw_sample_editor_api;} static void reset(void) { memset(&mw_sample_editor,0,sizeof mw_sample_editor);memset(&source,0,sizeof source); diff --git a/sdk/machines/octatrack/module-adapters/test_sample_editor_service.c b/sdk/machines/octatrack/module-adapters/test_sample_editor_service.c index 512572264..7a65da984 100644 --- a/sdk/machines/octatrack/module-adapters/test_sample_editor_service.c +++ b/sdk/machines/octatrack/module-adapters/test_sample_editor_service.c @@ -37,7 +37,16 @@ static unsigned publish(void *context,const struct mw_sample_capture *before,con } return publish_reply; } -static const struct mw_sample_backend backend={&editor,lock,unlock,available,published,capture,publish}; +static int32_t pcm_l[8],pcm_r[8]; +static unsigned pair_calls; +static int read_pair(void *context,uint32_t position,int32_t *left,int32_t *right) +{ + if(context!=&editor || !held){bad=1;return 0;} + if(position+1u>=8u)return 0; + ++pair_calls;left[0]=pcm_l[position];left[1]=pcm_l[position+1u]; + right[0]=pcm_r[position];right[1]=pcm_r[position+1u];return 1; +} +static const struct mw_sample_backend backend={&editor,lock,unlock,available,published,capture,publish,read_pair}; static void reset(uint32_t kind,uint32_t slot,uint32_t count) { uint8_t *p=(uint8_t *)&editor; @@ -196,6 +205,21 @@ uint32_t mw_sample_editor_service_test(void) CHECK(mw_sample_editor_begin(&editor,&owner,&token)==MODWERK_SAMPLE_UNSUPPORTED && token==123); reset(1,0,0);editor.revision=UINT32_MAX;mw_sample_editor_changed_locked(&editor); CHECK(mw_sample_editor_begin(&editor,&owner,&token)==MODWERK_SAMPLE_UNSUPPORTED); + reset(1,0,0); + pcm_l[0]=0;pcm_l[1]=-100;pcm_l[2]=40;pcm_l[3]=0; + pcm_r[0]=0;pcm_r[1]=20;pcm_r[2]=-80;pcm_r[3]=0; + pair_calls=0; + CHECK(mw_sample_editor_begin(&editor,&owner,&token)==MODWERK_SAMPLE_OK); + static int32_t peaks[4]; + static uint32_t got; + CHECK(mw_sample_editor_read(&editor,token,0,4,0,peaks,4,&got)==MODWERK_SAMPLE_OK && got==4 && pair_calls==2); + CHECK(peaks[0]==0 && peaks[1]==100 && peaks[2]==80 && peaks[3]==0); + CHECK(mw_sample_editor_read(&editor,token,0,4,1,peaks,4,&got)==MODWERK_SAMPLE_OK && peaks[1]==100 && peaks[2]==40); + CHECK(mw_sample_editor_read(&editor,token,0,4,2,peaks,4,&got)==MODWERK_SAMPLE_OK && peaks[1]==20 && peaks[2]==80); + CHECK(mw_sample_editor_read(&editor,token,0,33,0,peaks,33,&got)==MODWERK_SAMPLE_INVALID); + ready=0;CHECK(mw_sample_editor_read(&editor,token,0,4,0,peaks,4,&got)==MODWERK_SAMPLE_BUSY); + ready=1;CHECK(mw_sample_editor_read(&editor,token,100,4,0,peaks,4,&got)==MODWERK_SAMPLE_READ_FAILED && !got); + CHECK(!held && !bad); return 0; } #ifdef MW_SAMPLE_EDITOR_HOST_MAIN diff --git a/sdk/tests/test_threshold_slices.py b/sdk/tests/test_threshold_slices.py new file mode 100644 index 000000000..1b4e03494 --- /dev/null +++ b/sdk/tests/test_threshold_slices.py @@ -0,0 +1,31 @@ +# SPDX-License-Identifier: MIT +"""Host check for the threshold-slice planner. No firmware.""" +import shutil +import subprocess +import tempfile +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +DRAFT = ROOT / 'sdk' / 'drafts' / 'threshold-slices' + + +@unittest.skipUnless(shutil.which('cc'), 'host C compiler unavailable') +class ThresholdSlices(unittest.TestCase): + def test_scan_places_starts_on_rises(self): + with tempfile.TemporaryDirectory() as folder: + executable = Path(folder) / 'threshold' + result = subprocess.run([ + shutil.which('cc'), '-std=c99', '-Wall', '-Wextra', '-Werror', + '-I', str(DRAFT), + str(DRAFT / 'test_threshold.c'), str(DRAFT / 'threshold.c'), + '-o', str(executable), + ], capture_output=True, text=True) + self.assertEqual(result.returncode, 0, result.stderr) + result = subprocess.run([str(executable)], capture_output=True, text=True) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertIn('PASS:', result.stdout) + + +if __name__ == '__main__': + unittest.main() From f96425206d5b209701cb0d4c05c7a03084612dee Mon Sep 17 00:00:00 2001 From: jonathanwardle1992-beep Date: Sat, 7 Nov 2026 21:48:16 +0000 Subject: [PATCH 2/7] Threshold Slices: one slice for a held note, and reuse the measured peak. The menu row sits with the other editor actions, and LEVEL adds one slice at the marker. Co-authored-by: Cursor --- sdk/drafts/threshold-slices/CHANGELOG.json | 10 +- sdk/drafts/threshold-slices/LICENSE | 1 + sdk/drafts/threshold-slices/README.md | 5 +- sdk/drafts/threshold-slices/TESTING.md | 5 +- sdk/drafts/threshold-slices/device_module.c | 120 +++++++++++++++++- sdk/drafts/threshold-slices/manifest.py | 2 +- .../threshold-slices/octamod.module.json | 2 +- sdk/drafts/threshold-slices/runtime.json | 2 +- sdk/drafts/threshold-slices/test_threshold.c | 20 ++- sdk/drafts/threshold-slices/threshold.c | 32 ++++- sdk/drafts/threshold-slices/threshold.h | 13 +- sdk/drafts/threshold-slices/verify.py | 10 ++ 12 files changed, 194 insertions(+), 28 deletions(-) create mode 100644 sdk/drafts/threshold-slices/verify.py diff --git a/sdk/drafts/threshold-slices/CHANGELOG.json b/sdk/drafts/threshold-slices/CHANGELOG.json index f68ff12ef..c2fd8eec2 100644 --- a/sdk/drafts/threshold-slices/CHANGELOG.json +++ b/sdk/drafts/threshold-slices/CHANGELOG.json @@ -1,13 +1,13 @@ [ { "version": "0.1.0-experimental", - "date": "2026-11-06", + "date": "2026-11-07", "changes": [ - "AED SLICE gains CREATE THRESHOLD SLICES, under DELETE ALL SLICES and above CREATE SLICE GRID.", - "Turn the threshold and the minimum gap. Slices start where the level rises, a short moment before each hit.", + "AED SLICE gains CREATE THRESHOLD SLICES, under DELETE ALL SLICES. With Beat Slicer it sits under CREATE BEAT GRID.", + "Turn the threshold and the gap. A held note stays one slice, a short moment before the hit.", "YES asks the usual CREATE SLICES or REPLACE SLICES question. NO leaves the slices alone.", - "Known issue: the row needs a base that can read the waveform. An older base shows NO READ and writes nothing.", - "Known issue: not yet tried on a unit. A long sample takes a while to scan." + "Press LEVEL on the waveform to add one slice at the marker. The marker stays, and the slices already there stay.", + "Known issue: needs a base that can read the waveform. A long sample still takes a while to scan." ] } ] diff --git a/sdk/drafts/threshold-slices/LICENSE b/sdk/drafts/threshold-slices/LICENSE index fe0a27018..a51b53f65 100644 --- a/sdk/drafts/threshold-slices/LICENSE +++ b/sdk/drafts/threshold-slices/LICENSE @@ -1,6 +1,7 @@ MIT License Copyright (c) 2026 jonathanwardle1992-beep +Copyright (c) 2026 twwwy and contributors Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/sdk/drafts/threshold-slices/README.md b/sdk/drafts/threshold-slices/README.md index 13eebd0bf..a53b97422 100644 --- a/sdk/drafts/threshold-slices/README.md +++ b/sdk/drafts/threshold-slices/README.md @@ -4,9 +4,9 @@ Version: `0.1.0-experimental` · author: @jonathanwardle1992-beep · draft. ## Overview -CREATE THRESHOLD SLICES chops the current trim where the level rises. It is on the audio editor SLICE menu, under DELETE ALL SLICES and above CREATE SLICE GRID. With Beat Slicer installed, this row sits above CREATE BEAT GRID. +CREATE THRESHOLD SLICES chops the current trim where the level rises. It is on the audio editor SLICE menu, under DELETE ALL SLICES and above CREATE SLICE GRID. With Beat Slicer installed, this row sits under CREATE BEAT GRID. -A slice starts about 2 ms before a rise through the threshold and ends at the next start. The last slice ends at the trim. The first start stays at the trim, so the audio before the first hit is still a slice. +A slice starts about 2 ms before a rise through the threshold and ends at the next start. A held note stays one slice. The last slice ends at the trim. The first start stays at the trim, so the audio before the first hit is still a slice. ## Controls @@ -17,6 +17,7 @@ Select the sample, open AED, press AMP for SLICE, then YES, and choose CREATE TH | Encoder A | Threshold, 10 to 100, as a percent of the loudest point in the trim. Higher keeps fewer hits. | | Encoder B | Minimum gap: 10, 20, 50, 100, 200 or 400 ms. | | Encoder C | L+R uses the louder side. LEFT and RIGHT use that side only. | +| LEVEL, pressed | With the waveform open on TRIM, SLICE or EDIT, adds one slice at the marker and leaves the marker on that point. The slices already there stay. The press does nothing while the editor is closed. | | YES | When the scan has finished, opens the stock CREATE SLICES? or REPLACE SLICES? question. Markers change only after that second YES. | | NO | Close the popup. Nothing is written. | diff --git a/sdk/drafts/threshold-slices/TESTING.md b/sdk/drafts/threshold-slices/TESTING.md index f21f5107f..c897605b9 100644 --- a/sdk/drafts/threshold-slices/TESTING.md +++ b/sdk/drafts/threshold-slices/TESTING.md @@ -6,8 +6,11 @@ Draft written against `eaaf60caf0d050cc158e2b281207a913183e813b`. No device tria - `python -m unittest sdk.tests.test_threshold_slices` — PASS, 2026-11-06, host gcc. Spikes, silence, 64-slice refusal, a held rise and a short trim. - The sample-editor host fixtures (service, board, menu, actions) were compiled with the same gcc and each printed PASS, including the new peak read. +- Host scan again on 2026-11-07, WSL gcc (`-std=c99 -Wall -Wextra -Werror`): PASS, including one rectified tone burst staying two starts (trim start plus one hit). Windows `python -m unittest` skipped because this machine has no `cc`. -Not run yet: a ColdFire package build, a base build that contains the peak read, and a load on a unit. +Private ColdFire package `C:\Users\Roy Walker\modwerk-builds\threshold-slices.mwrm`, SHA-256 `a020a947cfdad3ad42b3e5566ec78e88a1e769942926f20fd897e6e9b145d3e7`, subscribed at order 60. Not committed. + +On the unit, 2026-11-07, OCTATRACK 1.40C, base `671f0afd406290cb`: `device try` of that package for 12 seconds, then rolled back (not kept). While it ran, the slot was running, refusals stayed 0, and the tick value stayed `0x54530000` (the editor was not opened). About 60 ticks a second. After rollback the unit was normal, generation 2, the same base, and no slot running. The menu, LEVEL insert, scan time and CPU were not tried. ## Hardware and audio quality diff --git a/sdk/drafts/threshold-slices/device_module.c b/sdk/drafts/threshold-slices/device_module.c index 6a46bf1ad..9a351cc34 100644 --- a/sdk/drafts/threshold-slices/device_module.c +++ b/sdk/drafts/threshold-slices/device_module.c @@ -25,22 +25,121 @@ static uint32_t token,active,level=3,gap=2,channel,reply,unsupported,pending_con static int encoder_counts[3]; static uint32_t consumed[2]; #define COUNTS_PER_STEP 4 +/* Encoder presses are panel keys. Turning LEVEL moves the waveform marker. + * The press only clears a latch. The release, in TRIM 0x4006fb30, SLICE + * 0x400718ac and EDIT 0x400745c4, toggles follow-playback. While that flag + * is set the next playhead sample replaces the marker, so the line leaves + * the zoomed view. This module takes both edges while TRIM, SLICE or EDIT + * is open, and writes the saved marker back after the slice is published. */ +#define PUSH_LEVEL 0x3e +#define EDITOR_WINDOW 0x460e5e4cu +#define EDITOR_PAGE 0x460e5e5cu +#define EDITOR_KIND 0x46c8d1a0u +#define EDITOR_SLOT 0x46c8d19cu +#define STATIC_SETTINGS 0x100d5b30u +#define FLEX_SETTINGS 0x100b14f0u +#define SLICE_MARKERS 0x40070cbcu +#define EDITOR_REDRAW 0x4006dbdcu +#define VIEW_REFRESH 0x40077004u +/* The knob reads the marker through these cells. The view argument is what + * the same knob passes to 0x40077004 so the line is scrolled back on screen. */ +static const uint32_t cursor_ptr[3]={0x400ce74cu,0x400ceb40u,0x400cefa4u}; +static const uint32_t cursor_view[3]={0x400ce724u,0x400ceb14u,0x400cef60u}; +static const uint32_t follow_flag[3]={0x460e5e90u,0x460e5f08u,0x460e6bc4u}; +static const uint32_t follow_hold[3]={0x460e5e88u,0x460e5f00u,0x460e6bbcu}; +static uint32_t read_u32(uint32_t at) +{ + const volatile uint8_t *p=(const volatile uint8_t *)at; + return (uint32_t)p[0]<<24|(uint32_t)p[1]<<16|(uint32_t)p[2]<<8|p[3]; +} +static void write_u32(uint32_t at,uint32_t value) +{ + volatile uint8_t *p=(volatile uint8_t *)at; + p[0]=(uint8_t)(value>>24);p[1]=(uint8_t)(value>>16);p[2]=(uint8_t)(value>>8);p[3]=(uint8_t)value; +} +static uint32_t settings_address(void) +{ + uint32_t kind=*(volatile uint32_t *)EDITOR_KIND,slot=*(volatile uint32_t *)EDITOR_SLOT; + if(!kind && slot<128u)return STATIC_SETTINGS+slot*1096u; + if((kind==1u || kind==4u) && slot<136u)return FLEX_SETTINGS+slot*1096u; + return 0; +} +/* Stock ADD SLICE HERE (0x400730f0) keeps every slice and inserts one at the + * marker. The same insert uses whichever waveform page is open. The marker + * is read before any write. Nothing here builds the threshold grid. */ +static void insert_at_cursor(void) +{ + uint32_t page=*(volatile uint32_t *)EDITOR_PAGE,cell,cursor,settings,start,end,count,i,at,sr,span; + if(page>2u)return; + cell=*(volatile uint32_t *)cursor_ptr[page]; + if(!cell)return; + cursor=read_u32(cell); + settings=settings_address(); + if(!settings)return; + start=*(volatile uint32_t *)(settings+300u); + end=*(volatile uint32_t *)(settings+304u); + count=*(volatile uint32_t *)(settings+1092u); + if(count>62u || cursor=end || end-cursor<64u)return; + at=count; + for(i=0;iend)span=end; + __asm__ __volatile__("move.w %%sr,%0" : "=d"(sr)); + __asm__ __volatile__("move.w %0,%%sr" :: "d"((sr&~0x0700u)|0x0700u) : "memory"); + *(volatile uint32_t *)(settings+1092u)=0; + for(i=count;i>at;--i) { + uint32_t from=settings+312u+12u*(i-1u),to=from+12u; + *(volatile uint32_t *)to=*(volatile uint32_t *)from; + *(volatile uint32_t *)(to+4u)=*(volatile uint32_t *)(from+4u); + *(volatile uint32_t *)(to+8u)=*(volatile uint32_t *)(from+8u); + } + *(volatile uint32_t *)(settings+312u+12u*at)=cursor; + *(volatile uint32_t *)(settings+316u+12u*at)=span; + *(volatile uint32_t *)(settings+320u+12u*at)=0xffffffffu; + *(volatile uint32_t *)(settings+1092u)=count+1u; + __asm__ __volatile__("move.w %0,%%sr" :: "d"(sr) : "memory"); + write_u32(cell,cursor); + *(volatile uint32_t *)follow_flag[page]=0; + ((void (*)(void))SLICE_MARKERS)(); + write_u32(cell,cursor); + ((void (*)(uint32_t,uint32_t))VIEW_REFRESH)(cursor_view[page],cursor_ptr[page]); + *(volatile uint32_t *)follow_hold[page]=20u; + ((void (*)(void))EDITOR_REDRAW)(); + write_u32(cell,cursor); +} + +static uint32_t saved_peak,peak_ready; static void close_editor(void) { if(active && editor)editor->cancel(token); - active=0;token=0;editor=0;reader=0;pending_confirm=0;unsupported=0; + active=0;token=0;editor=0;reader=0;pending_confirm=0;unsupported=0;peak_ready=0; encoder_counts[0]=encoder_counts[1]=encoder_counts[2]=0; } static int readable(void) { return active && reader && !unsupported && view.frames && view.trim_start=1u && scan.status!=TS_FAILED) { + saved_peak=scan.peak;peak_ready=1; + } +} +/* The loudest point depends on the channel only. Threshold and gap reuse it. */ +static void restart(int keep_peak) { pending_confirm=0;reply=MODWERK_SAMPLE_OK; if(!readable())return; - ts_scan_begin(&scan,view.trim_start,view.trim_end,view.frame_rate,levels[level],gaps[gap]); + if(keep_peak && peak_ready) + ts_scan_begin_peak(&scan,view.trim_start,view.trim_end,view.frame_rate,levels[level],gaps[gap],saved_peak); + else { + peak_ready=0; + ts_scan_begin(&scan,view.trim_start,view.trim_end,view.frame_rate,levels[level],gaps[gap]); + } } static void pull(void) { @@ -64,7 +163,9 @@ static void pull(void) if(why==MODWERK_SAMPLE_STALE){close_editor();return;} if(why!=MODWERK_SAMPLE_OK || !got_frames){scan.phase=2;scan.status=TS_FAILED;return;} ts_scan_feed(&scan,scan.cursor,block,got_frames); + remember_peak(); } + remember_peak(); } static void confirm_when_ready(void) { @@ -86,7 +187,7 @@ void ts_device_open(uint32_t opened,const struct modwerk_sample_editor_api *api) reader=(const struct modwerk_sample_editor_read_api *)api; if(!reader->read){unsupported=1;reader=0;return;} if(editor->copy(token,&view)!=MODWERK_SAMPLE_OK){close_editor();return;} - restart(); + restart(0); } const struct modwerk_sample_action ts_sample_action={ MODWERK_SAMPLE_EDITOR_ABI,MODWERK_SAMPLE_ACTION_BYTES,0x54534c31u,0,ts_device_open, @@ -110,7 +211,8 @@ static int adjust(int encoder,int delta) if(encoder==0)level=(uint32_t)step_value((int)level,delta,(int)(sizeof levels/sizeof levels[0])); else if(encoder==1)gap=(uint32_t)step_value((int)gap,delta,(int)(sizeof gaps/sizeof gaps[0])); else channel=(uint32_t)step_value((int)channel,delta,3); - restart();return 1; + if(encoder==2)peak_ready=0; + restart(encoder!=2);return 1; } int module_enc(int encoder,int delta) { @@ -138,6 +240,14 @@ int module_key(int code,int pressed) consumed[bank]&=~mask;return 1; } if(consumed[bank]&mask)return 1; + if(!active && code==PUSH_LEVEL && *(volatile uint32_t *)EDITOR_WINDOW && + *(volatile uint32_t *)EDITOR_PAGE<=2u) { + /* Both edges. The release is what arms follow-playback; leaving it + * for stock replaces the marker and the line leaves the view. */ + if(pressed){consumed[bank]|=mask;insert_at_cursor();} + else consumed[bank]&=~mask; + return 1; + } if(!active)return 0; if(code!=YES && code!=NO && code!=UP && code!=DOWN){close_editor();return 0;} consumed[bank]|=mask; diff --git a/sdk/drafts/threshold-slices/manifest.py b/sdk/drafts/threshold-slices/manifest.py index dc61c7b9f..84414e707 100644 --- a/sdk/drafts/threshold-slices/manifest.py +++ b/sdk/drafts/threshold-slices/manifest.py @@ -8,7 +8,7 @@ MODULE = Module( name="threshold-slices", key="THRESHOLD", kind=Kind.CF_PATCH, - doc="Slice a sample where the level rises through a threshold.", + doc="Slice a sample where the level rises through a threshold. The row sits under CREATE BEAT GRID.", category=Category.FIXES, author="jonathanwardle1992-beep", author_url="https://github.com/jonathanwardle1992-beep", proof=Proof.UNTESTED, diff --git a/sdk/drafts/threshold-slices/octamod.module.json b/sdk/drafts/threshold-slices/octamod.module.json index c8f3710cf..4377337a2 100644 --- a/sdk/drafts/threshold-slices/octamod.module.json +++ b/sdk/drafts/threshold-slices/octamod.module.json @@ -17,7 +17,7 @@ "label": "THRESHOLD", "family": "Sample workflow", "summary": "Slice a sample where the level rises through a threshold you set.", - "overview": "CREATE THRESHOLD SLICES sits on the audio editor SLICE menu, under DELETE ALL SLICES and above CREATE SLICE GRID. It finds rises in the trim and writes slice starts through the stock create or replace question.", + "overview": "CREATE THRESHOLD SLICES sits on the audio editor SLICE menu, under DELETE ALL SLICES and above CREATE SLICE GRID. With Beat Slicer installed it sits under CREATE BEAT GRID. It finds rises in the trim and writes slice starts through the stock create or replace question.", "highlights": [ "Threshold sets how strong a rise must be, relative to the loudest point in the trim.", "Gap is the shortest time between two slice starts.", diff --git a/sdk/drafts/threshold-slices/runtime.json b/sdk/drafts/threshold-slices/runtime.json index 48037b463..1dcf35196 100644 --- a/sdk/drafts/threshold-slices/runtime.json +++ b/sdk/drafts/threshold-slices/runtime.json @@ -1,6 +1,6 @@ { "schema": 1, "contribute": [ - { "to": "ot_sample_editor_actions", "data": "ts_sample_action", "order": 40 } + { "to": "ot_sample_editor_actions", "data": "ts_sample_action", "order": 60 } ] } diff --git a/sdk/drafts/threshold-slices/test_threshold.c b/sdk/drafts/threshold-slices/test_threshold.c index acce92b35..343c1f9c6 100644 --- a/sdk/drafts/threshold-slices/test_threshold.c +++ b/sdk/drafts/threshold-slices/test_threshold.c @@ -79,9 +79,27 @@ static void short_trim(void) ts_scan_begin(&scan,0,40,44100,40,50); CHECK(scan.status==TS_SHORT && ts_scan_feed(&scan,0,0,0)==1); } +/* A rectified tone: each half-cycle falls to zero. One burst is one slice. */ +static void tone_burst(void) +{ + enum { N=12000 }; + static int32_t peaks[N]; + struct ts_scan scan; + uint32_t i; + for(i=0;i=100u)pos-=100u; + peaks[i]=(int32_t)(pos*(100u-pos)*4u); + } + ts_scan_begin(&scan,0,N,44100,40,10); + feed_all(&scan,peaks,N); + CHECK(scan.status==TS_OK && scan.count==2); + CHECK(scan.starts[1]>3000u-200u && scan.starts[1]<3200u); +} int main(void) { - spikes();silence();too_many();within_limit();held();short_trim(); + spikes();silence();too_many();within_limit();held();short_trim();tone_burst(); if(fail)return 1; puts("PASS: threshold scan"); return 0; diff --git a/sdk/drafts/threshold-slices/threshold.c b/sdk/drafts/threshold-slices/threshold.c index 143e6b7d2..ae38032f4 100644 --- a/sdk/drafts/threshold-slices/threshold.c +++ b/sdk/drafts/threshold-slices/threshold.c @@ -7,7 +7,7 @@ void ts_scan_begin(struct ts_scan *s, uint32_t trim_start, uint32_t trim_end, s->trim_start=trim_start;s->trim_end=trim_end;s->rate=rate?rate:44100u; s->percent=percent;s->gap_ms=gap_ms;s->pad=s->rate*2u/1000u; s->minimum=s->level=s->peak=0;s->phase=0;s->cursor=trim_start;s->count=0; - s->status=TS_OK;s->armed=1;s->have_prev=0;s->previous=0; + s->status=TS_OK;s->armed=1;s->have_prev=0;s->previous=0;s->envelope=0; for(i=0;istarts[i]=0; if(trim_start>=trim_end || trim_end-trim_start<64u){s->phase=2;s->status=TS_SHORT;} } @@ -17,11 +17,19 @@ static void begin_hits(struct ts_scan *s) { uint32_t gap=s->gap_ms*(s->rate/1000u)+(s->gap_ms*(s->rate%1000u))/1000u; s->minimum=gap>64u?gap:64u; - s->level=(uint32_t)(((uint64_t)s->peak*s->percent)/100u); - s->cursor=s->trim_start;s->have_prev=0;s->armed=1;s->previous=0; + /* peak * percent / 100 without a 64-bit helper: the ColdFire package links no libgcc. */ + s->level=(s->peak/100u)*s->percent + (s->peak%100u)*s->percent/100u; + s->cursor=s->trim_start;s->have_prev=0;s->armed=1;s->previous=0;s->envelope=0; if(!s->peak || !s->level){finish_empty(s);return;} s->count=1;s->starts[0]=s->trim_start;s->phase=1; } +void ts_scan_begin_peak(struct ts_scan *s, uint32_t trim_start, uint32_t trim_end, + uint32_t rate, uint32_t percent, uint32_t gap_ms, uint32_t known_peak) +{ + ts_scan_begin(s,trim_start,trim_end,rate,percent,gap_ms); + if(s->phase>=2u)return; + s->peak=known_peak;begin_hits(s); +} static void add_hit(struct ts_scan *s, uint32_t frame) { uint32_t start=frame>s->pad?frame-s->pad:0u, previous=s->starts[s->count-1u]; @@ -52,11 +60,21 @@ unsigned ts_scan_feed(struct ts_scan *s, uint32_t at, const int32_t *peaks, uint uint32_t frame=s->cursor; if(!s->phase){if((uint32_t)sample>s->peak)s->peak=(uint32_t)sample;} else { - if(s->have_prev && s->armed && (uint32_t)s->previous<=s->level && (uint32_t)sample>s->level) + /* Instant attack, release of about 20 ms. A held tone stays up + * between its zero crossings, so it becomes one slice. */ + uint32_t mag=(uint32_t)sample,release=s->rate/50u; + if(!release)release=1u; + if(mag>=s->envelope)s->envelope=mag; + else { + uint32_t drop=s->envelope/release; + if(!drop)drop=1u; + s->envelope=s->envelope>drop?s->envelope-drop:0u; + } + if(s->have_prev && s->armed && (uint32_t)s->previous<=s->level && s->envelope>s->level) add_hit(s,frame); - if((uint32_t)sample<=s->level/2u)s->armed=1; - else if((uint32_t)sample>s->level)s->armed=0; - s->previous=sample;s->have_prev=1; + if(s->envelope<=s->level/2u)s->armed=1; + else if(s->envelope>s->level)s->armed=0; + s->previous=(int32_t)s->envelope;s->have_prev=1; } s->cursor=frame+1u; if(s->cursor>=s->trim_end) { diff --git a/sdk/drafts/threshold-slices/threshold.h b/sdk/drafts/threshold-slices/threshold.h index 613a00289..3c23b436e 100644 --- a/sdk/drafts/threshold-slices/threshold.h +++ b/sdk/drafts/threshold-slices/threshold.h @@ -5,17 +5,22 @@ #define TS_MAX 64u enum { TS_OK, TS_EMPTY, TS_TOO_MANY, TS_SHORT, TS_FAILED }; /* Two passes over absolute peaks, one frame each, from trim start. - * Pass one keeps the peak. Pass two places a start a fixed pad before each - * rise through peak * percent / 100, held off for the longer of 64 frames - * and the gap. The first start is the trim start. A 65th hit refuses. */ + * Pass one keeps the peak. Pass two follows that peak with a short release + * and places a start a fixed pad before each rise of the envelope through + * peak * percent / 100. It re-arms when the envelope drops, not when one + * sample does, and holds off for the longer of 64 frames and the gap. + * The first start is the trim start. A 65th hit refuses. */ struct ts_scan { uint32_t trim_start, trim_end, rate, percent, gap_ms, pad, minimum, level, peak; - uint32_t phase, cursor, count, status, armed, have_prev; + uint32_t phase, cursor, count, status, armed, have_prev, envelope; int32_t previous; uint32_t starts[TS_MAX]; }; void ts_scan_begin(struct ts_scan *, uint32_t trim_start, uint32_t trim_end, uint32_t rate, uint32_t percent, uint32_t gap_ms); +/* Same scan, starting at pass two with a peak already measured for this channel. */ +void ts_scan_begin_peak(struct ts_scan *, uint32_t trim_start, uint32_t trim_end, + uint32_t rate, uint32_t percent, uint32_t gap_ms, uint32_t known_peak); /* peaks[i] is frame `at + i`. `at` must be the cursor. Returns 1 when the * scan has finished. A zero count does nothing until the cursor is at the end. */ unsigned ts_scan_feed(struct ts_scan *, uint32_t at, const int32_t *peaks, uint32_t count); diff --git a/sdk/drafts/threshold-slices/verify.py b/sdk/drafts/threshold-slices/verify.py new file mode 100644 index 000000000..a081ad84a --- /dev/null +++ b/sdk/drafts/threshold-slices/verify.py @@ -0,0 +1,10 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: MIT +"""Threshold Slices host scan. The ColdFire package is a private trial, not a published one.""" +import subprocess +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[3] +subprocess.run([sys.executable, '-B', '-m', 'unittest', 'sdk.tests.test_threshold_slices'], check=True, cwd=ROOT) +print('[PASS] threshold-slices host scan') From 4a146add9ed4966446a457f5c25b9a7741aacdd5 Mon Sep 17 00:00:00 2001 From: jonathanwardle1992-beep Date: Sun, 8 Nov 2026 17:49:53 +0000 Subject: [PATCH 3/7] Threshold Slices: press D on the waveform to remove the slice under the marker. --- sdk/drafts/threshold-slices/CHANGELOG.json | 2 +- sdk/drafts/threshold-slices/README.md | 1 + sdk/drafts/threshold-slices/TESTING.md | 2 +- sdk/drafts/threshold-slices/device_module.c | 79 +++++++++++++++++---- 4 files changed, 68 insertions(+), 16 deletions(-) diff --git a/sdk/drafts/threshold-slices/CHANGELOG.json b/sdk/drafts/threshold-slices/CHANGELOG.json index c2fd8eec2..fb0e25daf 100644 --- a/sdk/drafts/threshold-slices/CHANGELOG.json +++ b/sdk/drafts/threshold-slices/CHANGELOG.json @@ -6,7 +6,7 @@ "AED SLICE gains CREATE THRESHOLD SLICES, under DELETE ALL SLICES. With Beat Slicer it sits under CREATE BEAT GRID.", "Turn the threshold and the gap. A held note stays one slice, a short moment before the hit.", "YES asks the usual CREATE SLICES or REPLACE SLICES question. NO leaves the slices alone.", - "Press LEVEL on the waveform to add one slice at the marker. The marker stays, and the slices already there stay.", + "Press LEVEL on the waveform to add one slice at the marker, or D to remove only the slice under it. The marker stays, and the other slices stay.", "Known issue: needs a base that can read the waveform. A long sample still takes a while to scan." ] } diff --git a/sdk/drafts/threshold-slices/README.md b/sdk/drafts/threshold-slices/README.md index a53b97422..64973caca 100644 --- a/sdk/drafts/threshold-slices/README.md +++ b/sdk/drafts/threshold-slices/README.md @@ -18,6 +18,7 @@ Select the sample, open AED, press AMP for SLICE, then YES, and choose CREATE TH | Encoder B | Minimum gap: 10, 20, 50, 100, 200 or 400 ms. | | Encoder C | L+R uses the louder side. LEFT and RIGHT use that side only. | | LEVEL, pressed | With the waveform open on TRIM, SLICE or EDIT, adds one slice at the marker and leaves the marker on that point. The slices already there stay. The press does nothing while the editor is closed. | +| Encoder D, pressed | With the waveform open on TRIM, SLICE or EDIT, removes only the slice that contains the marker. The others keep their starts, ends and loops, and the marker stays on that point. Nothing happens when the marker is outside every slice, or when fewer than two slices exist. | | YES | When the scan has finished, opens the stock CREATE SLICES? or REPLACE SLICES? question. Markers change only after that second YES. | | NO | Close the popup. Nothing is written. | diff --git a/sdk/drafts/threshold-slices/TESTING.md b/sdk/drafts/threshold-slices/TESTING.md index c897605b9..2c6d4feeb 100644 --- a/sdk/drafts/threshold-slices/TESTING.md +++ b/sdk/drafts/threshold-slices/TESTING.md @@ -10,7 +10,7 @@ Draft written against `eaaf60caf0d050cc158e2b281207a913183e813b`. No device tria Private ColdFire package `C:\Users\Roy Walker\modwerk-builds\threshold-slices.mwrm`, SHA-256 `a020a947cfdad3ad42b3e5566ec78e88a1e769942926f20fd897e6e9b145d3e7`, subscribed at order 60. Not committed. -On the unit, 2026-11-07, OCTATRACK 1.40C, base `671f0afd406290cb`: `device try` of that package for 12 seconds, then rolled back (not kept). While it ran, the slot was running, refusals stayed 0, and the tick value stayed `0x54530000` (the editor was not opened). About 60 ticks a second. After rollback the unit was normal, generation 2, the same base, and no slot running. The menu, LEVEL insert, scan time and CPU were not tried. +On the unit, 2026-11-07, OCTATRACK 1.40C, base `671f0afd406290cb`: `device try` of that package for 12 seconds, then rolled back (not kept). While it ran, the slot was running, refusals stayed 0, and the tick value stayed `0x54530000` (the editor was not opened). About 60 ticks a second. After rollback the unit was normal, generation 2, the same base, and no slot running. The menu, LEVEL insert, D removal, scan time and CPU were not tried. ## Hardware and audio quality diff --git a/sdk/drafts/threshold-slices/device_module.c b/sdk/drafts/threshold-slices/device_module.c index 9a351cc34..a3e05ea9c 100644 --- a/sdk/drafts/threshold-slices/device_module.c +++ b/sdk/drafts/threshold-slices/device_module.c @@ -32,6 +32,7 @@ static uint32_t consumed[2]; * the zoomed view. This module takes both edges while TRIM, SLICE or EDIT * is open, and writes the saved marker back after the slice is published. */ #define PUSH_LEVEL 0x3e +#define PUSH_D 0x3b #define EDITOR_WINDOW 0x460e5e4cu #define EDITOR_PAGE 0x460e5e5cu #define EDITOR_KIND 0x46c8d1a0u @@ -65,6 +66,23 @@ static uint32_t settings_address(void) if((kind==1u || kind==4u) && slot<136u)return FLEX_SETTINGS+slot*1096u; return 0; } +static int waveform_open(void) +{ + return *(volatile uint32_t *)EDITOR_WINDOW && *(volatile uint32_t *)EDITOR_PAGE<=2u; +} +/* The knob's own redraw. The saved marker is written back after the slice + * table changes, so follow-playback cannot walk the line off the point. */ +static void show_marker(uint32_t page,uint32_t cell,uint32_t cursor) +{ + write_u32(cell,cursor); + *(volatile uint32_t *)follow_flag[page]=0; + ((void (*)(void))SLICE_MARKERS)(); + write_u32(cell,cursor); + ((void (*)(uint32_t,uint32_t))VIEW_REFRESH)(cursor_view[page],cursor_ptr[page]); + *(volatile uint32_t *)follow_hold[page]=20u; + ((void (*)(void))EDITOR_REDRAW)(); + write_u32(cell,cursor); +} /* Stock ADD SLICE HERE (0x400730f0) keeps every slice and inserts one at the * marker. The same insert uses whichever waveform page is open. The marker * is read before any write. Nothing here builds the threshold grid. */ @@ -102,14 +120,47 @@ static void insert_at_cursor(void) *(volatile uint32_t *)(settings+320u+12u*at)=0xffffffffu; *(volatile uint32_t *)(settings+1092u)=count+1u; __asm__ __volatile__("move.w %0,%%sr" :: "d"(sr) : "memory"); - write_u32(cell,cursor); - *(volatile uint32_t *)follow_flag[page]=0; - ((void (*)(void))SLICE_MARKERS)(); - write_u32(cell,cursor); - ((void (*)(uint32_t,uint32_t))VIEW_REFRESH)(cursor_view[page],cursor_ptr[page]); - *(volatile uint32_t *)follow_hold[page]=20u; - ((void (*)(void))EDITOR_REDRAW)(); - write_u32(cell,cursor); + show_marker(page,cell,cursor); +} +/* Stock DELETE SLICE (0x40073064) sets the chosen start to 0x7fffffff, sorts + * the 12-byte records by start, and drops the count by one. End and loop + * travel with their start, so every slice that remains keeps them. The later + * records slide down and the hole in the list closes. This does the same + * shift for the one slice that contains the marker. */ +static void remove_at_cursor(void) +{ + uint32_t page=*(volatile uint32_t *)EDITOR_PAGE,cell,cursor,settings,count,i,which,found,sr; + if(page>2u)return; + cell=*(volatile uint32_t *)cursor_ptr[page]; + if(!cell)return; + cursor=read_u32(cell); + settings=settings_address(); + if(!settings)return; + count=*(volatile uint32_t *)(settings+1092u); + if(count<2u || count>64u)return; + found=0; + which=0; + for(i=0;i Date: Sun, 8 Nov 2026 17:50:31 +0000 Subject: [PATCH 4/7] Put the sample-editor waveform read back to main so this pull request stays the draft. --- docs/module-guides/system.md | 1 - sdk/machines/octatrack/HOOKS.md | 4 +- sdk/machines/octatrack/SAMPLE_EDITOR.md | 4 +- sdk/machines/octatrack/events.json | 6 +-- .../octatrack/include/modwerk_events.h | 4 +- .../octatrack/include/modwerk_sample_editor.h | 17 +-------- .../module-adapters/sample_editor_board.c | 38 +------------------ .../module-adapters/sample_editor_board.h | 1 - .../module-adapters/sample_editor_menu.c | 4 +- .../module-adapters/sample_editor_service.c | 36 ------------------ .../module-adapters/sample_editor_service.h | 8 ---- .../test_sample_editor_board.c | 19 ---------- .../module-adapters/test_sample_editor_menu.c | 4 +- .../test_sample_editor_service.c | 26 +------------ 14 files changed, 16 insertions(+), 156 deletions(-) diff --git a/docs/module-guides/system.md b/docs/module-guides/system.md index 21a3e86c3..b5ed95f26 100644 --- a/docs/module-guides/system.md +++ b/docs/module-guides/system.md @@ -29,7 +29,6 @@ interpreting edit timing; trig masks and advancing DSP frames do not prove audio - Gate recording by the source being edited. A playback track and its recorder are independent; recording into another buffer must not automatically block editing an unrelated file or inactive buffer. Verify the actual source ownership and writer boundary. - A queue being empty, a dirty flag or an unchanged slot counter does not establish ownership. Cover complete writer lifetimes, nested calls, failed replacements, recorder/allocator work and delayed completion before admitting access. - Copy views for the UI. Validate on opening and explicit intent rather than polling live metadata every display tick; retain a busy confirmation intent only while its source and owner remain valid, and cancel it on Back or changed controls. -- An edit that needs the waveform reads absolute peaks through the base's bounded read, a few frames at a time on the UI task, and proposes starts only. The base still owns confirmation and the marker write. No sample pointer is kept. - Use the machine's metadata-writer boundary while preserving frame/DMA deadlines. A loader lock is not proof of exclusion against PCM or recorder writers. Refuse heavy work from unsuitable interrupt contexts. - A final state check still races with a higher-priority recorder unless the commit is ordered against it. Keep table copies and whole-table comparisons outside any short commit guard, and measure its duration on the unit. - Establish native menu-pointer lifetime before contributing rows. Copied labels, callbacks and selection storage can outlive a closed menu; retire all references before reclaiming module memory. Check typed descriptor alignment on the target, not only the host. diff --git a/sdk/machines/octatrack/HOOKS.md b/sdk/machines/octatrack/HOOKS.md index 6b9f402a9..8802a1344 100644 --- a/sdk/machines/octatrack/HOOKS.md +++ b/sdk/machines/octatrack/HOOKS.md @@ -50,12 +50,12 @@ next base build, which renames them; the base loads them by those paths. | `ot_machines` | table | none | the machine chooser, SRC SETUP, the page resolver, the Part validator, the UI tick, pool navigation and the FLEX renderer ask the contributed machines, in row order: chooser rows 5 on | `80-byte record` | Contribute one struct modwerk_machine (every field but abi, sig and label optional, 0). A Part keeps its signature when the machine is absent and reads as stock. Leaving a PROTECT machine that has no assign of its own puts stock's FLEX defaults back in both Part copies before the signature goes. A priced machine (source_kind) is charged on its track's core before the track becomes it, and every owned track at each UI tick; a track the core cannot carry renders stock's silent source. Removal is refused while a Part, a UI root or a paused task still holds the machine. A SETUP_PAGE machine contributes a struct modwerk_machine_setup (88 bytes): while SRC SETUP's cursor is on its row and the current track is its own, the drawer shows the page its page function returns and encoders A-F go to its knob function before stock edits the backing pool's setup; a base without the flag ignores it and reads 80 bytes, so the machine keeps stock's setup there. A NATIVE_FLEX machine must contribute START and its own render callback. It keeps its native FLEX voice active while the base clears its retrig count; refused STARTs, unpaid owners and missing machines still stop it. Translate and restore custom controls around native START and render calls. Use a matching base: older bases ignore this flag and stop the voice. | lower first, then module id: the chooser's row order. 10 POLY8, 20 Analog BD, 30 VECTOR, 40 FM Synth (the order of POLY8's shared controller); others 50-90. | the UI task and the audio render (render only): short, no firmware call that can block | | `ot_sequencer_rows` | table | none | PROJECT > CONTROL > SEQUENCER lists stock's three rows, then one per contributed label | `4-byte record` | Contribute one struct modwerk_sequencer_rows: its rows, in order. The window shows stock's three rows and up to eight more; a row with a label an earlier row has is not shown; the getter and setter follow stock's rows' convention (CHAIN AFTER's 0x400659ec). | lower first, then module id: the rows' order. 40 FM Synth's bundled quantizer, 50 Scale Quantizer. | the UI task | | `ev_tail_kill` | call | 0x40004d0c | STOP's tail kill (STOP, STOP, STOP) holds a track: each frame, for each held track, before stock tests the track's FX2 against its three reverbs and clears theirs | `void f(uint8_t *record, unsigned track)` | A notification: every handler runs, then stock's own test. record is the track's 64-byte frame record its DSP instances read (32 halfwords): the halfword at +56 is the FX2 id stock tests, the byte at +53 FX2 SETUP's slot 11, which stock zeroes for its reverbs while the kill holds. A handler may zero its own effect's bytes there the same way (check +56 first). d2-d7/a2-a6 are the builder's and must be kept. | lower first, then module id; 10-90. | the frame interrupt: short, no firmware call that can block | -| `ot_midi_generators` | table | none | MIDI NOTE SETUP (double-tap SRC, or double-tap TRACK in MIDI mode) lists OFF and the contributed generators in its empty left half; a MIDI track running one shows that generator's pages on the MIDI pages it takes (always ARP; FUNC+AMP: the ARP page's second) | `36-byte record` | Contribute one struct modwerk_midi_generator (every field but abi, label and id optional, 0). Choosing it puts 'M' and its id in the track's arp step mask (Part 0x1822 + 2 t, high byte first), which stock's validator never reads and only ARP SETUP edits; stock's default there is ffff. On every change of generator the base first puts the track's arpeggiator back to stock's defaults (MODE OFF, every note 0), then calls the old generator's assign(t, 0) and the new one's assign(t, 1). While a generator runs a track it owns that track's 16 arpeggiator note bytes (Part 0x1832 + 16 t, each -64..63, the range stock's validator keeps), so the ARP page is always its own and FUNC+AMP never opens ARP SETUP there. Its pages show while no trig or scene is held (stock's then, so locks stay stock's); the base takes encoders A-F, YES and, on the second ARP page, NO there. Values behind the pages it takes stay stock's and keep their meaning (a NOTE page's NOT2-4 still add chord notes): set or show them itself. Layout A (owner, 24 October 2026): generators take SRC and AMP; FX1 and FX2 are kept for MIDI effects. A track whose id has no installed generator reads as stock. | lower first, then module id: the list's order. 30 for the first generator; others 40-90. | the UI task | +| `ot_midi_generators` | table | none | MIDI NOTE SETUP (double-tap SRC, or double-tap TRACK in MIDI mode) lists ARP (stock's arpeggiator: no generator) and the contributed generators in its empty left half, five rows at a time (UP/DOWN scroll); a MIDI track running one shows that generator's pages on the MIDI pages it takes (always ARP; FUNC+AMP: the ARP page's second) | `36-byte record` | Contribute one struct modwerk_midi_generator (every field but abi, label and id optional, 0). Choosing it puts 'M' and its id in the track's arp step mask (Part 0x1822 + 2 t, high byte first), which stock's validator never reads and only ARP SETUP edits; stock's default there is ffff. On every change of generator the base first puts the track's arpeggiator back to stock's defaults (MODE OFF, every note 0), then calls the old generator's assign(t, 0) and the new one's assign(t, 1). While a generator runs a track it owns that track's 16 arpeggiator note bytes (Part 0x1832 + 16 t, each -64..63, the range stock's validator keeps), so the ARP page is always its own and FUNC+AMP never opens ARP SETUP there. Its pages show while no trig or scene is held (stock's then, so locks stay stock's); the base takes encoders A-F, YES and, on the second ARP page, NO there. Values behind the pages it takes stay stock's and keep their meaning (a NOTE page's NOT2-4 still add chord notes): set or show them itself. Layout A (owner, 24 October 2026): generators take SRC and AMP; FX1 and FX2 are kept for MIDI effects. A track whose id has no installed generator reads as stock. | lower first, then module id: the list's order. 30 for the first generator; others 40-90. | the UI task | | `ev_midi_note_key` | take | 0x4009fb58 | a MIDI track's note, from its trig, chord or arpeggiator, is about to be corrected to the track's KEY (ARP SETUP) and sent (d7 the track) | `struct modwerk_take, jmp entry, claims(d7)` | The taker's entry gets every register and the stack as at the site: d7 the track, a2 the note byte (it may rewrite it, 0-127, or store -1 to drop the note), -64(%fp) stock's KEY root offset. It returns to stock by jumping to 0x4009fb80 to send the note as it is now, or by replaying the stock tail below (stock's KEY correction); it must not jump into the site's bytes. Stock keeps the note it sends for that note's note-off. | 10-29: a machine claiming its own tracks (claims set); 30-49: a machine's bundled feature; 50-69: a standalone feature module. The first record that claims the call takes it. | the MIDI sequencer tick 0x400a1e0c (an interrupt, vector 0x60; octabam docs/firmware/MIDI_SCENES.md section 5), once per note: a few microseconds, no firmware call, nothing that waits or allocates | | `ev_arp_setup_knob` | take | 0x4007a2ec | an encoder turns on MIDI ARP SETUP (FUNC+AMP on a MIDI track), before stock edits LEN, KEY or an arp note | `struct modwerk_take, jmp entry, claims(nothing)` | The taker's entry gets every register and the stack as at the site: (%sp) the return address, 4(%sp) the encoder (2 LEN, 5 KEY, 6 the arp note editor), 8(%sp) the delta. It takes the turn by returning to the caller with rts (stock edits nothing), or hands it to stock by replaying the stock tail below; it must not jump into the site's bytes. A taker that stores into the pattern or the Part writes the bank and its battery-backed copy and sets the edited flags as stock's writers do. | 10-29: a machine claiming its own tracks (claims set); 30-49: a machine's bundled feature; 50-69: a standalone feature module. The first record that claims the call takes it. | the UI task (ARP SETUP's input layer): short, no firmware call that can block | | `ev_arp_setup_text` | take | 0x4007a1ec | MIDI ARP SETUP is about to draw a setup value (d3 the slot: 2 LEN, 5 KEY) | `struct modwerk_take, jmp entry, claims(d3)` | The taker's entry gets every register and the stack as at the site: d3 the slot, d4 the track's value, a3 the slot's descriptor (a3@ the text function f(buffer, value), a3@(48) the drawer or 0 for 0x400479b4), a2 and d2 the place, d6 the surface. It draws the value itself, normally by calling the slot's drawer as stock does with a text function of its own, and goes on at 0x4007a216 with every register but d0-d1/a0-a1 as it found them; or it hands the value to stock by replaying the stock tail below. It must not jump into the site's bytes. | 10-29: a machine claiming its own tracks (claims set); 30-49: a machine's bundled feature; 50-69: a standalone feature module. The first record that claims the call takes it. | the UI task (the drawer): short, no firmware call that can block | | `ev_trig_muted` | call | none: a module calls the base's `modwerk_raise_ev_trig_muted` through a word the loader fills | a machine that starts a note from a trig itself, before stock binds the track's voice (ev_voice_bind), asks which tracks' new trigs a module drops now; no stock site: the machine calls the base's modwerk_raise_ev_trig_muted(), which returns every handler's answer OR'd, 0 with none | `unsigned f(void)` | Return the tracks whose new trigs this module drops now: bit t for track t+1, bits 0-7; higher bits are ignored. Every handler runs. They are the tracks whose ev_voice_bind this module drops (1) in the same frame: muted or soloed-out tracks while one of its trig-dropping modes is on, less those whose trigs it lets through (ev_mute_keep). The asking machine starts no new note on these tracks and lets a note already sounding finish; with no answer (0) it plays every trig, as stock does. d2-d7/a2-a6 must be kept. | lower first, then module id; every handler runs and the answers are OR'd, so the order does not change the answer. 10-90. | where the machine starts its note: the frame interrupt (the renderer the packer calls at 0x4000d43e) or a task: short, no firmware call that can block | -| `ot_sample_editor_actions` | table | none | the shared AED SLICE action menu lists admitted sample-editor contributions; selection opens the package with a fresh base-owned source token | `64-byte record` | Contribute one 64-byte struct modwerk_sample_action, ABI 1, flags/reserved zero and inline printable label. A nonzero action key is globally unique. The base validates the whole initialized record and entry against its exact published owner, copies its label into resident menus, and retains no module pointer in stock UI. Source capture, stock confirmation and marker publication belong to the base; owner/source/writer changes retire or refuse access before mutation. Actual waveform-read dispatch remains blocked until idle and complete metadata revalidation. Flex and Static file slots and inactive recorder buffers are supported during pattern playback. Recording blocks only the recorder buffer being edited, not unrelated files or buffers. A bounded read copies absolute peaks into caller memory and no sample pointer crosses. No automatic sample/project save or slice Undo. Older bases refuse the longer hook list. | lower first, then module id: 50-90 for independent editor actions. | the UI task; entry is nonblocking and receives copied settings through the base API | +| `ot_sample_editor_actions` | table | none | the shared AED SLICE action menu lists admitted sample-editor contributions; selection opens the package with a fresh base-owned source token | `64-byte record` | Contribute one 64-byte struct modwerk_sample_action, ABI 1, flags/reserved zero and inline printable label. A nonzero action key is globally unique. The base validates the whole initialized record and entry against its exact published owner, copies its label into resident menus, and retains no module pointer in stock UI. Source capture, stock confirmation and marker publication belong to the base; owner/source/writer changes retire or refuse access before mutation. Actual waveform-read dispatch remains blocked until idle and complete metadata revalidation. Flex and Static file slots and inactive recorder buffers are supported during pattern playback. Recording blocks only the recorder buffer being edited, not unrelated files or buffers. No PCM access, automatic sample/project save or slice Undo. Older bases refuse the longer hook list. | lower first, then module id: 50-90 for independent editor actions. | the UI task; entry is nonblocking and receives copied settings through the base API | | `ot_machine_starts` | table | none | the source START handler is selected in the frame interrupt, before native arguments and the optional trig gate | `16-byte record` | Requires a base advertising ot_machine_starts. ABI 1 machine descriptors keep their 80-byte extent. A machine with MODWERK_MACHINE_START must contribute exactly one version-1 record naming its descriptor; malformed, absent, duplicate or DSP-unpaid ownership refuses START and silences its previous voice until an accepted START. All legacy machines and unowned tracks retain the selected stock/module fallback, including FM Synth. Absent known PROTECT machines never receive stock START with custom controls. Tables publish masked, and the frame interrupt plus existing paused-context retention holds descriptors, callbacks and the selected fallback through the call. This is not a task-call lease. Module removal follows the existing machine/Part/UI and paused-stack rules. No changes to FM Synth or its FLEX START table cell. | lower first, then module id; records match descriptor identity, duplicate ownership is refused. | frame interrupt only: bounded, no blocking, preserve d2-d7/a2-a6 using the C ABI | ## Reviewed subscribers diff --git a/sdk/machines/octatrack/SAMPLE_EDITOR.md b/sdk/machines/octatrack/SAMPLE_EDITOR.md index 6d7fffbe6..200775626 100644 --- a/sdk/machines/octatrack/SAMPLE_EDITOR.md +++ b/sdk/machines/octatrack/SAMPLE_EDITOR.md @@ -41,9 +41,7 @@ The target descriptor test reads the registry index instead of assuming 32. and row writes preserve frame/DMA interrupts. The 0.1.65 candidate briefly excludes level 5 only for a recorder's final state/controls/trim check and count store, with DMA still enabled; physical timing remains unqualified. - Playback processing and implicit saves remain out of scope. A module may - copy absolute peaks through the optional read tail, in short steps, and - propose slice starts. No sample pointer crosses the API. + PCM analysis, playback processing and implicit saves remain out of scope. - `sample_editor_menu.c` and `sample_editor_hooks.s`: native menu/selection and waveform-worker boundaries, built with `--sample-editor`. The beta base sets it; a base built without it leaves the stock editor sites alone and refuses diff --git a/sdk/machines/octatrack/events.json b/sdk/machines/octatrack/events.json index 882e9933b..ec757f997 100644 --- a/sdk/machines/octatrack/events.json +++ b/sdk/machines/octatrack/events.json @@ -1675,9 +1675,9 @@ "length": 0, "record": 36, "key": null, - "room": 4, + "room": 16, "declaration": "struct modwerk_midi_generator {\n uint32_t abi; /* MODWERK_MIDI_GENERATOR_ABI */\n const char *label; /* its row in MIDI NOTE SETUP's list: up to 8 characters */\n uint32_t id; /* 1-255, its own: a MIDI track running it keeps 'M' and it in its arp step mask */\n uint32_t pages; /* the MIDI pages it draws, bit k for page k: 0 NOTE (SRC), 1 LFO, 2 ARP (AMP, always), 3 CTRL1 (FX1), 4 CTRL2 (FX2) */\n void (*assign)(unsigned track, unsigned on); /* MIDI track 0-7 of the current Part becomes it (1) or leaves it (0) */\n uint32_t (*page)(uint32_t stock, unsigned page, unsigned second); /* the descriptor to draw on page 0-4 instead of stock's; second: FUNC+AMP's second ARP page */\n void (*knob)(unsigned page, unsigned index, int delta, unsigned second); /* encoder A-F (0-5) turned on that page */\n void (*yes)(unsigned page); /* YES on that page */\n void (*ui_tick)(void);\n};\n#define MODWERK_MIDI_GENERATOR_ABI 2u", - "when": "MIDI NOTE SETUP (double-tap SRC, or double-tap TRACK in MIDI mode) lists OFF and the contributed generators in its empty left half; a MIDI track running one shows that generator's pages on the MIDI pages it takes (always ARP; FUNC+AMP: the ARP page's second)", + "when": "MIDI NOTE SETUP (double-tap SRC, or double-tap TRACK in MIDI mode) lists ARP (stock's arpeggiator: no generator) and the contributed generators in its empty left half, five rows at a time (UP/DOWN scroll); a MIDI track running one shows that generator's pages on the MIDI pages it takes (always ARP; FUNC+AMP: the ARP page's second)", "context": "the UI task", "contract": "Contribute one struct modwerk_midi_generator (every field but abi, label and id optional, 0). Choosing it puts 'M' and its id in the track's arp step mask (Part 0x1822 + 2 t, high byte first), which stock's validator never reads and only ARP SETUP edits; stock's default there is ffff. On every change of generator the base first puts the track's arpeggiator back to stock's defaults (MODE OFF, every note 0), then calls the old generator's assign(t, 0) and the new one's assign(t, 1). While a generator runs a track it owns that track's 16 arpeggiator note bytes (Part 0x1832 + 16 t, each -64..63, the range stock's validator keeps), so the ARP page is always its own and FUNC+AMP never opens ARP SETUP there. Its pages show while no trig or scene is held (stock's then, so locks stay stock's); the base takes encoders A-F, YES and, on the second ARP page, NO there. Values behind the pages it takes stay stock's and keep their meaning (a NOTE page's NOT2-4 still add chord notes): set or show them itself. Layout A (owner, 24 October 2026): generators take SRC and AMP; FX1 and FX2 are kept for MIDI effects. A track whose id has no installed generator reads as stock.", "order": "lower first, then module id: the list's order. 30 for the first generator; others 40-90.", @@ -1793,7 +1793,7 @@ "declaration": "#include \"modwerk_sample_editor.h\"", "when": "the shared AED SLICE action menu lists admitted sample-editor contributions; selection opens the package with a fresh base-owned source token", "context": "the UI task; entry is nonblocking and receives copied settings through the base API", - "contract": "Contribute one 64-byte struct modwerk_sample_action, ABI 1, flags/reserved zero and inline printable label. A nonzero action key is globally unique. The base validates the whole initialized record and entry against its exact published owner, copies its label into resident menus, and retains no module pointer in stock UI. Source capture, stock confirmation and marker publication belong to the base; owner/source/writer changes retire or refuse access before mutation. Actual waveform-read dispatch remains blocked until idle and complete metadata revalidation. Flex and Static file slots and inactive recorder buffers are supported during pattern playback. Recording blocks only the recorder buffer being edited, not unrelated files or buffers. A bounded read copies absolute peaks into caller memory and no sample pointer crosses. No automatic sample/project save or slice Undo. Older bases refuse the longer hook list.", + "contract": "Contribute one 64-byte struct modwerk_sample_action, ABI 1, flags/reserved zero and inline printable label. A nonzero action key is globally unique. The base validates the whole initialized record and entry against its exact published owner, copies its label into resident menus, and retains no module pointer in stock UI. Source capture, stock confirmation and marker publication belong to the base; owner/source/writer changes retire or refuse access before mutation. Actual waveform-read dispatch remains blocked until idle and complete metadata revalidation. Flex and Static file slots and inactive recorder buffers are supported during pattern playback. Recording blocks only the recorder buffer being edited, not unrelated files or buffers. No PCM access, automatic sample/project save or slice Undo. Older bases refuse the longer hook list.", "order": "lower first, then module id: 50-90 for independent editor actions.", "subscribers": [], "orderEvidence": "The beta base carries the service (--sample-editor). No released module contributes yet; Beat Slicer's CREATE BEAT GRID is the first.", diff --git a/sdk/machines/octatrack/include/modwerk_events.h b/sdk/machines/octatrack/include/modwerk_events.h index 07f79a6fe..80db7e1a3 100644 --- a/sdk/machines/octatrack/include/modwerk_events.h +++ b/sdk/machines/octatrack/include/modwerk_events.h @@ -150,7 +150,7 @@ struct modwerk_sequencer_rows { uint32_t count; struct modwerk_sequencer_row row #define MODWERK_EV_TAIL_KILL 30 typedef void modwerk_ev_tail_kill(uint8_t *record, unsigned track); -/* ot_midi_generators (table, contribute {"to": "ot_midi_generators", "order", "data"}): MIDI NOTE SETUP (double-tap SRC, or double-tap TRACK in MIDI mode) lists OFF and the contributed generators in its empty left half; a MIDI track running one shows that generator's pages on the MIDI pages it takes (always ARP; FUNC+AMP: the ARP page's second). The UI task. Contribute one struct modwerk_midi_generator (every field but abi, label and id optional, 0). Choosing it puts 'M' and its id in the track's arp step mask (Part 0x1822 + 2 t, high byte first), which stock's validator never reads and only ARP SETUP edits; stock's default there is ffff. On every change of generator the base first puts the track's arpeggiator back to stock's defaults (MODE OFF, every note 0), then calls the old generator's assign(t, 0) and the new one's assign(t, 1). While a generator runs a track it owns that track's 16 arpeggiator note bytes (Part 0x1832 + 16 t, each -64..63, the range stock's validator keeps), so the ARP page is always its own and FUNC+AMP never opens ARP SETUP there. Its pages show while no trig or scene is held (stock's then, so locks stay stock's); the base takes encoders A-F, YES and, on the second ARP page, NO there. Values behind the pages it takes stay stock's and keep their meaning (a NOTE page's NOT2-4 still add chord notes): set or show them itself. Layout A (owner, 24 October 2026): generators take SRC and AMP; FX1 and FX2 are kept for MIDI effects. A track whose id has no installed generator reads as stock. */ +/* ot_midi_generators (table, contribute {"to": "ot_midi_generators", "order", "data"}): MIDI NOTE SETUP (double-tap SRC, or double-tap TRACK in MIDI mode) lists ARP (stock's arpeggiator: no generator) and the contributed generators in its empty left half, five rows at a time (UP/DOWN scroll); a MIDI track running one shows that generator's pages on the MIDI pages it takes (always ARP; FUNC+AMP: the ARP page's second). The UI task. Contribute one struct modwerk_midi_generator (every field but abi, label and id optional, 0). Choosing it puts 'M' and its id in the track's arp step mask (Part 0x1822 + 2 t, high byte first), which stock's validator never reads and only ARP SETUP edits; stock's default there is ffff. On every change of generator the base first puts the track's arpeggiator back to stock's defaults (MODE OFF, every note 0), then calls the old generator's assign(t, 0) and the new one's assign(t, 1). While a generator runs a track it owns that track's 16 arpeggiator note bytes (Part 0x1832 + 16 t, each -64..63, the range stock's validator keeps), so the ARP page is always its own and FUNC+AMP never opens ARP SETUP there. Its pages show while no trig or scene is held (stock's then, so locks stay stock's); the base takes encoders A-F, YES and, on the second ARP page, NO there. Values behind the pages it takes stay stock's and keep their meaning (a NOTE page's NOT2-4 still add chord notes): set or show them itself. Layout A (owner, 24 October 2026): generators take SRC and AMP; FX1 and FX2 are kept for MIDI effects. A track whose id has no installed generator reads as stock. */ #define MODWERK_OT_MIDI_GENERATORS 31 struct modwerk_midi_generator { uint32_t abi; /* MODWERK_MIDI_GENERATOR_ABI */ @@ -179,7 +179,7 @@ struct modwerk_midi_generator { typedef unsigned modwerk_ev_trig_muted(void); /* Raise it: define `modwerk_ev_trig_muted *raise_ev_trig_muted` as a zero word in your image (initialized data, not bss), list "ev_trig_muted" under "raise" in runtime.json and call through it; every handler's answer, OR'd. */ -/* ot_sample_editor_actions (table, contribute {"to": "ot_sample_editor_actions", "order", "data"}): the shared AED SLICE action menu lists admitted sample-editor contributions; selection opens the package with a fresh base-owned source token. The UI task; entry is nonblocking and receives copied settings through the base API. Contribute one 64-byte struct modwerk_sample_action, ABI 1, flags/reserved zero and inline printable label. A nonzero action key is globally unique. The base validates the whole initialized record and entry against its exact published owner, copies its label into resident menus, and retains no module pointer in stock UI. Source capture, stock confirmation and marker publication belong to the base; owner/source/writer changes retire or refuse access before mutation. Actual waveform-read dispatch remains blocked until idle and complete metadata revalidation. Flex and Static file slots and inactive recorder buffers are supported during pattern playback. Recording blocks only the recorder buffer being edited, not unrelated files or buffers. A bounded read copies absolute peaks into caller memory and no sample pointer crosses. No automatic sample/project save or slice Undo. Older bases refuse the longer hook list. */ +/* ot_sample_editor_actions (table, contribute {"to": "ot_sample_editor_actions", "order", "data"}): the shared AED SLICE action menu lists admitted sample-editor contributions; selection opens the package with a fresh base-owned source token. The UI task; entry is nonblocking and receives copied settings through the base API. Contribute one 64-byte struct modwerk_sample_action, ABI 1, flags/reserved zero and inline printable label. A nonzero action key is globally unique. The base validates the whole initialized record and entry against its exact published owner, copies its label into resident menus, and retains no module pointer in stock UI. Source capture, stock confirmation and marker publication belong to the base; owner/source/writer changes retire or refuse access before mutation. Actual waveform-read dispatch remains blocked until idle and complete metadata revalidation. Flex and Static file slots and inactive recorder buffers are supported during pattern playback. Recording blocks only the recorder buffer being edited, not unrelated files or buffers. No PCM access, automatic sample/project save or slice Undo. Older bases refuse the longer hook list. */ #define MODWERK_OT_SAMPLE_EDITOR_ACTIONS 36 #include "modwerk_sample_editor.h" diff --git a/sdk/machines/octatrack/include/modwerk_sample_editor.h b/sdk/machines/octatrack/include/modwerk_sample_editor.h index c5daa9849..c5f4b76a1 100644 --- a/sdk/machines/octatrack/include/modwerk_sample_editor.h +++ b/sdk/machines/octatrack/include/modwerk_sample_editor.h @@ -1,8 +1,7 @@ /* SPDX-License-Identifier: MIT * Proposed shared Octatrack sample-editor interface. Not released capability. * The base owns source access, confirmation and publication; packages own their - * algorithms and controls. No sample pointer crosses this API. The optional - * read tail copies bounded absolute peaks into caller memory. + * algorithms and controls. No PCM or instrument pointer crosses this API. */ #ifndef MODWERK_SAMPLE_EDITOR_H #define MODWERK_SAMPLE_EDITOR_H @@ -16,8 +15,7 @@ enum modwerk_sample_reply { MODWERK_SAMPLE_OK, MODWERK_SAMPLE_BUSY, MODWERK_SAMPLE_STALE, MODWERK_SAMPLE_UNSUPPORTED, MODWERK_SAMPLE_INVALID, MODWERK_SAMPLE_PRESERVATION, MODWERK_SAMPLE_CONFIRMING, - MODWERK_SAMPLE_APPLIED, MODWERK_SAMPLE_NO_CHANGE, MODWERK_SAMPLE_FAULT, - MODWERK_SAMPLE_READ_FAILED + MODWERK_SAMPLE_APPLIED, MODWERK_SAMPLE_NO_CHANGE, MODWERK_SAMPLE_FAULT }; struct modwerk_slice_record { uint32_t start, end, loop; }; struct modwerk_sample_view { @@ -51,17 +49,6 @@ struct modwerk_sample_editor_api { * confirm() above offers preservation-only edits. */ unsigned (*replace)(uint32_t token, const struct modwerk_sample_grid *); }; -/* Optional tail. The ABI-1 struct above keeps its size. bytes covers this - * tail when the base provides it. read copies absolute peaks for frames - * inside the captured trim: channel 0 is the louder of the pair, 1 is left, - * 2 is right. count is at most MODWERK_SAMPLE_READ_FRAMES. BUSY means the - * same request can be tried on a later tick. No sample pointer is returned. */ -#define MODWERK_SAMPLE_READ_FRAMES 32u -struct modwerk_sample_editor_read_api { - struct modwerk_sample_editor_api base; - unsigned (*read)(uint32_t token, uint32_t frame, uint32_t count, uint32_t channel, - int32_t *peaks, uint32_t cap, uint32_t *got); -} __attribute__((aligned(4))); /* One typed contribution to the proposed base-owned action table. The record * is 64 bytes and four-byte aligned on ColdFire; label is inline, printable, * NUL + zero padding. ColdFire C otherwise permits two-byte struct alignment. diff --git a/sdk/machines/octatrack/module-adapters/sample_editor_board.c b/sdk/machines/octatrack/module-adapters/sample_editor_board.c index 042d0540a..4f6caed01 100644 --- a/sdk/machines/octatrack/module-adapters/sample_editor_board.c +++ b/sdk/machines/octatrack/module-adapters/sample_editor_board.c @@ -1,8 +1,7 @@ /* SPDX-License-Identifier: MIT * Native 1.40C base adapter. The source build checks every owned seam against * the private stock image. Enabled only by the private sample-editor option. - * Flex/Static and inactive recorder metadata. A bounded read copies absolute - * peaks through the stock pair readers; no sample pointer leaves the base. + * Flex/Static and inactive recorder metadata only; no PCM access. */ #include "sample_editor_board.h" #ifdef MW_SAMPLE_BOARD_HOST @@ -221,24 +220,7 @@ static unsigned publish(void *unused,const struct mw_sample_capture *before,cons return matches(settings,v->count,v->records) && READ(0x400ceb3cu)==selected? (stale?MODWERK_SAMPLE_STALE:MODWERK_SAMPLE_NO_CHANGE):MODWERK_SAMPLE_FAULT; } -static int read_pair(void *unused,uint32_t position,int32_t *left,int32_t *right) -{ - (void)unused; - if(!audio_preemptible())return 0; - const struct modwerk_sample_view *v=&mw_sample_editor.before.view; - if(position>=v->frames || v->frames-position<2u)return 0; -#ifdef MW_SAMPLE_BOARD_HOST - int mw_sample_host_read_pair(uint32_t,int32_t *,int32_t *); - return mw_sample_host_read_pair(position,left,right); -#else - /* Pair readers identified by the sample-editor research: Flex, then Static. - * Two frames per call, the count that research ran on hardware. */ - typedef int (*reader)(uint32_t,uint32_t,uint32_t,int32_t *,int32_t *); - reader native=(reader)(v->kind?0x40095984u:0x40093064u); - return native(v->slot,position,2u,left,right)>=0; -#endif -} -static const struct mw_sample_backend backend={0,lock,unlock,ready,owner,capture,publish,read_pair}; +static const struct mw_sample_backend backend={0,lock,unlock,ready,owner,capture,publish}; void mw_sample_editor_bootstrap(void) { if(!mw_sample_editor.initialized)(void)mw_sample_editor_init(&mw_sample_editor,&backend); @@ -280,25 +262,9 @@ static unsigned api_replace(uint32_t token,const struct modwerk_sample_grid *gri unsigned why=buffer(grid,sizeof *grid,0)?mw_sample_editor_replace(&mw_sample_editor,token,grid):MODWERK_SAMPLE_INVALID; modwerk_machine_unlock(saved);return why; } -static unsigned api_read(uint32_t token,uint32_t frame,uint32_t count,uint32_t channel, - int32_t *peaks,uint32_t cap,uint32_t *got) -{ - uint32_t saved=modwerk_machine_lock(); - unsigned why=MODWERK_SAMPLE_INVALID; - if(count && count<=MODWERK_SAMPLE_READ_FRAMES && - buffer(peaks,count*(uint32_t)sizeof *peaks,1) && buffer(got,sizeof *got,1)) - why=mw_sample_editor_read(&mw_sample_editor,token,frame,count,channel,peaks,cap,got); - modwerk_machine_unlock(saved);return why; -} const struct modwerk_sample_editor_api mw_sample_editor_api={ MODWERK_SAMPLE_EDITOR_ABI,sizeof(struct modwerk_sample_editor_api),api_copy,api_confirm,api_cancel,api_replace }; -static const struct modwerk_sample_editor_read_api mw_sample_editor_read_api={ - {MODWERK_SAMPLE_EDITOR_ABI,sizeof(struct modwerk_sample_editor_read_api), - api_copy,api_confirm,api_cancel,api_replace},api_read -}; -const struct modwerk_sample_editor_api *mw_sample_editor_public_api(void) -{return &mw_sample_editor_read_api.base;} void mw_sample_owner_changing(const struct runtime_dsp *from) { uint32_t saved=modwerk_machine_mask(); diff --git a/sdk/machines/octatrack/module-adapters/sample_editor_board.h b/sdk/machines/octatrack/module-adapters/sample_editor_board.h index 188267ef3..c8c7a8edf 100644 --- a/sdk/machines/octatrack/module-adapters/sample_editor_board.h +++ b/sdk/machines/octatrack/module-adapters/sample_editor_board.h @@ -5,7 +5,6 @@ #include "sample_editor_actions.h" extern struct mw_sample_editor mw_sample_editor; extern const struct modwerk_sample_editor_api mw_sample_editor_api; -const struct modwerk_sample_editor_api *mw_sample_editor_public_api(void); void mw_sample_editor_bootstrap(void); int mw_sample_owner_current_locked(const struct runtime_owner *); void mw_sample_owner_changing(const struct runtime_dsp *); diff --git a/sdk/machines/octatrack/module-adapters/sample_editor_menu.c b/sdk/machines/octatrack/module-adapters/sample_editor_menu.c index 5fe2ecac5..0d53764fd 100644 --- a/sdk/machines/octatrack/module-adapters/sample_editor_menu.c +++ b/sdk/machines/octatrack/module-adapters/sample_editor_menu.c @@ -268,11 +268,11 @@ void mw_sample_ui_tick(void) uintptr_t base; if(why==MODWERK_SAMPLE_OK && image(&pending.owner,&base) && mw_sample_owner_current_locked(&pending.owner)) { #ifdef MW_SAMPLE_MENU_HOST - mw_sample_menu_host_open_action(base+pending.action.entry_offset,token,mw_sample_editor_public_api()); + mw_sample_menu_host_open_action(base+pending.action.entry_offset,token,&mw_sample_editor_api); #else void (*open)(uint32_t,const struct modwerk_sample_editor_api *)= (void (*)(uint32_t,const struct modwerk_sample_editor_api *))(base+pending.action.entry_offset); - open(token,mw_sample_editor_public_api()); + open(token,&mw_sample_editor_api); #endif modwerk_machine_unlock(saved);return; } diff --git a/sdk/machines/octatrack/module-adapters/sample_editor_service.c b/sdk/machines/octatrack/module-adapters/sample_editor_service.c index 83e8a32f4..497035398 100644 --- a/sdk/machines/octatrack/module-adapters/sample_editor_service.c +++ b/sdk/machines/octatrack/module-adapters/sample_editor_service.c @@ -201,42 +201,6 @@ unsigned mw_sample_editor_confirm(struct mw_sample_editor *e,uint32_t token,cons {return confirm(e,token,g,0);} unsigned mw_sample_editor_replace(struct mw_sample_editor *e,uint32_t token,const struct modwerk_sample_grid *g) {return confirm(e,token,g,1);} -static uint32_t magnitude(int32_t value) -{return value<0?(uint32_t)(~value)+1u:(uint32_t)value;} -static int32_t louder(uint32_t channel,int32_t left,int32_t right) -{ - uint32_t l=magnitude(left),r=magnitude(right); - if(channel==1u)return (int32_t)l; - if(channel==2u)return (int32_t)r; - return (int32_t)(l>r?l:r); -} -unsigned mw_sample_editor_read(struct mw_sample_editor *e,uint32_t token,uint32_t frame, - uint32_t count,uint32_t channel,int32_t *peaks,uint32_t cap,uint32_t *got) -{ - if(!binding(e) || !peaks || !got || channel>2u || !count || - count>MODWERK_SAMPLE_READ_FRAMES || count>cap)return MODWERK_SAMPLE_INVALID; - const struct mw_sample_backend *b=e->backend;uint32_t saved=b->lock(b->context); - unsigned why=current(e,token); - if(why==MODWERK_SAMPLE_OK && e->state!=MW_SAMPLE_OPEN)why=MODWERK_SAMPLE_BUSY; - if(why==MODWERK_SAMPLE_OK && !b->read_pair)why=MODWERK_SAMPLE_UNSUPPORTED; - if(why==MODWERK_SAMPLE_OK) { - const struct modwerk_sample_view *v=&e->before.view; - if(frametrim_start || frame>=v->trim_end || count>v->trim_end-frame)why=MODWERK_SAMPLE_INVALID; - else { - uint32_t wrote=0; - while(wrote=v->frames || !b->read_pair(b->context,pos,left,right))break; - peaks[wrote++]=louder(channel,left[0],right[0]); - if(wroteserial && why!=MODWERK_SAMPLE_BUSY)e->state=MW_SAMPLE_CLOSED; - b->unlock(b->context,saved);return why; -} unsigned mw_sample_editor_answer(struct mw_sample_editor *e,uint32_t token,unsigned yes) { if(!binding(e))return MODWERK_SAMPLE_UNSUPPORTED; diff --git a/sdk/machines/octatrack/module-adapters/sample_editor_service.h b/sdk/machines/octatrack/module-adapters/sample_editor_service.h index 16c4ae5b6..b3862a859 100644 --- a/sdk/machines/octatrack/module-adapters/sample_editor_service.h +++ b/sdk/machines/octatrack/module-adapters/sample_editor_service.h @@ -35,10 +35,6 @@ struct mw_sample_backend { * Recorder extent/trim/PCM changes must never be rolled back. No disk save, * PCM mutation, normalizing old rows or user history is permitted. */ unsigned (*publish)(void *, const struct mw_sample_capture *, const struct mw_sample_edit *); - /* Optional. NULL means read returns UNSUPPORTED. Called with the same lock - * held, below the frame interrupt, for two frames at position. 0 fails the - * read and writes nothing. It must not retain the pointers. */ - int (*read_pair)(void *, uint32_t position, int32_t *left, int32_t *right); }; enum mw_sample_state { MW_SAMPLE_CLOSED, MW_SAMPLE_OPEN, MW_SAMPLE_PROMPT }; struct mw_sample_editor { @@ -55,10 +51,6 @@ unsigned mw_sample_editor_begin(struct mw_sample_editor *, const struct runtime_ unsigned mw_sample_editor_copy(struct mw_sample_editor *, uint32_t token, struct modwerk_sample_view *); unsigned mw_sample_editor_confirm(struct mw_sample_editor *, uint32_t token, const struct modwerk_sample_grid *); unsigned mw_sample_editor_replace(struct mw_sample_editor *, uint32_t token, const struct modwerk_sample_grid *); -/* Copies up to MODWERK_SAMPLE_READ_FRAMES absolute peaks from the captured trim. - * The caller owns peaks and got. A short *got still returns OK. */ -unsigned mw_sample_editor_read(struct mw_sample_editor *, uint32_t token, uint32_t frame, - uint32_t count, uint32_t channel, int32_t *peaks, uint32_t cap, uint32_t *got); /* Invoked only by the base's verified stock confirmation dispatch, with the * token captured when THAT prompt was shown. Revalidate source/owner on YES. * NO just cancels. BUSY retains the prompt for a later revalidated attempt; diff --git a/sdk/machines/octatrack/module-adapters/test_sample_editor_board.c b/sdk/machines/octatrack/module-adapters/test_sample_editor_board.c index cfd58ad17..941aac3c2 100644 --- a/sdk/machines/octatrack/module-adapters/test_sample_editor_board.c +++ b/sdk/machines/octatrack/module-adapters/test_sample_editor_board.c @@ -112,11 +112,6 @@ void mw_sample_menu_retire_locked(const struct runtime_dsp *from) {(void)from;if(!masked)bad=1;++retired;} void mw_sample_host_refresh(void) {if(!locked || masked)bad=1;++refreshes;} -int mw_sample_host_read_pair(uint32_t position,int32_t *left,int32_t *right) -{ - if(!locked || masked)bad=1; - left[0]=left[1]=(int32_t)position;right[0]=right[1]=0;return 1; -} static uint32_t setup(unsigned kind,unsigned slot) { used=masked=locked=bad=writes=refreshes=retired=metadata_reads=frame_services=0;live=1; @@ -532,19 +527,5 @@ int main(void) mw_sample_owner_changing(&from); CHECK(retired==1 && mw_sample_editor_api.copy(token,view)==MODWERK_SAMPLE_STALE && !writes); CHECK(!masked && !locked && !bad); - { - const struct modwerk_sample_editor_api *pub=mw_sample_editor_public_api(); - const struct modwerk_sample_editor_read_api *ext=(const void *)pub; - int32_t *peaks=(int32_t *)((uint8_t *)extent+4096); - uint32_t *got=(uint32_t *)((uint8_t *)extent+4096+128); - uint32_t settings=setup(1,0); - (void)settings; - CHECK(pub->bytes==sizeof(struct modwerk_sample_editor_read_api) && pub->bytes>sizeof *pub); - CHECK(mw_sample_editor_begin(&mw_sample_editor,&owner,&token)==MODWERK_SAMPLE_OK); - CHECK(ext->read(token,0,4,0,peaks,4,got)==MODWERK_SAMPLE_OK && *got==4); - CHECK(peaks[0]==0 && peaks[1]==0 && peaks[2]==2 && peaks[3]==2); - storage=0;CHECK(ext->read(token,0,4,0,peaks,4,got)==MODWERK_SAMPLE_BUSY); - CHECK(!masked && !locked && !bad); - } puts("PASS: both native metadata paths, buffer ownership, atomic rollback and fail-closed gates");return 0; } diff --git a/sdk/machines/octatrack/module-adapters/test_sample_editor_menu.c b/sdk/machines/octatrack/module-adapters/test_sample_editor_menu.c index 4f3923580..a5afb17c4 100644 --- a/sdk/machines/octatrack/module-adapters/test_sample_editor_menu.c +++ b/sdk/machines/octatrack/module-adapters/test_sample_editor_menu.c @@ -93,7 +93,7 @@ static unsigned publish(void *p,const struct mw_sample_capture *before,const str ++writes;source.view.count=edit->count;memcpy(source.view.records,edit->records,sizeof edit->records); return MODWERK_SAMPLE_APPLIED; } -static const struct mw_sample_backend backend={0,lock,unlock,ready,owner,capture,publish,0}; +static const struct mw_sample_backend backend={0,lock,unlock,ready,owner,capture,publish}; void mw_sample_editor_bootstrap(void) {if(!mw_sample_editor.initialized && !mw_sample_editor_init(&mw_sample_editor,&backend))bad=1;} static unsigned api_copy(uint32_t t,struct modwerk_sample_view *v){return mw_sample_editor_copy(&mw_sample_editor,t,v);} @@ -102,8 +102,6 @@ static unsigned api_replace(uint32_t t,const struct modwerk_sample_grid *g){retu static void api_cancel(uint32_t t){mw_sample_editor_cancel(&mw_sample_editor,t);} const struct modwerk_sample_editor_api mw_sample_editor_api={MODWERK_SAMPLE_EDITOR_ABI,sizeof mw_sample_editor_api, api_copy,api_confirm,api_cancel,api_replace}; -const struct modwerk_sample_editor_api *mw_sample_editor_public_api(void) -{return &mw_sample_editor_api;} static void reset(void) { memset(&mw_sample_editor,0,sizeof mw_sample_editor);memset(&source,0,sizeof source); diff --git a/sdk/machines/octatrack/module-adapters/test_sample_editor_service.c b/sdk/machines/octatrack/module-adapters/test_sample_editor_service.c index 7a65da984..512572264 100644 --- a/sdk/machines/octatrack/module-adapters/test_sample_editor_service.c +++ b/sdk/machines/octatrack/module-adapters/test_sample_editor_service.c @@ -37,16 +37,7 @@ static unsigned publish(void *context,const struct mw_sample_capture *before,con } return publish_reply; } -static int32_t pcm_l[8],pcm_r[8]; -static unsigned pair_calls; -static int read_pair(void *context,uint32_t position,int32_t *left,int32_t *right) -{ - if(context!=&editor || !held){bad=1;return 0;} - if(position+1u>=8u)return 0; - ++pair_calls;left[0]=pcm_l[position];left[1]=pcm_l[position+1u]; - right[0]=pcm_r[position];right[1]=pcm_r[position+1u];return 1; -} -static const struct mw_sample_backend backend={&editor,lock,unlock,available,published,capture,publish,read_pair}; +static const struct mw_sample_backend backend={&editor,lock,unlock,available,published,capture,publish}; static void reset(uint32_t kind,uint32_t slot,uint32_t count) { uint8_t *p=(uint8_t *)&editor; @@ -205,21 +196,6 @@ uint32_t mw_sample_editor_service_test(void) CHECK(mw_sample_editor_begin(&editor,&owner,&token)==MODWERK_SAMPLE_UNSUPPORTED && token==123); reset(1,0,0);editor.revision=UINT32_MAX;mw_sample_editor_changed_locked(&editor); CHECK(mw_sample_editor_begin(&editor,&owner,&token)==MODWERK_SAMPLE_UNSUPPORTED); - reset(1,0,0); - pcm_l[0]=0;pcm_l[1]=-100;pcm_l[2]=40;pcm_l[3]=0; - pcm_r[0]=0;pcm_r[1]=20;pcm_r[2]=-80;pcm_r[3]=0; - pair_calls=0; - CHECK(mw_sample_editor_begin(&editor,&owner,&token)==MODWERK_SAMPLE_OK); - static int32_t peaks[4]; - static uint32_t got; - CHECK(mw_sample_editor_read(&editor,token,0,4,0,peaks,4,&got)==MODWERK_SAMPLE_OK && got==4 && pair_calls==2); - CHECK(peaks[0]==0 && peaks[1]==100 && peaks[2]==80 && peaks[3]==0); - CHECK(mw_sample_editor_read(&editor,token,0,4,1,peaks,4,&got)==MODWERK_SAMPLE_OK && peaks[1]==100 && peaks[2]==40); - CHECK(mw_sample_editor_read(&editor,token,0,4,2,peaks,4,&got)==MODWERK_SAMPLE_OK && peaks[1]==20 && peaks[2]==80); - CHECK(mw_sample_editor_read(&editor,token,0,33,0,peaks,33,&got)==MODWERK_SAMPLE_INVALID); - ready=0;CHECK(mw_sample_editor_read(&editor,token,0,4,0,peaks,4,&got)==MODWERK_SAMPLE_BUSY); - ready=1;CHECK(mw_sample_editor_read(&editor,token,100,4,0,peaks,4,&got)==MODWERK_SAMPLE_READ_FAILED && !got); - CHECK(!held && !bad); return 0; } #ifdef MW_SAMPLE_EDITOR_HOST_MAIN From 7a5e6f2c8f8734ca1dd7e55aa0139d13684add09 Mon Sep 17 00:00:00 2001 From: jonathanwardle1992-beep Date: Sun, 8 Nov 2026 17:53:00 +0000 Subject: [PATCH 5/7] Name the Threshold Slices test commit that is on this branch. --- sdk/drafts/threshold-slices/TESTING.md | 2 +- sdk/drafts/threshold-slices/octamod.module.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/sdk/drafts/threshold-slices/TESTING.md b/sdk/drafts/threshold-slices/TESTING.md index 2c6d4feeb..68e239b2d 100644 --- a/sdk/drafts/threshold-slices/TESTING.md +++ b/sdk/drafts/threshold-slices/TESTING.md @@ -2,7 +2,7 @@ ## Commands and exact revision -Draft written against `eaaf60caf0d050cc158e2b281207a913183e813b`. No device trial is tied to that commit. +The host scan and the 12-second USB try below are commit `f96425206d5b209701cb0d4c05c7a03084612dee`. Pressing D to remove a slice came after that and was not part of those runs. - `python -m unittest sdk.tests.test_threshold_slices` — PASS, 2026-11-06, host gcc. Spikes, silence, 64-slice refusal, a held rise and a short trim. - The sample-editor host fixtures (service, board, menu, actions) were compiled with the same gcc and each printed PASS, including the new peak read. diff --git a/sdk/drafts/threshold-slices/octamod.module.json b/sdk/drafts/threshold-slices/octamod.module.json index 4377337a2..44f45491b 100644 --- a/sdk/drafts/threshold-slices/octamod.module.json +++ b/sdk/drafts/threshold-slices/octamod.module.json @@ -102,7 +102,7 @@ "report": "TESTING.md", "summary": "Host scan of synthetic peaks. The sample-editor read is covered by the base host fixtures. No unit trial yet.", "hardwareStatus": "untested", - "evidenceRevision": "eaaf60caf0d050cc158e2b281207a913183e813b", + "evidenceRevision": "f96425206d5b209701cb0d4c05c7a03084612dee", "gates": [] }, "license": { From 5190b21acbc1efe21cb0e375afbf9accfdb41696 Mon Sep 17 00:00:00 2001 From: jonathanwardle1992-beep Date: Sun, 8 Nov 2026 17:58:06 +0000 Subject: [PATCH 6/7] Name 0.1.0-experimental in the Threshold Slices test notes. --- sdk/drafts/threshold-slices/TESTING.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/sdk/drafts/threshold-slices/TESTING.md b/sdk/drafts/threshold-slices/TESTING.md index 68e239b2d..6ff33b822 100644 --- a/sdk/drafts/threshold-slices/TESTING.md +++ b/sdk/drafts/threshold-slices/TESTING.md @@ -1,5 +1,7 @@ # Threshold Slices testing +0.1.0-experimental. The host scan passed. A 12-second USB try was rolled back without opening the editor. D removal is not tested yet. + ## Commands and exact revision The host scan and the 12-second USB try below are commit `f96425206d5b209701cb0d4c05c7a03084612dee`. Pressing D to remove a slice came after that and was not part of those runs. From 6a77e168c56ef25c1b60a525bf3815d91d94a427 Mon Sep 17 00:00:00 2001 From: jonathanwardle1992-beep Date: Sun, 8 Nov 2026 18:19:28 +0000 Subject: [PATCH 7/7] Record the author's report that D removes the slice under the marker. --- sdk/drafts/threshold-slices/TESTING.md | 8 +++++--- sdk/drafts/threshold-slices/octamod.module.json | 2 +- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/sdk/drafts/threshold-slices/TESTING.md b/sdk/drafts/threshold-slices/TESTING.md index 6ff33b822..2cb68b05f 100644 --- a/sdk/drafts/threshold-slices/TESTING.md +++ b/sdk/drafts/threshold-slices/TESTING.md @@ -1,6 +1,6 @@ # Threshold Slices testing -0.1.0-experimental. The host scan passed. A 12-second USB try was rolled back without opening the editor. D removal is not tested yet. +0.1.0-experimental. The host scan passed. A 12-second USB try was rolled back without opening the editor. The author reports that pressing D on the waveform removes only the slice under the marker. ## Commands and exact revision @@ -12,7 +12,9 @@ The host scan and the 12-second USB try below are commit `f96425206d5b209701cb0d Private ColdFire package `C:\Users\Roy Walker\modwerk-builds\threshold-slices.mwrm`, SHA-256 `a020a947cfdad3ad42b3e5566ec78e88a1e769942926f20fd897e6e9b145d3e7`, subscribed at order 60. Not committed. -On the unit, 2026-11-07, OCTATRACK 1.40C, base `671f0afd406290cb`: `device try` of that package for 12 seconds, then rolled back (not kept). While it ran, the slot was running, refusals stayed 0, and the tick value stayed `0x54530000` (the editor was not opened). About 60 ticks a second. After rollback the unit was normal, generation 2, the same base, and no slot running. The menu, LEVEL insert, D removal, scan time and CPU were not tried. +On the unit, 2026-11-07, OCTATRACK 1.40C, base `671f0afd406290cb`: `device try` of that package for 12 seconds, then rolled back (not kept). While it ran, the slot was running, refusals stayed 0, and the tick value stayed `0x54530000` (the editor was not opened). About 60 ticks a second. After rollback the unit was normal, generation 2, the same base, and no slot running. The menu, LEVEL insert, scan time and CPU were not tried. + +Reported by the author, 8 November 2026: with the waveform open, pressing D removed only the slice under the marker. The other slices stayed, and the result matched the description. No package hash was recorded for that load. ## Hardware and audio quality @@ -32,4 +34,4 @@ Unmeasured. ## Hardware -Untested. +The author's report of the D gesture is above. No audio capture. The menu, scan time and CPU were not tried. diff --git a/sdk/drafts/threshold-slices/octamod.module.json b/sdk/drafts/threshold-slices/octamod.module.json index 44f45491b..ec6ce08dc 100644 --- a/sdk/drafts/threshold-slices/octamod.module.json +++ b/sdk/drafts/threshold-slices/octamod.module.json @@ -100,7 +100,7 @@ }, "tests": { "report": "TESTING.md", - "summary": "Host scan of synthetic peaks. The sample-editor read is covered by the base host fixtures. No unit trial yet.", + "summary": "Host scan of synthetic peaks. The author reports that pressing D removes the slice under the marker. The menu, scan time and CPU were not tried.", "hardwareStatus": "untested", "evidenceRevision": "f96425206d5b209701cb0d4c05c7a03084612dee", "gates": []