soundlog 0.8.0

Builder and parser for retro sound-chip register-write logs (VGM).
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
# soundlog

soundlog — builder, parser and stream-processor for retro sound-chip register-write logs

`soundlog` is a small crate for building and parsing register-write
logs for retro sound chips. It currently supports the VGM
(Video Game Music) file format.

Key features:
- Builder API to construct VGM documents programmatically.
- Parser support to read VGM data into a structured `VgmDocument`.
- Type-safe APIs: chip specifications and VGM commands are modeled as
  Rust types to help prevent invalid register writes at compile time.
- Stream processing: `VgmStream` provides a low-memory, iterator-based
  processor that can accept either chunked binary input (via `push_chunk`)
  or a pre-parsed `VgmDocument` (via `from_document`) and yields parsed
  `VgmCommand` values as they become available.
- Callback-based processing: `VgmCallbackStream` wraps `VgmStream` to provide
  callback registration for chip register writes with automatic state tracking
  and event detection (KeyOn, KeyOff, ToneChange).
- Memory limits: Configurable limits for data block accumulation (default 32 MiB)
  and parsing buffer size (default 64 MiB) prevent unbounded memory growth from
  untrusted input.
- Chip state tracking: Monitor register writes to track key on/off events and
  extract tone information (frequency, pitch) from sound chip registers in real-time.

## VgmStream overview

`VgmStream` is designed for streaming/real-time consumption of VGM data:
- It yields `VgmCommand` values wrapped in stream results as it parses
  input and as it generates writes from DAC streams.
- It understands DAC stream control commands (e.g. `SetupStreamControl`,
  `SetStreamData`, `SetStreamFrequency`, `StartStream`, `StartStreamFastCall`,
  `StopStream`) and will expand stream-generated writes into the output
  timeline at the correct sample positions.
- It also supports YM2612 direct DAC writes and expands them into corresponding
  `Ym2612Port0Address2AWriteAndWaitN`, `SeekOffset` commands on the stream timeline.
- While processing `Wait*` commands, the internal scheduler computes the outputs of the DAC stream
  (including YM2612 `Ym2612Port0Address2AWriteAndWaitN`) and normalises the timeline
  into register-write commands and `WaitSamples`.
  This process converts DAC stream events into explicit write commands and normalised waits,
  thereby preserving per-sample timing when multiple streams are active concurrently.
- DataBlock compression (e.g. bit-packed and DPCM streams) is automatically decompressed
  and expanded by the crate so compressed streams and their associated
  decompression tables are applied transparently.
- Memory limits are enforced to protect against malicious or malformed files:
  - Data block size limit (default 32 MiB, configurable via `set_max_data_block_size()`)
  - Parsing buffer size limit (for chunked parsing via `push_chunk()`) (default 64 MiB, configurable via `set_max_buffer_size()`)

## Examples

### `VgmBuilder` as builder

```rust
use soundlog::chip::{Chip, Ym2612Spec};
use soundlog::meta::Gd3;
use soundlog::vgm::command::{Instance, StreamChipType, WaitSamples};
use soundlog::vgm::detail::UncompressedStream;
use soundlog::{VgmBuilder, VgmDocument};
use std::path::PathBuf;

let mut builder = VgmBuilder::new();

// Register the chip's master clock in the VGM header (in Hz)
builder.register_chip(Chip::Ym2612, Instance::Primary, 7_670_454);
// Attach a DataBlock detail
builder.attach_data_block(UncompressedStream {
    chip_type: StreamChipType::Ym2612Pcm,
    data: vec![0x01, 0x02],
});
// Add chip register writes using a chip-specific spec
builder.add_chip_write(
    Instance::Primary,
    Ym2612Spec {
        port: 0,
        register: 0x22,
        value: 0x91,
    },
);
builder.add_vgm_command(WaitSamples(44100));
// ... add more commands

// Set loop offset (1: WaitSamples(44100))
builder.set_loop_offset(1);

// Set GD3 metadata for the document
builder.set_gd3(Gd3 {
    track_name_en: Some("Example Track".to_string()),
    game_name_en: Some("soundlog examples".to_string()),
    ..Default::default()
});

// Finalize the document (An `EndOfData` tag is automatically added)
let document: VgmDocument = builder.finalize();
// `into()` converts the finalized `VgmDocument` into VGM-format binary bytes
let _bytes: Vec<u8> = document.into();
```

### `VgmDocument` as parser

