Skip to content

Migrate midi module to Conductor#791

Open
Akshay-2007-1 wants to merge 3 commits into
conductor-migrationfrom
feat/migrate-midi
Open

Migrate midi module to Conductor#791
Akshay-2007-1 wants to merge 3 commits into
conductor-migrationfrom
feat/migrate-midi

Conversation

@Akshay-2007-1

@Akshay-2007-1 Akshay-2007-1 commented Jul 13, 2026

Copy link
Copy Markdown

Description

Fixes #775

Migrates the midi module to be Conductor-ready.

midi is a dependency of both sound and stereo_sound, which import several of its functions (midi_note_to_frequency, letter_name_to_midi_note, letter_name_to_frequency) directly as plain TypeScript, and re-export them as part of their own Source-facing APIs. Rewriting midi's functions to require an IDataHandler (the way binary_tree/repeat do) would have broken both of those call sites and re-exports immediately, so this migration keeps a pure, evaluator-free implementation available for cross-bundle TS consumption:

  • functions.ts / scales.ts / utils.ts / types.ts — pure logic. scales.ts still builds js-slang-style lists via pair/List, same as before.
  • conductorAdapters.ts — new, undecorated helper used only by the plugin: converts a js-slang scale list into a real Conductor list. Kept out of index.ts deliberately — a test file that imports index.ts directly hits a decorator syntax error under vitest's transform (it doesn't apply the same decorator settings tsc does), so anything meant to be unit-tested needs to live somewhere undecorated.
  • index.ts — now a BaseModulePlugin subclass wrapping the pure functions. SHARP/FLAT/NATURAL are plain string constants, not functions, so BaseModulePlugin.initialise() (which only registers exportedNames that are functions) can't pick them up — they're pushed onto this.exports directly in the constructor instead.
  • sound/stereo_sound: only their imports of @sourceacademy/bundle-midi changed, to @sourceacademy/bundle-midi/functions (the bundle root now exports the Conductor plugin instead of plain functions). No other changes to either bundle — their own Conductor migration is a separate, larger effort (they're ~90% duplicated implementations of each other; deferred pending a decision on whether to merge them).

Includes the same __bindExportedMethods() workaround repeat/rune/binary_tree use for the unbound-method issue in BaseModulePlugin.initialise() (source-academy/conductor#41, merged).

conductor-migration had drifted from master for this bundle

While this migration was in flight, master's midi picked up 6 new functions (is_note_with_octave, add_octave_to_note, get_octave, get_note_name, get_accidental, key_signature_to_key) and input validation on the existing ones (midi_note_to_frequency now range-checks its input), none of which existed on conductor-migration. This PR ports all of it, using the new EvaluatorParameterTypeError/EvaluatorNumberRangeError/assertNumberWithinRange from source-academy/conductor#42 (a generic, reusable standardized-error-message addition to Conductor itself, not something bundle-specific) in place of modules-lib's InvalidParameterTypeError/assertNumberWithinRange, which functions.ts can't depend on without pulling js-slang's modules-lib re-exports into sound/stereo_sound's dependency graph transitively. Message format is unchanged — verified identical to master's existing test expectations.

Also fixes midi_note_to_letter_name/key_signature_to_key's accidental parameter to match master: it's the Accidental enum value ('#'/'b'), not the word 'flat'/'sharp' my first pass used before finding the drift. midi_note_to_letter_name silently treats anything other than exactly SHARP as flat (matching master's actual, slightly loose behavior) rather than validating it — key_signature_to_key is the one that validates, since its own switch has an explicit default case for that.

Depends on source-academy/conductor#42 (not yet merged) for EvaluatorParameterTypeError/EvaluatorNumberRangeError/assertNumberWithinRange.

Testing

  • yarn workspace @sourceacademy/bundle-midi run tsc / lint / test (29/29) / build — all pass
  • yarn workspace @sourceacademy/bundle-sound run tsc / test (28/28) — pass with the updated import path
  • yarn workspace @sourceacademy/bundle-stereo_sound run tsc / test (18/18) — pass with the updated import path
  • Verified against the published docs page (source-academy.github.io/modules/documentation/modules/midi.html): every documented function and constant (19 functions + 3 constants, no more, no less) confirmed working end-to-end through the actual compiled bundle, driving each export exactly the way a real evaluator calls a closure (detached, not bound to any instance) rather than just unit-testing the pure logic directly

Splits midi into a pure, evaluator-free functions.ts (unchanged
signatures) and a Conductor-facing index.ts plugin, since sound and
stereo_sound import midi_note_to_frequency and friends directly as
plain TypeScript and sound/stereo_sound's own Source-facing APIs
re-export several of these functions. Migrating index.ts's exports to
require an IDataHandler would have broken both call sites immediately.

- functions.ts / scales.ts / utils.ts / types.ts: untouched pure logic
- conductorAdapters.ts: undecorated helpers (scale-list <-> Conductor
  list conversion, accidental validation) used by the plugin; kept
  separate from index.ts so they stay importable from vitest, which
  hits a decorator syntax error importing index.ts directly
- index.ts: BaseModulePlugin subclass wrapping the pure functions;
  SHARP/FLAT/NATURAL are pushed onto `exports` directly in the
  constructor since BaseModulePlugin.initialise() only registers
  exportedNames that are functions
- Includes the same __bindExportedMethods() workaround as
  repeat/rune/binary_tree for the unbound-method issue in
  BaseModulePlugin.initialise() (source-academy/conductor#41)
- sound/stereo_sound's functions.ts and index.ts updated to import
  midi's pure functions from the new `/functions` subpath instead of
  the bundle root, which now exports the Conductor plugin

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request refactors the midi bundle to support the Conductor plugin architecture, separating pure MIDI functions into functions.ts and implementing the Conductor-facing plugin in index.ts. It also updates the sound and stereo_sound bundles to import directly from the new functions entry point. The review feedback highlights a potential issue where accessing the .name property of functions (such as letter_name_to_midi_note and midi_note_to_letter_name) for error reporting could fail or produce cryptic messages in minified production environments due to name mangling. It is recommended to replace these with hardcoded string literals.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment thread src/bundles/midi/src/index.ts Outdated
Comment thread src/bundles/midi/src/functions.ts Outdated
Comment thread src/bundles/midi/src/functions.ts Outdated
@Akshay-2007-1 Akshay-2007-1 self-assigned this Jul 13, 2026
@Akshay-2007-1 Akshay-2007-1 marked this pull request as draft July 13, 2026 08:42
Function.prototype.name gets mangled under minification, which would
turn these error messages into cryptic garbage like "t expects...".
Two of these were carried over unchanged from the original module; the
third is in the new Conductor-facing index.ts.
conductor-migration's midi had drifted from master: 6 functions
(is_note_with_octave, add_octave_to_note, get_octave, get_note_name,
get_accidental, key_signature_to_key) and input validation on the
existing ones (midi_note_to_frequency now range-checks its input) only
existed on master, added there while this migration was in flight.
Confirmed against the published docs page
(source-academy.github.io/modules/documentation/modules/midi.html)
that this is now the complete function/constant list, no more, no
less - verified end-to-end through the actual compiled bundle, not
just unit tests, driving every export exactly the way a real evaluator
calls a closure (detached, not bound to any instance).

Also fixes midi_note_to_letter_name/key_signature_to_key's accidental
parameter to match master: it's the Accidental enum value ('#'/'b'),
not the word 'flat'/'sharp' my first pass used before I'd found the
drift. midi_note_to_letter_name silently treats anything other than
exactly SHARP as flat (matching master's actual, slightly loose
behavior) rather than validating it - key_signature_to_key is the one
that validates, since its own switch has an explicit default case for
that.

Validation now uses conductor's new EvaluatorParameterTypeError /
assertNumberWithinRange (source-academy/conductor#42) in place of
modules-lib's InvalidParameterTypeError / assertNumberWithinRange,
which functions.ts can't depend on without pulling js-slang's
modules-lib re-exports (and everything under it) into sound/
stereo_sound's dependency graph transitively. Message format is
unchanged - verified identical to master's existing test expectations.
@leeyi45

leeyi45 commented Jul 13, 2026

Copy link
Copy Markdown
Contributor

Is there a way to get Vitest to work with decorators? They're functionalities need to be tested anyway

@Akshay-2007-1

Copy link
Copy Markdown
Author

Is there a way to get Vitest to work with decorators? They're functionalities need to be tested anyway

Dug into this, afaik, I dont think its possible, not with our current Vite/Vitest setup, and it's not a config issue we can fix from our side.

Root cause:

Vitest runs test files through Vite's SSR module pipeline, which calls ssrTransformScript → parseAstAsync (from rolldown) unconditionally, regardless of whether oxc or esbuild is set as the TS transformer. That parser doesn't support native TC39 decorator syntax, so any test file importing a decorated class (like our index.ts plugins) throws a SyntaxError before the test even runs.

I confirmed esbuild alone can downlevel decorators for non-esnext targets, and traced that Vitest defaults to oxc (which doesn't downlevel decorators for any target). I patched buildtools's runVitest to actually pass through Vite config overrides and forced esbuild/oxc:false, the config verifiably took effect, but the crash persisted, tracing straight to that hard-coded parseAstAsync call in Vite's SSR internals, below any of our config knobs. I reverted that experiment.

For now, decorated Conductor-facing classes (index.ts) stay thin/untested-directly, and the actual logic under test lives in undecorated files (functions.ts, conductorAdapters.ts, utils.ts). Do you have any suggestions @leeyi45?

@Akshay-2007-1 Akshay-2007-1 marked this pull request as ready for review July 14, 2026 06:18

@AaravMalani AaravMalani left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM except documentation and the __bindExportedMethods function! I've made a couple non-blocking comments, though

*/
import { DataType, type IDataHandler, type TypedValue } from '@sourceacademy/conductor/types';
import { mEmptyList } from '@sourceacademy/conductor/util';
import type { List } from 'js-slang/dist/stdlib/list';

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe should remove our dependency on js-slang? It's not important right now, but would be a nice to have

const [noteName, accidental, octave] = noteToValues(note, letter_name_to_midi_note.name);
@moduleMethod([DataType.CONST_STRING], DataType.NUMBER)
async* letter_name_to_midi_note(note: TypedValue<DataType.CONST_STRING>): AsyncGenerator<void, TypedValue<DataType.NUMBER>, unknown> {
return { type: DataType.NUMBER, value: letter_name_to_midi_note_func(note.value as NoteWithOctave) };

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I disagree with the function signature of these functions; if they can accept any string and throw errors for invalid strings, they should accept strings and they shouldn't need the cast.

* key_signature_to_key(FLAT, 3); // Returns "Eb", since the key of Eb has 3 flats
* ```
*/
export function key_signature_to_key(accidental: Accidental.FLAT | Accidental.SHARP, numAccidentals: number): Note {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is unrelated to the migration and my circle of fifths is rusty, but doesn't every major key have a relative minor (D <-> Bm)? Shouldn't it be made clear in the documentation that it returns the major key / have a way to return it in a scale of the user's choosing?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually, all the scales are modes of the major scale, so technically we should be able to support it returning the key in any of the scales (I'm surprised natural and harmonic minor are missing from the scales)

static channelAttach = [];
constructor(conduit: IConduit, channels: IChannel<any>[], evaluator: IInterfacableEvaluator) {
super(conduit, channels, evaluator);
this.__bindExportedMethods();

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removable now (thanks!)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants