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.
Zero runtime dependencies — only the Python standard library is used.
git clone <this-repo>
cd codesong
pip install -e .Requires Python 3.8+.
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.
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.
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.
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.
tests/test_edge_cases.py covers three boundaries the reference fixtures
above don't reach:
- Deep recursion.
deep_recursion_fixture.pyrecurses 250 levels deep -- far pastpitch._OCTAVE_SPAN(4) -- and is pinned against a golden.midfile. 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_pathsfilter that matches nothing produces an empty event list;notes_for_traceandnotes_to_midi_bytesboth 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'ssys.settracefires"return"on exceptional exit too, so the recorder's call/return balance holds even for a frame that never reaches an explicitreturnstatement. A function that raises uncaught propagates the exception out oftrace_call/trace_scriptas-is (nothing here swallows errors in your code), and the recorder'stry/finallystill restoressys.settraceso a later, unrelated trace isn't left recording by accident.
Built autonomously, gated on passing tests — every change here only ships once the test suite for it passes.