```rust
use soundlog::{VgmBuilder, VgmDocument};
use soundlog::vgm::command::{VgmCommand, WaitSamples};

// Read VGM bytes from somewhere
let bytes: Vec<u8> = /* read a .vgm file */ Vec::new();

// For this example we construct a VGM byte sequence using the builder
// and then parse it back.
let mut b = VgmBuilder::new();
b.add_vgm_command(WaitSamples(100));
b.add_vgm_command(WaitSamples(200));
let doc = b.finalize();
let bytes: Vec<u8> = (&doc).into();

// Parse the bytes into a `VgmDocument`
let document: VgmDocument = (bytes.as_slice())
    .try_into()
    .expect("failed to parse serialized VGM");

// Example: map commands to their sample counts and sum them.
let total_wait: u32 = document
    .iter()
    .map(|cmd| match cmd {
        VgmCommand::WaitSamples(s) => s.0 as u32,
        _ => 0,
    })
    .sum();

assert_eq!(total_wait, 300);
```

### `VgmStream::from_document`

The `from_document` constructor is convenient when you already have a
parsed `VgmDocument` (for example: constructed programmatically via the
`VgmBuilder`). The stream will expand DAC-stream-generated writes into
the emitted command sequence and split waits so emitted writes are
interleaved at the correct sample positions. All wait commands
(`WaitSamples`, `WaitNSample`, `Wait735Samples`, `Wait882Samples`, `Ym2612Port0Address2AWriteAndWaitN`)
are converted to `WaitSamples` for consistent processing.

```rust
use soundlog::{VgmBuilder, VgmStream, VgmDocument};
use soundlog::vgm::stream::StreamResult;
use soundlog::vgm::command::{
    VgmCommand, WaitSamples, WaitNSample, Wait735Samples,
    Wait882Samples, SetupStreamControl, StartStream, Instance
};
use soundlog::chip::Ym2612Spec;
use soundlog::vgm::detail::{parse_data_block, DataBlockType};

// Build a minimal document that contains a data block and stream control
// commands. (Builder helpers for data blocks / stream setup exist on the
// `VgmBuilder` type; see the vgm module docs for details.)
let mut b = VgmBuilder::new();
// Example: append a YM2612 chip register write using the chip-specific spec
b.add_chip_write(
    Instance::Primary,
    Ym2612Spec {
        port: 0,
        register: 0x22,
        value: 0x91,
    },
);
// (pseudo-code) append data block, configure stream and start it
// b.attach_data_block(...);
// b.add_vgm_command(SetupStreamControl { /* ... */ });
// b.add_vgm_command(StartStream { /* ... */ });
b.add_vgm_command(WaitSamples(8));
b.add_vgm_command(WaitNSample(5));  // 6 samples (5+1)
b.add_vgm_command(Wait735Samples);  // 735 samples
b.add_vgm_command(Wait882Samples);  // 882 samples

let doc: VgmDocument = b.finalize();

// Create a stream from the parsed document. The iterator will yield
// parsed commands as well as any stream-generated writes expanded into
// the timeline.
let mut stream = VgmStream::from_document(doc);
stream.set_loop_count(Some(2)); // Prevent infinite loops
while let Some(result) = stream.next() {
    match result {
        Ok(StreamResult::Command(cmd)) => match cmd {
            VgmCommand::WaitSamples(s) => {
                // All wait commands
                // (WaitSamples, WaitNSample, Wait735Samples,
                //  Wait882Samples, Ym2612Port0Address2AWriteAndWaitN)
                // are converted to WaitSamples by VgmStream.
                // Waits may also have been split to accommodate stream-generated writes.
                println!("wait {} samples", s.0);
            }
            VgmCommand::Ym2612Write(inst, spec) => {
                // We simply wait for WaitSamples and write to the register
                // without needing to concern ourselves with DAC Stream.
                println!("YM2612 write: {:?} {:?}", inst, spec);
            }
            VgmCommand::DataBlock(block) => {
                // Note: the stream may return certain DataBlock types back to the
                // caller instead of storing them; check the source for details.
                // These returned DataBlock types include:
                // - RomRamDump   (data_type 0x80..=0xBF)
                // - RamWrite16   (data_type 0xC0..=0xDF)
                // - RamWrite32   (data_type 0xE0..=0xFF)
                // The parse_data_block can parse the details of a DataBlock.
                match parse_data_block(block) {
                    Ok(DataBlockType::RomRamDump(dump)) => {
                        // Handle ROM/RAM dump here 
                        println!(
                            "ROM/RAM dump: {:?}, size {}, start 0x{:08X}",
                            dump.chip_type, dump.rom_size, dump.start_address
                        );
                    }
                    Ok(_) => {
                        println!("DataBlock parsed (non-ROM/RAM)");
                    }
                    Err((orig_block, err)) => {
                        eprintln!("Failed to parse DataBlock {:?}: {:?}", orig_block, err);
                    }
                }
            }
            VgmCommand::PcmRamWrite(write) => {
                // And also `PcmRamWrite` commands (opcode 0x68) may be emitted by the stream
                // instead of being stored as a DataBlock. Handle them here if you
                // need to process PCM RAM writes directly.
                println!("PCM RAM write: chip_type={:?} size={}", write.chip_type, write.size);
                // Process or store the PCM RAM write as appropriate.
            }
            other => {
                // Write to the target chips here (e.g. SN76489).
                // Implement actual playback / device I/O in this branch.                
            },
        },
        Ok(StreamResult::NeedsMoreData) => break,
        Ok(StreamResult::EndOfStream) => break,
        Err(e) => eprintln!("stream error: {:?}", e),
    }
}
```

