diff --git a/sdk/drafts/threshold-slices/CHANGELOG.json b/sdk/drafts/threshold-slices/CHANGELOG.json new file mode 100644 index 000000000..fb0e25daf --- /dev/null +++ b/sdk/drafts/threshold-slices/CHANGELOG.json @@ -0,0 +1,13 @@ +[ + { + "version": "0.1.0-experimental", + "date": "2026-11-07", + "changes": [ + "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, 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/LICENSE b/sdk/drafts/threshold-slices/LICENSE new file mode 100644 index 000000000..a51b53f65 --- /dev/null +++ b/sdk/drafts/threshold-slices/LICENSE @@ -0,0 +1,22 @@ +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 +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..64973caca --- /dev/null +++ b/sdk/drafts/threshold-slices/README.md @@ -0,0 +1,57 @@ +# 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 under CREATE BEAT GRID. + +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 + +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. | +| 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. | + +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..2cb68b05f --- /dev/null +++ b/sdk/drafts/threshold-slices/TESTING.md @@ -0,0 +1,37 @@ +# Threshold Slices testing + +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 + +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. +- 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`. + +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. + +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 + +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 + +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/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..a3e05ea9c --- /dev/null +++ b/sdk/drafts/threshold-slices/device_module.c @@ -0,0 +1,372 @@ +/* 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 +/* 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 PUSH_D 0x3b +#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; +} +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. */ +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"); + 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;icancel(token); + 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; + 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) +{ + uint32_t step; + if(!readable() || scan.phase>=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); + remember_peak(); + } + remember_peak(); +} +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(0); +} +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); + if(encoder==2)peak_ready=0; + restart(encoder!=2);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 && (code==PUSH_LEVEL || code==PUSH_D) && waveform_open()) { + /* Both edges. A release left for stock can arm follow-playback and + * walk the marker off the point. D is not a stock waveform key. */ + consumed[bank]|=mask; + if(code==PUSH_LEVEL)insert_at_cursor(); + else remove_at_cursor(); + 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..84414e707 --- /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. The row sits under CREATE BEAT GRID.", + 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..ec6ce08dc --- /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. 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.", + "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 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": [] + }, + "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..1dcf35196 --- /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": 60 } + ] +} diff --git a/sdk/drafts/threshold-slices/test_threshold.c b/sdk/drafts/threshold-slices/test_threshold.c new file mode 100644 index 000000000..343c1f9c6 --- /dev/null +++ b/sdk/drafts/threshold-slices/test_threshold.c @@ -0,0 +1,106 @@ +/* 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;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();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 new file mode 100644 index 000000000..ae38032f4 --- /dev/null +++ b/sdk/drafts/threshold-slices/threshold.c @@ -0,0 +1,90 @@ +/* SPDX-License-Identifier: MIT */ +#include "threshold.h" +void ts_scan_begin(struct ts_scan *s, uint32_t trim_start, uint32_t trim_end, + uint32_t rate, uint32_t percent, uint32_t gap_ms) +{ + uint32_t i; + 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->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;} +} +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; + /* 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]; + 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 { + /* 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(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) { + 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..3c23b436e --- /dev/null +++ b/sdk/drafts/threshold-slices/threshold.h @@ -0,0 +1,27 @@ +/* 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 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, 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); +#endif 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') 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()