diff --git a/docs/brainstorming_notes/005-audio-buffer-cycle-length-configuration.md b/docs/brainstorming_notes/archived/005-audio-buffer-cycle-length-configuration.md similarity index 100% rename from docs/brainstorming_notes/005-audio-buffer-cycle-length-configuration.md rename to docs/brainstorming_notes/archived/005-audio-buffer-cycle-length-configuration.md diff --git a/docs/todos/160-compiler-buffer-loop-strategy-config.md b/docs/todos/160-compiler-buffer-loop-strategy-config.md new file mode 100644 index 000000000..c278f71c8 --- /dev/null +++ b/docs/todos/160-compiler-buffer-loop-strategy-config.md @@ -0,0 +1,72 @@ +--- +title: 'Plan: Configurable Buffer Generation Strategy (Loop vs Unrolled)' +priority: Medium +effort: 6-10 hours +created: 2025-11-06 +status: Planned +completed: null +--- + +# Plan: Configurable Buffer Generation Strategy (Loop vs Unrolled) + +## Goals +- Make buffer generation compile-time configurable between looped and unrolled implementations. +- Add compile-time `bufferSize` support with loop as the default strategy. +- Extract loop generation into a helper to keep `packages/compiler/src/index.ts` smaller. + +## Current Behavior (Context) +- The compiler exports three wasm functions: `init`, `cycle`, and `buffer`. +- `buffer` is currently generated as a flat, unrolled sequence of `call cycle` repeated 128 times in `packages/compiler/src/index.ts` (inside the `createCodeSection` call that builds `buffer`). +- Runtime code calls `buffer()` once per audio buffer render and reads/writes audio buffers from wasm memory. +- There is no compile-time `bufferSize` or `bufferStrategy` option in `CompileOptions` yet. + +## Proposed API +- Add to `CompileOptions`: + - `bufferSize?: number` (default 128) + - `bufferStrategy?: 'loop' | 'unrolled'` (default 'loop') + +## Implementation Plan +1. Add `bufferSize` and `bufferStrategy` to `CompileOptions` in `packages/compiler/src/types.ts` with defaults applied in the compiler entrypoint. +2. Create `packages/compiler/src/wasmBuilders/` and add a helper (e.g., `createBufferFunctionBody.ts`) that returns the buffer function body byte array. It should accept `bufferSize` and `bufferStrategy` and emit either: + - Unrolled: `new Array(bufferSize).fill(call(1)).flat()` (existing behavior). + - Loop: a counted loop that calls `cycle` `bufferSize` times using a local i32 counter and `br_if`. +3. Update `packages/compiler/src/index.ts` to use the helper instead of inlining the buffer function body. +4. Update snapshots/tests that embed the `buffer` function bytecode or export list to reflect looped default. +5. Add tests to cover: + - Default behavior: `bufferStrategy = 'loop'` and `bufferSize = 128`. + - Explicit `bufferStrategy = 'unrolled'` with a custom `bufferSize`. + - A custom `bufferSize` with loop strategy. +6. Update docs that discuss buffer size and loop unrolling so the new default and options are clear. + +## Open Questions +- Exact naming: `bufferStrategy` vs `bufferLoopMode` vs `bufferUnroll` (current leaning: `bufferStrategy`). +- Should there be validation or warnings for very large `bufferSize` values? (current direction: no cap) + +## Implementation Details (Loop Strategy) +- Use wasm locals and control flow helpers already present in `packages/compiler/src/wasmUtils`: + - Locals: `createLocalDeclaration`, `localGet`, `localSet` + - Consts: `i32const` + - Control flow: `block`, `loop`, `br_if`, `Instruction.I32_SUB` +- Suggested loop shape (pseudocode): + - `local.set $i (i32.const bufferSize)` + - `block` + - `loop` + - `call $cycle` + - `local.set $i (local.get $i - 1)` + - `local.get $i` + - `br_if 0` (continue loop if counter != 0) + - `end` + - `end` +- Keep `buffer` signature unchanged (`[] -> []`). +- The `buffer` function index remains 2 (after `init` and `cycle`) in the export section. + +## Files Likely to Change +- `packages/compiler/src/types.ts` (add compile-time options) +- `packages/compiler/src/index.ts` (use helper for buffer body) +- `packages/compiler/src/wasmBuilders/createBufferFunctionBody.ts` (new helper) +- `packages/compiler/tests/**` and snapshots referencing `buffer` body +- `docs/brainstorming_notes/005-audio-buffer-cycle-length-configuration.md` and `docs/todos/054-benchmark-unrolled-vs-normal-loop-audio-buffer-filler.md` (update defaults and new options) + +## Risks +- Snapshot churn in compiler instruction tests. +- Performance regression risk vs unrolled implementation (mitigate with follow-up benchmarks). diff --git a/docs/todos/159-add-comment-code-blocks.md b/docs/todos/archived/159-add-comment-code-blocks.md similarity index 98% rename from docs/todos/159-add-comment-code-blocks.md rename to docs/todos/archived/159-add-comment-code-blocks.md index 66a3e6c36..b4236609e 100644 --- a/docs/todos/159-add-comment-code-blocks.md +++ b/docs/todos/archived/159-add-comment-code-blocks.md @@ -3,8 +3,8 @@ title: 'TODO: Add Comment Code Blocks' priority: Medium effort: 1-2d created: 2026-01-02 -status: Open -completed: null +status: Completed +completed: 2026-01-03 --- # TODO: Add Comment Code Blocks diff --git a/packages/editor/packages/editor-state/src/effects/compiler.ts b/packages/editor/packages/editor-state/src/effects/compiler.ts index 9bf5871e1..ef3682abb 100644 --- a/packages/editor/packages/editor-state/src/effects/compiler.ts +++ b/packages/editor/packages/editor-state/src/effects/compiler.ts @@ -132,12 +132,12 @@ export default async function compiler(store: StateManager, events: Event if (state.initialProjectState?.compiledWasm && state.initialProjectState.compiledModules) { try { - state.compiler.codeBuffer = decodeBase64ToUint8Array(state.initialProjectState.compiledWasm); state.compiler.compiledModules = state.initialProjectState.compiledModules; + store.set('compiler.codeBuffer', decodeBase64ToUint8Array(state.initialProjectState.compiledWasm)); log(state, 'Pre-compiled WASM loaded and decoded successfully', 'Loader'); } catch (err) { - state.compiler.codeBuffer = new Uint8Array(); state.compiler.compiledModules = {}; + store.set('compiler.codeBuffer', new Uint8Array()); console.error('[Loader] Failed to decode pre-compiled WASM:', err); error(state, 'Failed to decode pre-compiled WASM', 'Loader'); } diff --git a/packages/editor/packages/editor-state/src/effects/config.ts b/packages/editor/packages/editor-state/src/effects/config.ts index 16e952dcb..c916b2612 100644 --- a/packages/editor/packages/editor-state/src/effects/config.ts +++ b/packages/editor/packages/editor-state/src/effects/config.ts @@ -64,7 +64,7 @@ export default function configEffect(store: StateManager, events: EventDi * Each config block is compiled independently to allow proper error mapping. * Errors are saved to codeErrors.configErrors with the creationIndex of the source block. */ - async function forceCompileConfig(): Promise { + async function rebuildConfig(): Promise { const compileConfig = state.callbacks.compileConfig; if (!compileConfig) { return; @@ -90,19 +90,9 @@ export default function configEffect(store: StateManager, events: EventDi } } - function rebuildConfig() { - // Check if compilation is disabled by config - if (state.compiler.disableAutoCompilation) { - log(state, 'Config compilation skipped: disableAutoCompilation flag is set', 'Config'); - return; - } - - forceCompileConfig(); - } - // Wire up event handlers // rebuildConfig runs BEFORE module compilation because blockTypeUpdater runs first - events.on('compileConfig', forceCompileConfig); + events.on('compileConfig', rebuildConfig); store.subscribe('initialProjectState', () => { if (state.initialProjectState?.compiledConfig) { applyConfigToState(store, state.initialProjectState.compiledConfig); @@ -124,15 +114,10 @@ export default function configEffect(store: StateManager, events: EventDi * @returns Promise resolving to the merged config object */ export async function compileConfigForExport(state: State): Promise { - // If compilation is disabled, return the stored compiled config if available - if (state.compiler.disableAutoCompilation) { - return state.compiledConfig || {}; - } - // If no compileConfig callback, return empty object const compileConfig = state.callbacks.compileConfig; if (!compileConfig) { - return {}; + return state.compiledConfig || {}; } // Collect all config blocks diff --git a/packages/editor/packages/editor-state/src/effects/disableCompilation.test.ts b/packages/editor/packages/editor-state/src/effects/disableCompilation.test.ts index 11d347a45..de7dceb1d 100644 --- a/packages/editor/packages/editor-state/src/effects/disableCompilation.test.ts +++ b/packages/editor/packages/editor-state/src/effects/disableCompilation.test.ts @@ -114,7 +114,7 @@ describe('disableAutoCompilation feature', () => { }); describe('Config compilation', () => { - it('should skip config compilation when disableAutoCompilation is true', async () => { + it('should compile config even when disableAutoCompilation is true', async () => { store.set('compiler.disableAutoCompilation', true); configEffect(store, mockEvents); @@ -122,14 +122,7 @@ describe('disableAutoCompilation feature', () => { store.set('graphicHelper.codeBlocks', [...mockState.graphicHelper.codeBlocks]); await new Promise(resolve => setTimeout(resolve, 0)); - expect(mockCompileConfig).not.toHaveBeenCalled(); - expect( - mockState.console.logs.some( - log => - log.message.includes('Config compilation skipped: disableAutoCompilation flag is set') && - log.category === '[Config]' - ) - ).toBe(true); + expect(mockCompileConfig).toHaveBeenCalled(); }); it('should compile config normally when disableAutoCompilation is false', async () => { @@ -145,16 +138,16 @@ describe('disableAutoCompilation feature', () => { }); describe('Runtime-ready export', () => { - it('should skip config compilation for export when disableAutoCompilation is true', async () => { + it('should compile config for export even when disableAutoCompilation is true', async () => { mockState.compiler.disableAutoCompilation = true; const result = await compileConfigForExport(mockState); - expect(mockCompileConfig).not.toHaveBeenCalled(); - expect(result).toEqual({}); + expect(mockCompileConfig).toHaveBeenCalled(); + expect(result).toEqual({ memorySizeBytes: 1048576 }); }); - it('should return stored compiledConfig when disableAutoCompilation is true and config exists', async () => { + it('should compile config for export even when compiledConfig exists', async () => { mockState.compiler.disableAutoCompilation = true; mockState.compiledConfig = { memorySizeBytes: 2097152, @@ -163,11 +156,8 @@ describe('disableAutoCompilation feature', () => { const result = await compileConfigForExport(mockState); - expect(mockCompileConfig).not.toHaveBeenCalled(); - expect(result).toEqual({ - memorySizeBytes: 2097152, - selectedRuntime: 1, - }); + expect(mockCompileConfig).toHaveBeenCalled(); + expect(result).toEqual({ memorySizeBytes: 1048576 }); }); it('should compile config for export when disableAutoCompilation is false', async () => { diff --git a/packages/editor/packages/editor-state/src/impureHelpers/config/applyConfigToState.test.ts b/packages/editor/packages/editor-state/src/impureHelpers/config/applyConfigToState.test.ts index 2ebd36dfe..81459f677 100644 --- a/packages/editor/packages/editor-state/src/impureHelpers/config/applyConfigToState.test.ts +++ b/packages/editor/packages/editor-state/src/impureHelpers/config/applyConfigToState.test.ts @@ -79,11 +79,11 @@ describe('applyConfigToState', () => { expect(state.compiler.disableAutoCompilation).toBe(true); }); - it('should not change disableAutoCompilation when not in config', () => { + it('should reset disableAutoCompilation to false when not in config', () => { const state = createMockState(); const store = createStateManager(state); state.compiler.disableAutoCompilation = true; applyConfigToState(store, { memorySizeBytes: 65536 }); - expect(state.compiler.disableAutoCompilation).toBe(true); + expect(state.compiler.disableAutoCompilation).toBe(false); }); }); diff --git a/packages/editor/packages/editor-state/src/impureHelpers/config/applyConfigToState.ts b/packages/editor/packages/editor-state/src/impureHelpers/config/applyConfigToState.ts index 3fad872c6..c2974d712 100644 --- a/packages/editor/packages/editor-state/src/impureHelpers/config/applyConfigToState.ts +++ b/packages/editor/packages/editor-state/src/impureHelpers/config/applyConfigToState.ts @@ -42,11 +42,6 @@ export function applyConfigToState(store: StateManager, config: ConfigObj } } - if (typeof config.disableAutoCompilation === 'boolean') { - store.set('compiler.disableAutoCompilation', config.disableAutoCompilation); - } - - if (typeof config.memorySizeBytes === 'number') { - store.set('compiler.compilerOptions.memorySizeBytes', config.memorySizeBytes); - } + store.set('compiler.disableAutoCompilation', config.disableAutoCompilation ?? false); + store.set('compiler.compilerOptions.memorySizeBytes', config.memorySizeBytes ?? 1048576); // 1MB default } diff --git a/packages/runtime-main-thread-logic/src/index.ts b/packages/runtime-main-thread-logic/src/index.ts index 7706d857e..3d26f14ca 100644 --- a/packages/runtime-main-thread-logic/src/index.ts +++ b/packages/runtime-main-thread-logic/src/index.ts @@ -34,6 +34,7 @@ export default function createMainThreadLogicRuntime( const intervalTime = Math.floor(1000 / sampleRate); + clearInterval(interval); interval = setInterval(() => { if (!wasmApp) return; const startTime = performance.now(); @@ -44,6 +45,7 @@ export default function createMainThreadLogicRuntime( timeToExecuteLoopMs = endTime - startTime; }, intervalTime); + clearInterval(statsInterval); statsInterval = setInterval(() => { onStats({ timerPrecisionPercentage: 100 - Math.abs(timerDriftMs / intervalTime) * 100, diff --git a/packages/runtime-web-worker-logic/src/index.ts b/packages/runtime-web-worker-logic/src/index.ts index 6f6c1f18a..294ee995b 100644 --- a/packages/runtime-web-worker-logic/src/index.ts +++ b/packages/runtime-web-worker-logic/src/index.ts @@ -8,14 +8,14 @@ let timerDriftMs: number; async function init(memoryRef: WebAssembly.Memory, sampleRate: number, codeBuffer: Uint8Array) { try { - clearInterval(interval); - clearInterval(statsInterval); - const wasmApp = await createModule(memoryRef, codeBuffer); const intervalTime = Math.floor(1000 / sampleRate); lastIntervalTime = performance.now(); + + clearInterval(interval); + interval = setInterval(() => { const startTime = performance.now(); timerDriftMs = startTime - lastIntervalTime - intervalTime; @@ -25,6 +25,8 @@ async function init(memoryRef: WebAssembly.Memory, sampleRate: number, codeBuffe timeToExecuteLoopMs = endTime - startTime; }, intervalTime); + clearInterval(statsInterval); + statsInterval = setInterval(() => { self.postMessage({ type: 'stats', diff --git a/packages/runtime-web-worker-midi/src/index.ts b/packages/runtime-web-worker-midi/src/index.ts index b9d1c039d..7f5588a77 100644 --- a/packages/runtime-web-worker-midi/src/index.ts +++ b/packages/runtime-web-worker-midi/src/index.ts @@ -24,9 +24,6 @@ async function init( compiledModules: CompiledModuleLookup ) { try { - clearInterval(interval); - clearInterval(statsInterval); - const wasmApp = await createModule(memoryRef, codeBuffer); memoryBuffer = wasmApp.memoryBuffer; @@ -39,6 +36,7 @@ async function init( const intervalTime = Math.floor(1000 / sampleRate); lastIntervalTime = performance.now(); + clearInterval(interval); interval = setInterval(() => { const startTime = performance.now(); timerDriftMs = startTime - lastIntervalTime - intervalTime; @@ -50,6 +48,7 @@ async function init( broadcastMidiMessages(midiNoteModules, memoryBuffer); }, intervalTime); + clearInterval(statsInterval); statsInterval = setInterval(() => { self.postMessage({ type: 'stats', diff --git a/src/examples/projects/index.ts b/src/examples/projects/index.ts index 76346c5ca..9e7fa8f95 100644 --- a/src/examples/projects/index.ts +++ b/src/examples/projects/index.ts @@ -52,6 +52,7 @@ export const projectMetadata: ProjectMetadata[] = [ slug: 'simpleCounterMainThread', title: 'Simple Counter (Main Thread)', }, + { slug: 'standaloneProject', title: 'Standalone Project Example' }, ]; // For backwards compatibility diff --git a/src/examples/projects/simpleCounterMainThread.ts b/src/examples/projects/simpleCounterMainThread.ts index 1ef885c9e..f22509e75 100644 --- a/src/examples/projects/simpleCounterMainThread.ts +++ b/src/examples/projects/simpleCounterMainThread.ts @@ -16,8 +16,7 @@ const project: Project = { 'set', 'popScope', '', - 'scope "runtimeSettings"', - 'scope 0', + 'scope "runtimeSettings[0]"', 'scope "runtime"', 'push "MainThreadLogicRuntime"', 'set', diff --git a/src/examples/projects/standaloneProject.ts b/src/examples/projects/standaloneProject.ts new file mode 100644 index 000000000..e2ed2e0fa --- /dev/null +++ b/src/examples/projects/standaloneProject.ts @@ -0,0 +1,111 @@ +import { MemoryTypes } from '@8f4e/compiler'; + +import type { Project } from '@8f4e/editor-state'; + +const standalonProject: Project = { + codeBlocks: [ + { + code: [ + 'config', + '', + 'scope "memorySizeBytes"', + 'push 8', + 'set', + 'popScope', + '', + 'scope "selectedRuntime"', + 'push 0', + 'set', + 'popScope', + '', + 'scope "runtimeSettings[0]"', + 'scope "runtime"', + 'push "WebWorkerLogicRuntime"', + 'set', + 'rescopeTop "sampleRate"', + 'push 1', + 'set', + '', + 'rescope disableAutoCompilation', + 'push true', + 'set ', + '', + 'configEnd', + ], + gridCoordinates: { + x: -14, + y: 8, + }, + }, + { + code: [ + 'module counter', + '', + 'int count', + 'debug count', + '', + 'push &count', + 'push count', + 'push 1', + 'add', + 'store', + '', + 'moduleEnd', + ], + gridCoordinates: { + x: 25, + y: 8, + }, + }, + ], + viewport: { + gridCoordinates: { + x: -19, + y: 4, + }, + }, + binaryAssets: [], + compiledModules: { + counter: { + id: 'counter', + loopFunction: [15, 0, 65, 4, 65, 4, 40, 2, 0, 65, 1, 106, 54, 2, 0, 11], + initFunctionBody: [], + byteAddress: 4, + wordAlignedAddress: 1, + memoryMap: { + count: { + numberOfElements: 1, + elementWordSize: 4, + wordAlignedAddress: 1, + wordAlignedSize: 1, + byteAddress: 4, + id: 'count', + default: 0, + type: 'int' as unknown as MemoryTypes, + isPointer: false, + isPointingToInteger: false, + isPointingToPointer: false, + isInteger: true, + }, + }, + wordAlignedSize: 1, + index: 0, + }, + }, + compiledWasm: + 'AGFzbQEAAAABDwNgAABgAX8Bf2ACf38BfwIPAQJqcwZtZW1vcnkCAwEBAwYFAAAAAAAHGQMEaW5pdAAABWN5Y2xlAAEGYnVmZmVyAAIKogIFBAAQBAsEABADC4ICABABEAEQARABEAEQARABEAEQARABEAEQARABEAEQARABEAEQARABEAEQARABEAEQARABEAEQARABEAEQARABEAEQARABEAEQARABEAEQARABEAEQARABEAEQARABEAEQARABEAEQARABEAEQARABEAEQARABEAEQARABEAEQARABEAEQARABEAEQARABEAEQARABEAEQARABEAEQARABEAEQARABEAEQARABEAEQARABEAEQARABEAEQARABEAEQARABEAEQARABEAEQARABEAEQARABEAEQARABEAEQARABEAEQARABEAEQARABEAEQARABEAEQARABEAEQARABEAELDwBBBEEEKAIAQQFqNgIACwIACw==', + memorySnapshot: 'AAAAABcAAAA=', + compiledConfig: { + memorySizeBytes: 8, + selectedRuntime: 0, + runtimeSettings: [ + { + runtime: 'WebWorkerLogicRuntime', + sampleRate: 1, + }, + ], + disableAutoCompilation: true, + }, +}; + +export default standalonProject;