### `VgmStream` — feeding raw byte chunks

If you cannot generate the `VgmDocument` all at once, you can use `push_chunk`.
This is useful when working with microcontrollers that have insufficient memory.

The required 'DataBlock' is allocated within the library. Please bear in mind the remaining memory.

Providing input via `push_chunk` is the only difference from the from_document example above.
As with that example, you should iterate over the stream and handle the StreamResult variants
(`Command`, `NeedsMoreData`, `EndOfStream` and `Err`) in the same way.

Note: When using `push_chunk`, ensure that the chunks start at the data_offset of the VGM header
— i.e. the serialised command/data region that begins at offset `0x34` + `header.data_offset`.

```rust
use soundlog::vgm::VgmStream;
use soundlog::vgm::stream::StreamResult;

let mut parser = VgmStream::new();
// Typically, the chunk size would be fixed at 1KB or similar.
let chunks = vec![vec![0x61, 0x44], vec![0x01], vec![0x62, 0x63]];

for chunk in chunks {
    parser.push_chunk(&chunk).expect("push chunk");
    for result in &mut parser {
        match result {
            Ok(StreamResult::Command(_)) => {},
            Ok(StreamResult::NeedsMoreData) => break,
            Ok(StreamResult::EndOfStream) => {
                // EndOfStream reached — the stream has no further data.
                // To loop playback, reset your chunk source to the loop
                // offset and call `push_chunk` again so the parser receives
                // the bytes from the loop point onward.
                break
            },
            Err(_) => break,
        }
    }
}
```

## VgmCallbackStream overview

`VgmCallbackStream` wraps `VgmStream` to provide automatic chip state tracking
and event-driven callbacks for real-time VGM processing:

- **Automatic State Tracking**: Enables per-chip state management for 35+ supported
  sound chips, automatically detecting register writes and maintaining internal state.
- **Event Detection**: Emits `StateEvent` notifications for key musical events:
  - `KeyOn`: Channel starts playing with tone/frequency information
  - `KeyOff`: Channel stops playing
  - `ToneChange`: Frequency changes while channel is active
- **Flexible Callbacks**: Register chip-specific callbacks using type-safe spec types
  (e.g., `Ym2612Spec`, `Sn76489Spec`) to handle register writes with sample timing
  and associated events.
- **Real-time Processing**: Low-overhead design suitable for streaming playback,
  with callbacks invoked automatically as commands are processed.
- **Comprehensive Chip Support**: (**WIP**) Works with all major sound chips including FM
  synthesizers (YM2612, YM2151, OPL series), PSG chips (SN76489, AY-8910),
  PCM chips, and more.

This enables building advanced VGM analysis tools, real-time visualizers,
and custom playback engines with minimal boilerplate code.

See also: the **Chip State Tracking** section below for current implementation status and a list of supported chips.

