Skip to content

Latest commit

 

History

6 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

codesong

Trace a running Python program's function calls and turn the trace into music. Each function becomes a pitch and call depth becomes an octave, so recursion and hot loops become audible — a repo you'd share because it's delightful, and a way to hear how an algorithm behaves.

The trace recorder is a sys.settrace-based tool that watches a callable or script run and produces a canonical, deterministic list of call/return events. The pitch mapper then turns each call event into a Note -- a (pitch, duration, velocity) triple -- via a fixed hash of the function's name and its call depth. The MIDI writer renders that note list into the bytes of a playable .mid file, hand-rolled with no dependency on any MIDI library.

Install

Zero runtime dependencies — only the Python standard library is used.

git clone <this-repo>
cd codesong
pip install -e .

Requires Python 3.8+.

Usage

from codesong import trace_script

events = trace_script("examples/example_script.py")
for event in events[:6]:
    print(event)
CallEvent(seq=0, kind='call', qualname='<module>', filename='.../example_script.py', lineno=1, depth=1)
CallEvent(seq=1, kind='call', qualname='main', filename='.../example_script.py', lineno=17, depth=2)
CallEvent(seq=2, kind='call', qualname='factorial', filename='.../example_script.py', lineno=11, depth=3)
CallEvent(seq=3, kind='call', qualname='factorial', filename='.../example_script.py', lineno=11, depth=4)
...

You can also trace a specific callable instead of a whole script:

from codesong import trace_call

def add(a, b):
    return a + b

events = trace_call(add, 2, 3)

By default, only calls made from the same file (or directory, for trace_script) as the traced code are recorded — standard-library and third-party calls are filtered out automatically. Pass include_paths=[...] to widen or narrow that filter yourself.

Every event is a CallEvent: a sequence number, "call" or "return", the function's qualified name, its source file and line, and the call depth at that point. Events carry no wall-clock timestamp, so tracing the same deterministic program twice produces byte-for-byte identical output.

Mapping calls to notes

from codesong import trace_script, notes_for_trace

events = trace_script("examples/example_script.py")
notes = notes_for_trace(events)
for note in notes[:3]:
    print(note)
Note(pitch=55, duration=240, velocity=71)
Note(pitch=67, duration=120, velocity=88)
Note(pitch=62, duration=360, velocity=95)
...

notes_for_trace maps every "call" event to a Note (a MIDI pitch 0-127, a duration in ticks, and a velocity 0-127), in event order; "return" events produce no note of their own. The mapping is a pure function of a function's qualified name and its call depth: a stable hash (sha256, not the process-salted built-in hash()) of the name picks a degree on a consonant five-note scale and a "home" duration/velocity, and call depth transposes the pitch by whole octaves -- wrapping every few levels so deep recursion stays in range -- and quietens deeper calls. The same function at the same depth always sings the same note, anywhere it's called from, in any process.

Writing a MIDI file

from codesong import trace_script, notes_for_trace, write_midi_file

events = trace_script("examples/example_script.py")
notes = notes_for_trace(events)
write_midi_file("trace.mid", notes)

write_midi_file hand-writes the bytes of a standard, format-0 MIDI file: a header chunk followed by a single track chunk of note on / note off events, laid out back to back in event order so total playback time is the sum of every note's duration. notes_to_midi_bytes does the same rendering in memory if you want the raw bytes instead of writing to disk. Nothing here reads the clock, the environment, or any other process-varying state, so the same note list always produces byte-identical output -- this is checked directly with golden-file tests that compare against committed .mid fixtures.

No MIDI library is used: the Standard MIDI File format is a small, fully documented binary layout (a header chunk, a track chunk, and a handful of event types), well within reach of the standard library alone, so pulling in a dependency for it would trade a few dozen lines of code for an external package this project doesn't need.

Reference fixtures

tests/fixtures/ ships three small, deterministic scripts, each isolating one call shape that the recorder and pitch mapper need to handle correctly: recursion_fixture.py (a function that calls only itself), loop_fixture.py (a function called repeatedly from a for loop, never recursively), and mutual_fixture.py (two functions -- is_even/is_odd -- that call each other). Each has a committed golden .mid file, and test_reference_fixtures.py asserts both the shape of the trace (call counts, depth pattern) and that running the full trace -> notes -> MIDI pipeline on it reproduces that file byte-for-byte, so a change to the pitch mapping or the MIDI writer that would silently alter previously-shipped output is caught immediately.

Edge cases

tests/test_edge_cases.py covers three boundaries the reference fixtures above don't reach:

  • Deep recursion. deep_recursion_fixture.py recurses 250 levels deep -- far past pitch._OCTAVE_SPAN (4) -- and is pinned against a golden .mid file. Pitch keeps wrapping to whole octaves and velocity keeps clipping at its floor no matter how deep the call chain goes, so a trace never produces an out-of-range MIDI value or a runaway file.
  • Zero calls. Tracing with an include_paths filter that matches nothing produces an empty event list; notes_for_trace and notes_to_midi_bytes both accept that empty list and still produce a small, valid, playable MIDI file (just a header, a tempo event, and an end-of-track marker -- no notes).
  • Exceptions during trace. A function that raises and is caught by its caller still gets a matching "return" event -- CPython's sys.settrace fires "return" on exceptional exit too, so the recorder's call/return balance holds even for a frame that never reaches an explicit return statement. A function that raises uncaught propagates the exception out of trace_call/trace_script as-is (nothing here swallows errors in your code), and the recorder's try/finally still restores sys.settrace so a later, unrelated trace isn't left recording by accident.

Status

Built autonomously, gated on passing tests — every change here only ships once the test suite for it passes.

About

A zero-dependency Python library that traces a running program's function calls via sys.settrace and deterministically renders the trace into a MIDI file…

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages