You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Once a closure crosses the module boundary into student code (e.g. sine_wave's returned wave, or a planned get_pixel_value), the evaluator can only call it back synchronously — required whenever student code is itself already running inside a synchronous host callback (e.g. a wave being sampled by play) — if that closure carries an optional .sync twin: a plain, Promise/generator-free function computing the same result. Without one:
even in ordinary (non-nested) use, every call pays the mandatory AsyncGenerator cost instead of a plain function call — source-academy/py-slang's own benchmark puts this at ~390-420ns/call vs. ~15-20ns/call, the difference between a usable per-frame budget and not, for something sampled thousands of times per frame/second
Reference implementation: sound already does this
sound's Conductor migration (#796) already implements the pattern correctly, and is the model other bundles should copy:
// types.tsexporttypeSyncWave=(t: number)=>number;exporttypeWave={(t: number): AsyncGenerator<void,number,undefined>;readonlysync?: SyncWave;};// functions.ts — any wave built purely from module-native math gets a .sync twin for freefunctionsyncWave(fn: SyncWave): Wave{constwave=(asyncfunction*(t: number){returnfn(t);})asWave;returnObject.assign(wave,{sync: fn});}exportfunctionsine_wave(freq: number): Wave{returnsyncWave(t=>Math.sin(2*Math.PI*t*freq));}// index.ts — threading Wave.sync through to the actual Conductor closurefunctionwaveToConductorClosure(evaluator: IDataHandler,wave: Wave): Promise<TypedValue<DataType.CLOSURE>>{asyncfunction*conductorWave(t: TypedValue<DataType.NUMBER>){return{type: DataType.NUMBERasconst,value: yield*wave(t.value)};}if(wave.sync){constsync=wave.sync;Object.assign(conductorWave,{sync: (t: TypedValue<DataType.NUMBER>): TypedValue<DataType.NUMBER>=>({type: DataType.NUMBERasconst,value: sync(t.value),}),});}returnevaluator.closure_make({returnType: DataType.NUMBER,args: [DataType.NUMBER]},conductorWave);}
@moduleMethod([DataType.NUMBER],DataType.CLOSURE)async*sine_wave(freq: TypedValue<DataType.NUMBER>): AsyncGenerator<void,TypedValue<DataType.CLOSURE>,undefined>{returnwaveToConductorClosure(this.evaluator,sine_wave_func(freq.value));}
It falls back to the plain AsyncGenerator path the instant a student-authored closure enters the composition (closureToWave never sets .sync), so correctness for student-authored waves is unaffected — only module-native closures get the fast path.
What's missing
stereo_sound — essentially the same wave-sampling architecture, same per-sample rate as sound — has no .sync at all yet. Its wave closures always pay the full AsyncGenerator cost, and (once py2js: closure_call_sync fast path for module closures called from Python py-slang#353 merges) still can't be called synchronously from inside a student's composed wave.
No written guidance exists anywhere in docs/ telling module implementers when/why to do this. It's only discoverable by reading sound's source. Every other already-migrated bundle (midi, rune, repeat, scrabble, binary_tree, plotly) has zero .sync usage — some may have no need for it, but nothing currently prompts an implementer to even consider it.
Write this up as a documented convention for module implementers migrating to Conductor (e.g. under docs/src/modules/5-advanced/), stated as a general pattern rather than a sound-specific trick:
Attach a .sync twin to any closure you hand back to student code whose underlying computation is provably synchronous — not selectively, based on where you expect it to be used. Once a closure is in the student's hands you can't control where it goes: it may be passed into a different module's own synchronous sampling loop, not just the one it came from. Reserve the AsyncGenerator-only path for closures that genuinely need a real host round-trip (actual I/O, a real async browser API) — those can't have a meaningful .sync twin, and that's the correct, permanent behavior for them, not a gap to close.
Sweep other already-migrated bundles for closure-returning methods that could benefit from the same treatment.
Preserve a moduleMethod's .sync twin through BaseModulePlugin.initialise() conductor#54 — preserves a @moduleMethod's .sync twin through BaseModulePlugin.initialise()'s bind(). Needed for class-method closures like a future get_pixel_value; not needed for sound's wave closures, which are built fresh per call via waveToConductorClosure and never go through that bind step.
Summary
Once a closure crosses the module boundary into student code (e.g.
sine_wave's returned wave, or a plannedget_pixel_value), the evaluator can only call it back synchronously — required whenever student code is itself already running inside a synchronous host callback (e.g. a wave being sampled byplay) — if that closure carries an optional.synctwin: a plain, Promise/generator-free function computing the same result. Without one:"... needs a frontend round-trip and cannot be called from a synchronous module callback"(see py2js: closure_call_sync fast path for module closures called from Python py-slang#353)AsyncGeneratorcost instead of a plain function call — source-academy/py-slang's own benchmark puts this at ~390-420ns/call vs. ~15-20ns/call, the difference between a usable per-frame budget and not, for something sampled thousands of times per frame/secondReference implementation: sound already does this
sound's Conductor migration (#796) already implements the pattern correctly, and is the model other bundles should copy:It falls back to the plain
AsyncGeneratorpath the instant a student-authored closure enters the composition (closureToWavenever sets.sync), so correctness for student-authored waves is unaffected — only module-native closures get the fast path.What's missing
stereo_sound— essentially the same wave-sampling architecture, same per-sample rate assound— has no.syncat all yet. Its wave closures always pay the fullAsyncGeneratorcost, and (once py2js: closure_call_sync fast path for module closures called from Python py-slang#353 merges) still can't be called synchronously from inside a student's composed wave.docs/telling module implementers when/why to do this. It's only discoverable by readingsound's source. Every other already-migrated bundle (midi,rune,repeat,scrabble,binary_tree,plotly) has zero.syncusage — some may have no need for it, but nothing currently prompts an implementer to even consider it.pix_n_flix's plannedget_pixel_value/set_pixel_value(sampled up to width×height×8 times/frame — see Preserve a moduleMethod's .sync twin through BaseModulePlugin.initialise() conductor#54) is the next obvious candidate.Ask
sound's.syncconvention tostereo_sound.docs/src/modules/5-advanced/), stated as a general pattern rather than asound-specific trick:Related
.synctwin when calling back into it synchronously (py2js engine).@moduleMethod's.synctwin throughBaseModulePlugin.initialise()'sbind(). Needed for class-method closures like a futureget_pixel_value; not needed forsound's wave closures, which are built fresh per call viawaveToConductorClosureand never go through that bind step.