Context
The Grain IR (onset, duration, pointer_pos, pitch_ratio, volume, pan) is fully computed in stream.voices after generate_grains() but never persisted as a structured format — only as Csound .sco lines or accumulated into audio buffers. A visualization client (PGE-ui) needs per-grain data to draw grain rectangles inside each stream's timeline clip.
Proposed change
Add a --grain-json CLI flag (only active with --per-stream) that writes:
cache/<yaml_basename>__<stream_id>_grains.json
JSON schema:
{
"stream_id": "stream1",
"duration": 8.0,
"num_voices": 4,
"grains": [
{"t": 0.000, "dur": 0.08, "vol": -6.0, "ptr": 0.34, "v": 0},
...
]
}
t = onset relative to stream start (grain.onset - stream.onset)
v = voice index
- Flat array sorted by
t, compact JSON (no whitespace)
Implementation
New file: src/export/grain_json_writer.py
- Class
GrainJsonWriter with write(stream, output_dir, yaml_basename) -> Path
- Follows pattern of
src/export/reaper_project_writer.py
Modified: src/main.py
- Add
--grain-json argparse flag
- After
create_elements(), before engine.render(): iterate generator.streams and call writer.write()
New test: tests/export/test_grain_json_writer.py
- Follows pattern of
tests/export/test_reaper_project_writer.py
Notes
- Writing happens for all streams every run (grains are always recomputed anyway)
pointer_pos units may vary per stream — document in schema
- Voice onset offsets can produce
t < 0 grains — valid data, consumers must handle
Context
The
GrainIR (onset,duration,pointer_pos,pitch_ratio,volume,pan) is fully computed instream.voicesaftergenerate_grains()but never persisted as a structured format — only as Csound.scolines or accumulated into audio buffers. A visualization client (PGE-ui) needs per-grain data to draw grain rectangles inside each stream's timeline clip.Proposed change
Add a
--grain-jsonCLI flag (only active with--per-stream) that writes:JSON schema:
{ "stream_id": "stream1", "duration": 8.0, "num_voices": 4, "grains": [ {"t": 0.000, "dur": 0.08, "vol": -6.0, "ptr": 0.34, "v": 0}, ... ] }t= onset relative to stream start (grain.onset - stream.onset)v= voice indext, compact JSON (no whitespace)Implementation
New file:
src/export/grain_json_writer.pyGrainJsonWriterwithwrite(stream, output_dir, yaml_basename) -> Pathsrc/export/reaper_project_writer.pyModified:
src/main.py--grain-jsonargparse flagcreate_elements(), beforeengine.render(): iterategenerator.streamsand callwriter.write()New test:
tests/export/test_grain_json_writer.pytests/export/test_reaper_project_writer.pyNotes
pointer_posunits may vary per stream — document in schemat < 0grains — valid data, consumers must handle