```rust
use soundlog::{VgmBuilder, VgmCallbackStream};
use soundlog::vgm::command::{Instance, WaitSamples, VgmCommand, EndOfData};
use soundlog::chip::{event::StateEvent, Ym2612Spec};

// Build a simple VGM document with YM2612 commands
let mut b = VgmBuilder::new();
b.register_chip(soundlog::chip::Chip::Ym2612, Instance::Primary, 7_670_454);
// Sound chip initialisation
// ..snip..
// Key on channel 1
b.add_chip_write(Instance::Primary, Ym2612Spec {
    port: 0,
    register: 0x28,
    value: 0xF0
});
b.add_vgm_command(VgmCommand::WaitSamples(WaitSamples(100)));
b.add_vgm_command(VgmCommand::EndOfData(EndOfData));
let doc = b.finalize();

// Grab the header's chip instances.
let instances = doc.header.chip_instances();

// Create VgmCallbackStream from VgmDocument
let mut callback_stream = VgmCallbackStream::from_document(doc);

// Automatically enable state tracking for every chip registered in the VGM header.
// This initializes per-instance state trackers using the clock values recorded in
// the document header; it is equivalent to calling `track_state` for each instance.
// Useful when a VGM contains multiple chip instances.
callback_stream.track_chips(&instances);

// If you prefer to enable tracking for a single chip instance manually,
// Enable state tracking for YM2612 at NTSC Genesis clock
// callback_stream.track_state::<soundlog::chip::state::Ym2612State>(
//     Instance::Primary, 7_670_454.0
// );

// Register callback for YM2612 writes
callback_stream.on_write(|inst, spec: Ym2612Spec, sample, events| {
    println!("YM2612[{:?}] @ sample {}: reg={:02X} val={:02X}",
             inst, sample, spec.register, spec.value);

    if let Some(events) = events {
        for event in events {
            match event {
                StateEvent::KeyOn { channel, tone } => {
                    println!("  → KeyOn ch={} freq={:.1}Hz",
                             channel, tone.freq_hz.unwrap_or(0.0));
                }
                StateEvent::KeyOff { channel } => {
                    println!("  → KeyOff ch={}", channel);
                }
                StateEvent::ToneChange { channel, tone } => {
                    println!("  → ToneChange ch={} freq={:.1}Hz",
                             channel, tone.freq_hz.unwrap_or(0.0));
                }
            }
        }
    }
});

// Process stream - callbacks fire automatically
for result in callback_stream {
    match result {
        Ok(_) => { /* callbacks already invoked */ }
        Err(e) => eprintln!("Error: {:?}", e),
    }
}
```

## Looping and EndOfData overview

- VgmDocument is a data representation only: When you construct a document using `VgmBuilder` and call `finalize()`, the builder will ensure the command stream contains an explicit `EndOfData` — if none is present it appends one.
- VgmStream is intended to act as a player-like iterator. When created from a parsed `VgmDocument` with `VgmStream::from_document()`, it can automatically loop at the documented loop point. Its `set_loop_count()` API controls how many playthroughs are performed:
  - `set_loop_count(Some(n))` — limit playback to `n` playthroughs (for example, `Some(1)` means play once and stop; `Some(2)` means play one full run and then one loop iteration).
  - `set_loop_count(None)` — infinite looping (the stream will jump back to the loop point on EndOfData and continue indefinitely).
- When the stream reaches an `EndOfData` command it handles looping and end-of-stream semantics internally. If a finite loop count is configured and the limit is reached the stream will stop; if an infinite loop is configured it will jump to the loop point and continue.
- Bytes-mode (raw chunk input via `push_chunk`) does not implicitly rewind or re-feed earlier bytes. The parser maintains a small internal buffer and parses incrementally; it does not replay previously-consumed bytes for you. If you feed the stream using `push_chunk()` and want to loop playback, you must re-supply the bytes starting at the loop point yourself (reset your chunk source to that offset and call `push_chunk` again). See the byte-feeding example above for a usage pattern.
- `VgmCallbackStream` wraps `VgmStream` and invokes callbacks for register writes and other commands as they are emitted. Note that `VgmStream` consumes the `EndOfData` command internally while implementing loop behavior; as a result the `on_end_of_data` callback registered on `VgmCallbackStream` will not be invoked in normal operation. To detect playback termination observe the iterator reaching `EndOfStream` (or the iterator returning `None` in the callback wrapper).
- Fadeout support: configure `set_fadeout_samples(Some(n))` on the stream to allow the stream to continue emitting commands for `n` samples after the final loop end, which can be used to implement graceful fadeouts. When fadeout is active the stream records the loop end sample and will keep yielding commands (or generated waits) until the fadeout period elapses, after which `EndOfStream` is returned.

## Chip State Tracking (WIP)

