tetorica-fm2612 0.2.0 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -96,7 +96,7 @@ not copy assets. Keep dependent files alongside each deployed entry point.
96
96
 
97
97
  ## Mega CD PCM with MegaSynth
98
98
 
99
- The `0.2.0` local build adds opt-in RF5C164 support. Enable `megaCD` before
99
+ Since `0.2.0`, the package includes opt-in RF5C164 support. Enable `megaCD` before
100
100
  `start()`. YM2612, Sega PSG and RF5C164 share one AudioContext, master volume
101
101
  and effects chain. Sega PSG is enabled with Mega CD unless its URL is explicitly
102
102
  set to `null`. Existing FM-only construction stays unchanged.
@@ -137,6 +137,41 @@ otherwise they are siblings of the YM2612 assets. The FM command recorder
137
137
  continues to record FM/DAC actions; PCM commands are not recorded. This adds
138
138
  the Mega CD PCM chip, not CD disc-image emulation or 32X PWM.
139
139
 
140
+ ## YM2608 ADPCM-B sample loading
141
+
142
+ Since `0.2.2`, `YM2608Synth` provides `synth.adpcm.loadSample()`.
143
+ It accepts decoded `{channels, sampleRate}`, AudioBuffer, PCM/Float RIFF WAV
144
+ bytes, Blob, a browser URL, or a Node.js path/file URL. It mixes channels to
145
+ mono, encodes Yamaha ADPCM-B, transfers it, and configures the sample range
146
+ and playback rate. It does not start playback or change volume/pan.
147
+
148
+ ```js
149
+ const wave = Float32Array.from({length: 8000}, (_, i) =>
150
+ 0.5 * Math.sin(i * 2 * Math.PI * 440 / 8000));
151
+ const info = await synth.adpcm.loadSample(
152
+ {channels: [wave], sampleRate: 8000},
153
+ {address: 0},
154
+ );
155
+ synth.adpcm.setVolume(180);
156
+ synth.adpcm.setPan(true, true);
157
+ synth.adpcm.keyOn();
158
+ ```
159
+
160
+ Use a 32-byte aligned address and reserve non-overlapping areas of the 2 MiB
161
+ memory yourself. Loading stops ADPCM-B; it preserves FM, SSG and rhythm.
162
+ `info` contains `start`, exclusive `end`, `frames`, `paddedFrames`, actual
163
+ `sampleRate`, `deltaN` and padded `duration`. Alignment adds at most 63 decoded
164
+ frames of encoded silence. `keyOn({repeat: true})` repeats the padded range.
165
+ The optional `sampleRate` selects a conversion rate; by default the source
166
+ rate is capped to the chip range. Rate conversion uses linear interpolation.
167
+ WAV PCM 8/16/24/32-bit and float 32/64-bit work without AudioContext in Node
168
+ and browsers. Other formats require an optional `decodeAudio(arrayBuffer)`
169
+ callback returning AudioBuffer or decoded PCM. `signal` can cancel loading.
170
+ Worklet uploads complete before the returned promise resolves.
171
+
172
+ `loadMemory()` remains the API for already-encoded ADPCM-B bytes.
173
+ This does not add arbitrary sample loading to YM2608's fixed ADPCM-A rhythm.
174
+
140
175
  ## Local packaging
141
176
 
142
177
  From the repository root:
@@ -161,10 +196,10 @@ FM/PSG/PCM mixing, stop/reset and close/restart. Playwright is a development
161
196
  dependency; it is not required by users of the sound-chip runtime.
162
197
 
163
198
  The build is staged in `dist/fm2612/`; packing produces
164
- `tetorica-fm2612-0.2.0.tgz`. To install a local build in another project:
199
+ `tetorica-fm2612-0.2.2.tgz`. To install a local build in another project:
165
200
 