The `chip::state` module provides real-time state tracking for sound chips,
detecting key on/off events and extracting tone information from register writes.

- Chips with a check in the `Test` column have unit or integration tests that exercise their state-tracking behavior to a reasonable extent.
- The specifications for key on/off detection — including total level extraction — are not yet stabilized.
- The values returned by `StateEvent` shall not be compatible across versions. Please note that the timing and values of events may differ between versions.

### Implemented Chips

| Chip | Channels | Key On/Off | Tone Extract | System | Test |
|------|----------|------------|--------------|--------|------|
| **SN76489 (PSG)** | 3 tone + 1 noise ||| Master System, Game Gear, etc. ||
| **YM2413 (OPLL)** | 9 FM ||| MSX, SMS FM Unit ||
| **YM2612 (OPN2)** | 6 FM ||| Sega Genesis/Mega Drive ||
| **YM2151 (OPM)** | 8 FM ||| Arcade systems, etc. ||
| **SegaPcm** | N/A | N/A | N/A | Sega PCM chip | - |
| **Rf5c68** | N/A | N/A | N/A | RF5C68 PCM chip | - |
| **YM2203 (OPN)** | 3 FM + 3 PSG ||| NEC PC-8801, etc. ||
| **YM2608 (OPNA)** | 6 FM + 3 PSG ||| NEC PC-8801, etc. ||
| **YM2610b (OPNB)** | 6 FM + 3 PSG + ADPCM ||| Neo Geo, etc. ||
| **YM3812 (OPL2)** | 9 FM ||| AdLib, Sound Blaster ||
| **YM3526 (OPL)** | 9 FM ||| C64 Sound Expander, etc. ||
| **Y8950** | 9 FM + ADPCM ||| MSX ||
| **YMf262 (OPL3)** | 18 FM ||| Sound Blaster 16, etc. ||
| **YMF278b (OPL4)** | 18 FM + PCM ||| YMF278B ||
| **YMF271 (OPX)** | 12 FM + PCM ||| YMF271 | NOT WORKING |
| **K051649** | 5 ||| Konami SCC ||
| **SCC1 (K052539)** | 5 ||| Konami SCC (same as K051649) ||
| **YMZ280b** | N/A | N/A | N/A | YMZ280B PCM | - |
| **RF5C164** | N/A | N/A | N/A | RF5C164 PCM | - |
| **PWM** | N/A | N/A | N/A | Sega PWM | - |
| **AY8910** | 3 tone + noise ||| ZX Spectrum, MSX, etc. ||
| **GbDmg** | 4 ||| Game Boy ||
| **NesApu** | 5 ||| NES | ✅ (1) |
| **MultiPcm** | N/A | N/A | N/A | Sega MultiPCM | - |
| **UPD7759** | N/A | N/A | N/A | uPD7759 ADPCM | - |
| **OKIM6258** | N/A | N/A | N/A | OKIM6258 ADPCM | - |
| **OKIM6295** | N/A | N/A | N/A | OKIM6295 ADPCM | - |
| **K054539** | N/A | N/A | N/A | Konami K054539 PCM | - |
| **HUC6280** | 6 ||| PC Engine/TurboGrafx-16 ||
| **C140** | N/A | N/A | N/A | Namco C140 PCM | - |
| **K053260** | N/A | N/A | N/A | Konami K053260 PCM | - |
| **Pokey** | 4 ||| Atari 8-bit computers ||
| **QSound** | N/A | N/A | N/A | Capcom QSound | - |
| **SCSP** | N/A | N/A | N/A | Sega Saturn SCSP | - |
| **WonderSwan** | 4 ||| WonderSwan APU ||
| **VSU** | 6 ||| Virtual Boy VSU ||
| **SAA1099** | 6 ||| SAM Coupé, etc. ||
| **ES5503** | N/A | N/A | N/A | Ensoniq ES5503 | - |
| **ES5506** | N/A | N/A | N/A | Ensoniq ES5506 | - |
| **X1010** | N/A | N/A | N/A | Setia X1-010 | - |
| **C352** | N/A | N/A | N/A | Namco C352 | - |
| **GA20** | N/A | N/A | N/A | Irem GA20 | - |
| **Mikey** | N/A | N/A | N/A | Atari Lynx | - |
| **GameGearPsg** | 3 tone + 1 noise ||| Game Gear PSG (same as SN76489) ||

- (1) The NES APU state tracker does not currently support Famicom Disk System (FDS) expansion audio.  

## License

MIT License