166
201
  ```sh
167
- npm install /absolute/path/to/tetorica-fm2612-0.2.0.tgz
202
+ npm install /absolute/path/to/tetorica-fm2612-0.2.2.tgz
168
203
  ```
169
204
 
170
205
  The existing `tetorica-vgm` CLI package is built separately. This first package
@@ -177,3 +212,14 @@ Project code is BSD-3-Clause. Third-party notices and component licenses are
177
212
  included; Nuked-OPN2 is LGPL-2.1-or-later, with its source and build script in
178
213
  `sources/`. The OPNA rhythm data is Tetorica-generated and includes its license
179
214
  and generator. External instrument/sample ROMs are not included.
215
+
216
+ ## Release notes
217
+
218
+ `0.2.2` adds YM2608 ADPCM-B `loadSample()` for decoded PCM, AudioBuffer and
219
+ PCM/Float WAV sources, with mono conversion, ADPCM-B encoding, memory upload
220
+ and range/rate setup. Worklet memory uploads are acknowledged, and OPN browser
221
+ runtimes release routing nodes when closed so they can restart safely.
222
+
223
+ `0.2.1` updates the npm Homepage link to
224
+ [tetorica-fm2612-examples](https://github.com/kyorohiro/tetorica-fm2612-examples).
225
+ Sound-chip runtime behavior is unchanged from `0.2.0`.
package/RUNTIME.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Full web runtime
2
2
 
3
- Includes all 131 top-level web runtime modules and 23 generated chip/engine pairs.
3
+ Includes all 132 top-level web runtime modules and 23 generated chip/engine pairs.
4
4
 
5
5
  ay8910, gameboy_apu, huc6280, k051649, nuked_opn2, okim6258, rf5c164, segapcm, segapsg, y8950, ym2151, ym2203, ym2413, ym2608, ym2610b, ym2612, ym3438, ym3526, ym3812, ymf262, ymf276, ymf278b, ymf288
6
6
 
@@ -0,0 +1,117 @@
1
+ /** Browser / Worker / Node: decode PCM WAV and encode Yamaha ADPCM-B.
2
+ * No AudioContext is created. Other formats can use an injected decodeAudio.
3
+ */
4
+ export async function readSamplePCM(source, {signal, decodeAudio} = {}) {
5
+ signal?.throwIfAborted();
6
+ if (source?.channels) return source;
7
+ if (typeof source?.getChannelData === 'function') {
8
+ return {sampleRate: source.sampleRate, channels: Array.from(
9
+ {length: source.numberOfChannels}, (_, i) => source.getChannelData(i))};
10
+ }
11
+ if (typeof source === 'string' || source instanceof URL) {
12
+ const node = typeof process !== 'undefined' && process.versions?.node;
13
+ if (node && (source instanceof URL ? source.protocol === 'file:' :
14
+ !/^(https?:|data:|blob:)/i.test(source))) {
15
+ const {readFile} = await import('node:fs/promises');
16
+ source = await readFile(typeof source === 'string' && source.startsWith('file:') ? new URL(source) : source, {signal});
17
+ } else {
18
+ const response = await fetch(source, {signal});
19
+ if (!response.ok) throw new Error(`Sample: HTTP ${response.status}`);
20
+ source = await response.arrayBuffer();
21
+ }
22
+ }
23
+ if (typeof Blob !== 'undefined' && source instanceof Blob) source = await source.arrayBuffer();
24
+ signal?.throwIfAborted();
25
+ if (source instanceof Uint8Array) source = source.buffer.slice(source.byteOffset, source.byteOffset + source.byteLength);
26
+ if (!(source instanceof ArrayBuffer)) throw new TypeError('Expected decoded PCM, AudioBuffer, WAV bytes, Blob, path or URL');
27
+ const view = new DataView(source);
28
+ const text = (offset, length) => Array.from({length}, (_, i) => String.fromCharCode(view.getUint8(offset + i))).join('');
29
+ if (view.byteLength < 12 || text(0, 4) !== 'RIFF' || text(8, 4) !== 'WAVE') {
30
+ if (!decodeAudio) throw new TypeError('Expected PCM/Float RIFF WAV; other formats require decodeAudio');
31
+ return readSamplePCM(await decodeAudio(source), {signal});
32
+ }
33
+ const end = view.getUint32(4, true) + 8;
34
+ if (end > view.byteLength || end < 12) throw new RangeError('Truncated WAV');
35
+ let format, data;
36
+ for (let offset = 12; offset + 8 <= end;) {
37
+ const size = view.getUint32(offset + 4, true), start = offset + 8;
38
+ if (start + size > end) throw new RangeError('Truncated WAV chunk');
39
+ const tag = text(offset, 4);
40
+ if (tag === 'fmt ') {
41
+ if (size < 16) throw new RangeError('Invalid WAV format');
42
+ format = {type: view.getUint16(start, true), count: view.getUint16(start + 2, true),
43
+ sampleRate: view.getUint32(start + 4, true), block: view.getUint16(start + 12, true),
44
+ bits: view.getUint16(start + 14, true)};
45
+ } else if (tag === 'data' && !data) data = {start, size};
46
+ offset = start + size + (size & 1);
47
+ }
48
+ if (!format || !data) throw new TypeError('WAV requires fmt and data chunks');
49
+ const {type, count, sampleRate, block, bits} = format;
50
+ if (!((type === 1 && [8, 16, 24, 32].includes(bits)) || (type === 3 && [32, 64].includes(bits)))) {
51
+ if (decodeAudio) return readSamplePCM(await decodeAudio(source), {signal});
52
+ throw new TypeError('Supported WAV: PCM 8/16/24/32-bit or float 32/64-bit');
53
+ }
54
+ if (count < 1 || count > 32 || !sampleRate || block !== count * bits / 8 || data.size % block) throw new RangeError('Invalid WAV layout');
55
+ const frames = data.size / block;
56
+ const channels = Array.from({length: count}, () => new Float32Array(frames));
57
+ for (let i = 0; i < frames; i++) for (let channel = 0; channel < count; channel++) {
58
+ const offset = data.start + i * block + channel * bits / 8;
59
+ let value;
60
+ if (type === 3) value = bits === 32 ? view.getFloat32(offset, true) : view.getFloat64(offset, true);
61
+ else if (bits === 8) value = (view.getUint8(offset) - 128) / 128;
62
+ else if (bits === 16) value = view.getInt16(offset, true) / 32768;
63
+ else if (bits === 32) value = view.getInt32(offset, true) / 2147483648;
64
+ else {
65
+ let raw = view.getUint8(offset) | view.getUint8(offset + 1) << 8 | view.getUint8(offset + 2) << 16;
66
+ if (raw & 0x800000) raw -= 0x1000000;
67
+ value = raw / 8388608;
68
+ }
69
+ channels[channel][i] = value;
70
+ }
71
+ return {channels, sampleRate};
72
+ }
73
+
74
+ /** Greedy encoder matching ymfm ADPCM-B: high nibble first, signed 16-bit
75
+ * predictor, initial step 127 and Yamaha's multiplicative step adjustment.
76
+ * Linear interpolation handles rate conversion; pad with encoded silence to
77
+ * the chip's 32-byte addressing boundary (64 decoded frames).
78
+ */
79
+ export function encodeAdpcmB({channels, sampleRate}, outputRate, maxBytes = 0x200000) {
80
+ if (!Array.isArray(channels) || !channels.length || channels.length > 32 ||
81
+ !Number.isFinite(sampleRate) || sampleRate <= 0 || !Number.isFinite(outputRate) || outputRate <= 0) throw new TypeError('Invalid PCM');
82
+ const length = channels[0]?.length;
83
+ if (!Number.isSafeInteger(length) || !length || channels.some(c => c.length !== length)) throw new RangeError('Invalid PCM channel lengths');
84
+ const frames = Math.max(1, Math.round(length * outputRate / sampleRate));
85
+ const byteLength = Math.ceil(frames / 64) * 32;
86
+ if (!Number.isSafeInteger(byteLength) || byteLength > maxBytes) throw new RangeError('Sample exceeds ADPCM-B memory');
87
+ const mono = new Float32Array(length);
88
+ for (let i = 0; i < length; i++) {
89
+ let value = 0;
90
+ for (const channel of channels) {
91
+ if (!Number.isFinite(channel[i])) throw new TypeError('Nonfinite PCM');
92
+ value += Math.max(-1, Math.min(1, channel[i])) / channels.length;
93
+ }
94
+ mono[i] = value;
95
+ }
96
+ const bytes = new Uint8Array(byteLength), scales = [57, 57, 57, 57, 77, 102, 128, 153];
97
+ let predictor = 0, step = 127;
98
+ for (let i = 0; i < byteLength * 2; i++) {
99
+ let target = 0;
100
+ if (i < frames) {
101
+ const position = Math.min(i * sampleRate / outputRate, length - 1);
102
+ const first = Math.floor(position), fraction = position - first;
103
+ const value = mono[first] * (1 - fraction) + mono[Math.min(first + 1, length - 1)] * fraction;
104
+ target = Math.round(value * (value < 0 ? 32768 : 32767));
105
+ }
106
+ let best = 0, bestValue = 0, error = Infinity;
107
+ for (let code = 0; code < 16; code++) {
108
+ const delta = Math.floor((2 * (code & 7) + 1) * step / 8) * (code & 8 ? -1 : 1);
109
+ const value = Math.max(-32768, Math.min(32767, predictor + delta));
110
+ if (Math.abs(value - target) < error) { best = code; bestValue = value; error = Math.abs(value - target); }
111
+ }
112
+ bytes[i >> 1] |= best << (i & 1 ? 0 : 4);
113
+ predictor = bestValue;
114
+ step = Math.max(127, Math.min(24576, Math.floor(step * scales[best & 7] / 64)));
115
+ }
116
+ return {bytes, frames, paddedFrames: byteLength * 2, sampleRate: outputRate};
117
+ }
@@ -86,7 +86,7 @@ CLI, pass the installed `dist/web/tetorica_ym2608_adpcm_rom.bin`
86
86
  path to `--ym2608-rom`. Version 0.2.1 predates this bundled asset.
87
87
 
88
88
  The YM2608 browser RuntimeSynth (including Playground) loads this synthetic
89
- ROM by default at startup. Select YM2608 and run `chip-saw/ym2608-rhythm.js` in
89
+ ROM by default at startup. Select YM2608 and run `pc98/ym2608-rhythm.js` in
90
90
  Playground to hear all six voices. Browser API callers can override the default
91
91
  using `new YM2608RuntimeSynth({rhythmRom: bytes})` or `{rhythmRomUrl: url}`;
92
92
  after startup, `synth.fm.rhythm.loadRom(bytes)` replaces the loaded data.
package/opn_fm_synth.js CHANGED
@@ -97,6 +97,37 @@ export class OPNWorkletTransport {
97
97
  this.node = node;
98
98
  this.portCount = portCount;
99
99
  this.chipName = chipName;
100
+ this.memorySequence = 0;
101
+ this.memoryRequests = new Map();
102
+ this.disposed = false;
103
+ this.onMemoryMessage = ({data}) => {
104
+ if (data?.type !== 'adpcm-memory-loaded') return;
105
+ const request = this.memoryRequests.get(data.id);
106
+ if (!request) return;
107
+ this.memoryRequests.delete(data.id);
108
+ data.error ? request.reject(new Error(data.error)) : request.resolve();
109
+ };
110
+ this.node.port.addEventListener?.('message', this.onMemoryMessage);
111
+ this.node.port.start?.();
112
+ }
113
+
114
+ loadAdpcmMemory(bytes, address = 0) {
115
+ if (this.disposed) return Promise.reject(new Error('ADPCM transport disposed'));
116
+ if (this.chipName !== 'YM2608') return Promise.reject(new Error('ADPCM memory upload requires YM2608'));
117
+ if (!(bytes instanceof Uint8Array) || !Number.isInteger(address) || address < 0 || address + bytes.length > 0x200000) return Promise.reject(new RangeError('Invalid ADPCM memory upload'));
118
+ const id = ++this.memorySequence;
119
+ return new Promise((resolve, reject) => {
120
+ this.memoryRequests.set(id, {resolve, reject});
121
+ try { this.node.port.postMessage({type: 'loadAdpcmMemory', id, bytes, address}); }
122
+ catch (error) { this.memoryRequests.delete(id); reject(error); }
123
+ });
124
+ }
125
+
126
+ dispose() {
127
+ this.disposed = true;
128
+ this.node.port.removeEventListener?.('message', this.onMemoryMessage);
129
+ for (const request of this.memoryRequests.values()) request.reject(new Error('ADPCM transport disposed'));
130
+ this.memoryRequests.clear();
100
131
  }
101
132
 
102
133
  reset() {
@@ -137,10 +137,13 @@ export class OPNRuntimeSynth {
137
137
  async #close() {
138
138
  this.audio.closeMedia();
139
139
  this.audio.disposeFXChain();
140
+ this.fm?.transport.dispose?.();
140
141
  this.node?.disconnect();
141
142
  this.node?.port.close();
142
143
  this.node = null;
143
144
  this.audio.disconnectRouting();
145
+ this.audio.masterInputNode = null;
146
+ this.audio.masterOutputNode = null;
144
147
  this.fm = null;
145
148
  this.readyPromise = null;
146
149
  this.state = "closed";
package/package.json CHANGED
@@ -1,9 +1,10 @@
1
1
  {
2
2
  "name": "tetorica-fm2612",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "description": "Tetorica sound chip WASM cores, Synth helpers and browser audio runtime",
5
5
  "type": "module",
6
6
  "license": "BSD-3-Clause",
7
+ "homepage": "https://github.com/kyorohiro/tetorica-fm2612-examples",
7
8
  "engines": { "node": ">=22" },
8
9
  "repository": {
9
10
  "type": "git",
@@ -1,5 +1,6 @@
1
1
  {
2
2
  "modules": [
3
+ "adpcm_b_sample.js",
3
4
  "ay8910.js",
4
5
  "ay8910audioengine.js",
5
6
  "bitcrusher-worklet.js",
package/ym2608-worklet.js CHANGED
@@ -18,6 +18,15 @@ class YM2608Processor extends AudioWorkletProcessor {
18
18
  const handle = data => {
19
19
  if (data.type === "initialize") void this.initialize(data.wasmBinary, data.rhythmRom);
20
20
  if (data.type === 'loadRhythmRom' && this.chip) this.chip.loadAdpcmARom(data.bytes);
21
+ if (data.type === 'loadAdpcmMemory') {
22
+ try {
23
+ if (!this.chip) throw new Error('YM2608 is not ready');
24
+ this.chip.loadAdpcmBMemory(data.bytes, data.address);
25
+ this.port.postMessage({type: 'adpcm-memory-loaded', id: data.id});
26
+ } catch (error) {
27
+ this.port.postMessage({type: 'adpcm-memory-loaded', id: data.id, error: String(error.message ?? error)});
28
+ }
29
+ }
21
30
  if (data.type === "write" && this.chip) this.write(data.port, data.register, data.value);
22
31
  if (data.type === "reset") this.chip?.reset();
23
32
  };
package/ym2608synth.js CHANGED
@@ -8,6 +8,7 @@ import { OPNDirectTransport, OPNFMSynth } from "./opn_fm_synth.js";
8
8
  import { OPNRuntimeSynth } from "./opn_runtime_synth.js";
9
9
  import { SSGSynth } from "./ssgsynth.js?v=ssg-period-1";
10
10
  import { YM2608_CLOCK } from "./ym2608.js";
11
+ import {readSamplePCM, encodeAdpcmB} from './adpcm_b_sample.js';
11
12
 
12
13
  /** Direct transport for YM2608 register operations. */
13
14
  export class YM2608DirectTransport extends OPNDirectTransport {
@@ -97,7 +98,7 @@ const adpcmInteger = (name, value, max) => {
97
98
  };
98
99
 
99
100
  /** YM2608 ADPCM-B external-memory playback, using 8-bit DRAM addressing (32-byte units).
100
- * Browser / Worker / Node.js: no file decoding, audio output or memory ownership here.
101
+ * Browser / Worker / Node.js. loadSample decodes PCM WAV without an audio device.
101
102
  */
102
103
  export class YM2608AdpcmSynth {
103
104
  /** @param {{write: function(number, number): void, loadMemory: function(Uint8Array, number): void}} transport
@@ -133,6 +134,33 @@ export class YM2608AdpcmSynth {
133
134
  if (bytes.length > 0x200000 - address) throw new RangeError("ADPCM-B memory exceeds 2 MiB");
134
135
  return this.transport.loadMemory(bytes, address);
135
136
  }
137
+ /** Decode PCM/AudioBuffer/WAV, mix to mono, encode ADPCM-B and select it.
138
+ * Does not start playback. The caller owns memory allocation: repeated calls
139
+ * at the same address replace the data. Range padding may add up to 63 frames.
140
+ * @param {*} source Decoded {channels, sampleRate}, AudioBuffer, WAV bytes, Blob or path/URL.
141
+ * @param {{address?: number, sampleRate?: number, signal?: AbortSignal, decodeAudio?: Function}} options
142
+ * @returns {Promise<{start:number,end:number,frames:number,paddedFrames:number,sampleRate:number,deltaN:number,duration:number}>}
143
+ */
144
+ async loadSample(source, {address = 0, sampleRate, signal, decodeAudio} = {}) {
145
+ adpcmInteger('address', address, 0x1fffff);
146
+ if (address % 32) throw new RangeError('ADPCM-B address must be 32-byte aligned');
147
+ const pcm = await readSamplePCM(source, {signal, decodeAudio});
148
+ const requested = sampleRate ?? Math.min(pcm.sampleRate, Math.floor(this.clock / 144));
149
+ if (!Number.isFinite(requested) || requested <= 0) throw new RangeError('Invalid ADPCM-B sample rate');
150
+ const deltaN = Math.round(requested * 144 * 65536 / this.clock);
151
+ if (deltaN < 1 || deltaN > 65535) throw new RangeError('ADPCM-B sample rate is outside the chip range');
152
+ const actualRate = deltaN * this.clock / (144 * 65536);
153
+ const encoded = encodeAdpcmB(pcm, actualRate, 0x200000 - address);
154
+ signal?.throwIfAborted();
155
+ this.keyOff();
156
+ await this.loadMemory(encoded.bytes, address);
157
+ signal?.throwIfAborted();
158
+ this.setSample({start: address, end: address + encoded.bytes.length});
159
+ this.setDeltaN(deltaN);
160
+ return {start: address, end: address + encoded.bytes.length, frames: encoded.frames,
161
+ paddedFrames: encoded.paddedFrames, sampleRate: actualRate, deltaN,
162
+ duration: encoded.paddedFrames / actualRate};
163
+ }
136
164
  /** Configure a byte range [start, end). Both boundaries must be 32-byte aligned.
137
165
  * Selects 8-bit DRAM mode and the full 2 MiB address limit; call while stopped.
138
166
  * @param {{start: number, end: number}} range End is exclusive, unlike the hardware register.