-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathSharpModInterop.cs
More file actions
298 lines (276 loc) · 12.7 KB
/
Copy pathSharpModInterop.cs
File metadata and controls
298 lines (276 loc) · 12.7 KB
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
using System;
using System.Runtime.InteropServices.JavaScript;
using System.Text;
using SharpMod;
public static partial class SharpModInterop {
private static SoundFile? sf;
private static byte[] readBuffer = [];
[JSExport]
public static string Load(byte[] data, int sampleRate, bool is16Bit, bool stereo, bool loop) {
try {
sf = new SoundFile(data, (uint)sampleRate, is16Bit, stereo, loop);
readBuffer = [];
return sf.IsValid ? "" : "Unrecognized or invalid module file.";
} catch(Exception ex) {
sf = null;
return ex.Message;
}
}
[JSExport]
public static byte[] Read(int byteCount) {
if(sf is null || byteCount <= 0) return [];
if(readBuffer.Length != byteCount) readBuffer = new byte[byteCount];
uint read = sf.Read(readBuffer, (uint)byteCount);
if(read == 0) return [];
if(read == (uint)byteCount) return readBuffer;
byte[] trimmed = new byte[read];
Array.Copy(readBuffer, trimmed, (int)read);
return trimmed;
}
// One-shot metadata probe for a module that is *not* the loaded song, so the demo-track
// browser can describe a track without disturbing playback. The engine keeps no shared
// mutable static state, so a second SoundFile is safe to build and throw away; the ctor
// already runs PrecomputeStats(), which is where Length/AverageTempo come from.
//
// Returns one U+001F-separated record. Field 0 is "1" on success or "0" on failure, in
// which case field 1 is the reason. Success layout:
// [1, title, tracker, typeName, channels, patterns, samples, declaredSamples,
// orders, lengthSeconds, speed, tempo, avgTempo]
[JSExport]
public static string ProbeMetadata(byte[] data) {
try {
var probe = new SoundFile(data, 44100, true, true, false);
if(!probe.IsValid) return JoinFields("0", "Unrecognized or invalid module file.");
int patterns = 0;
if(probe.Patterns is not null) foreach(var p in probe.Patterns) if(p is not null) patterns++;
// Count slots that actually carry PCM rather than the declared total: this skips
// the unused instrument slot at index 0 and the empty tail most trackers leave.
int samples = 0;
if(probe.Instruments is not null) foreach(var i in probe.Instruments) if(i.Length > 0) samples++;
int orders = 0;
if(probe.Order is not null) foreach(byte o in probe.Order) if(o != 0xFF) orders++;
return JoinFields("1",
probe.Title,
probe.TrackerName,
probe.Type.ToString(),
probe.ActiveChannels.ToString(),
patterns.ToString(),
samples.ToString(),
probe.ActiveSamples.ToString(),
orders.ToString(),
probe.Length.ToString(),
probe.MusicSpeed.ToString(),
probe.MusicTempo.ToString(),
probe.AverageTempo.ToString());
} catch(Exception ex) {
return JoinFields("0", ex.Message);
}
}
private const char FieldSeparator = (char)31;
private static string JoinFields(params string[] fields) {
var sb = new StringBuilder();
for(int i = 0; i < fields.Length; i++) {
if(i > 0) sb.Append(FieldSeparator);
sb.Append(SanitizeField(fields[i]));
}
return sb.ToString();
}
// Tracker metadata is CP437 and routinely pads names with NULs and other control bytes,
// which would corrupt the separated record, so fold them to spaces before joining.
private static string SanitizeField(string? s) {
if(string.IsNullOrEmpty(s)) return "";
var sb = new StringBuilder(s.Length);
foreach(char c in s) sb.Append(c < ' ' || c == (char)127 ? ' ' : c);
return sb.ToString().Trim();
}
[JSExport] public static bool IsLoaded() => sf is not null;
[JSExport] public static string GetTitle() => sf?.Title ?? "";
[JSExport] public static string GetTrackerName() => sf?.TrackerName ?? "";
[JSExport] public static string GetTypeName() => (sf?.Type ?? SoundFile.Types.INVALID).ToString();
[JSExport] public static int GetTypeCode() => (int)(sf?.Type ?? SoundFile.Types.INVALID);
[JSExport] public static int GetSampleRate() => (int)(sf?.Rate ?? 0);
[JSExport] public static bool GetIs16Bit() => sf?.Is16Bit ?? false;
[JSExport] public static bool GetIsStereo() => sf?.IsStereo ?? false;
[JSExport] public static int GetActiveChannels() => (int)(sf?.ActiveChannels ?? 0);
[JSExport] public static int GetActiveSamples() => (int)(sf?.ActiveSamples ?? 0);
[JSExport] public static int GetPosition() => (int)(sf?.Position ?? 0);
[JSExport] public static int GetPositionCount() => (int)(sf?.PositionCount ?? 0);
[JSExport] public static int GetMusicSpeed() => (int)(sf?.MusicSpeed ?? 0);
[JSExport] public static int GetMusicTempo() => (int)(sf?.MusicTempo ?? 0);
[JSExport] public static int GetRow() => (int)(sf?.Row ?? 0);
[JSExport] public static int GetCurrentPattern() => (int)(sf?.CurrentPattern ?? 0);
[JSExport] public static int GetPattern() => (int)(sf?.Pattern ?? 0);
[JSExport] public static int GetNextPattern() => (int)(sf?.NextPattern ?? 0xFF);
[JSExport] public static int GetLengthSeconds() => (int)(sf?.Length ?? 0);
[JSExport] public static int GetAverageTempo() => (int)(sf?.AverageTempo ?? 0);
[JSExport] public static void SetPosition(int pos) { if(sf is not null) sf.Position = (uint)pos; }
// Order list: returns -1 when the entry is past the end of the song (0xFF terminator).
[JSExport]
public static int GetOrderAt(int index) {
if(sf is null) return -1;
var order = sf.Order;
if(index < 0 || index >= order.Length) return -1;
byte v = order[index];
return v == 0xFF ? -1 : v;
}
// Resolves the pattern index that the patterns view should display: sf.Pattern unless
// playback has fallen off the end, in which case fall back to the last real pattern in
// the order list (mirrors ConsoleRenderer.RenderPatterns).
[JSExport]
public static int GetDisplayPattern() {
if(sf is null) return -1;
uint p = sf.Pattern;
if(p != 0xFF) return (int)p;
var order = sf.Order;
for(int i = order.Length - 1; i >= 0; i--) if(order[i] != 0xFF) return order[i];
return -1;
}
// Per-frame channel snapshot, packed for one round-trip per frame.
// Layout: 6 ints per active channel, in order:
// [muted(0/1), instrumentIndex, currentVolume, pan(0..256), isStereoSample(0/1), isActive(0/1)]
[JSExport]
public static int[] GetChannelStates() {
if(sf is null) return [];
var ch = sf.Channels;
int n = Math.Min((int)sf.ActiveChannels, ch.Length);
int[] r = new int[n * 6];
for(int i = 0; i < n; i++) {
var c = ch[i];
bool active = c.Length > 0 && c.Pos < c.Length;
int k = i * 6;
r[k + 0] = c.Muted ? 1 : 0;
r[k + 1] = (int)c.InstrumentIndex;
r[k + 2] = c.CurrentVolume;
r[k + 3] = c.Pan;
r[k + 4] = c.IsStereo ? 1 : 0;
r[k + 5] = active ? 1 : 0;
}
return r;
}
[JSExport] public static bool GetChannelMuted(int idx) {
if(sf is null || idx < 0 || idx >= sf.ActiveChannels) return false;
return sf.Channels[idx].Muted;
}
[JSExport] public static void ToggleChannelMute(int idx) {
if(sf is null) return;
sf.ToggleMute((uint)idx);
}
// Returns all 64 rows of a pattern as a single string. Each row is a fixed-width
// concatenation of CommandToString() output (14 chars per channel), joined with '\n'.
// A patternIndex of -1 or 0xFF yields a string of blank rows for layout convenience.
[JSExport]
public static string GetPatternData(int patternIndex) {
if(sf is null) return "";
int nch = (int)sf.ActiveChannels;
var sb = new StringBuilder(64 * (nch * 14 + 1));
var pats = sf.Patterns;
// Treat out-of-range indices and unallocated pattern slots (XM can leave
// gaps in the pattern array) as empty so the view falls back to placeholders.
bool empty = patternIndex < 0 || patternIndex >= pats.Length || pats[patternIndex] is null;
for(int row = 0; row < 64; row++) {
for(int c = 0; c < nch; c++) {
sb.Append(empty ? "... .. ... ..." : sf.CommandToString((uint)patternIndex, (uint)row, c));
}
sb.Append('\n');
}
return sb.ToString();
}
[JSExport] public static int GetInstrumentCount() => sf?.Instruments?.Length ?? 0;
[JSExport] public static string GetInstrumentName(int index) {
if(sf is null) return "";
var ins = sf.Instruments;
if(index < 0 || index >= ins.Length) return "";
return ins[index].Name ?? "";
}
// Per-instrument metadata. Layout: [length, volume, is16(0/1), isStereo(0/1), loopStart, loopEnd].
[JSExport]
public static int[] GetInstrumentMeta(int index) {
if(sf is null) return [];
var ins = sf.Instruments;
if(index < 0 || index >= ins.Length) return [];
var i = ins[index];
return new int[] {
(int)i.Length,
i.Volume,
i.Is16Bit ? 1 : 0,
i.IsStereo ? 1 : 0,
(int)i.LoopStart,
(int)i.LoopEnd,
};
}
// Downsamples the instrument's PCM to (width) min/max pairs in [-1, 1], packed as
// interleaved [min0, max0, min1, max1, ...] of length width*2. Same per-column min/max
// envelope strategy the console renderer uses for its braille waveform, but at canvas
// resolution. Returns an empty array for empty / missing instruments.
[JSExport]
public static double[] GetWaveformEnvelope(int index, int width) {
if(sf is null || width <= 0) return [];
var ins = sf.Instruments;
if(index < 0 || index >= ins.Length) return [];
var i = ins[index];
if(i.Sample is null || i.Length == 0) return [];
bool is16 = i.Is16Bit;
bool stereo = i.IsStereo;
int stride = (is16 ? 2 : 1) * (stereo ? 2 : 1);
int frameCount = (int)i.Length;
int frameBytes = i.Sample.Length / stride;
if(frameCount > frameBytes) frameCount = frameBytes;
double[] r = new double[width * 2];
for(int p = 0; p < width; p++) {
int s0 = (int)((long)p * frameCount / width);
int s1 = (int)((long)(p + 1) * frameCount / width);
if(s1 <= s0) s1 = s0 + 1;
if(s1 > frameCount) s1 = frameCount;
double vmin = double.MaxValue, vmax = double.MinValue;
for(int s = s0; s < s1; s++) {
double v = ReadFrame(i.Sample, s, is16, stereo, stride);
if(v < vmin) vmin = v;
if(v > vmax) vmax = v;
}
if(vmin > vmax) { vmin = 0; vmax = 0; }
r[p * 2 + 0] = vmin;
r[p * 2 + 1] = vmax;
}
return r;
}
// Distinct playback-position ratios (0..1) for every channel currently sourcing
// the given instrument. Used by the samples view to draw per-voice cursors over
// the waveform.
[JSExport]
public static double[] GetInstrumentCursors(int index) {
if(sf is null) return [];
var channels = sf.Channels;
Span<double> tmp = stackalloc double[32];
int n = 0;
for(int c = 0; c < channels.Length && n < tmp.Length; c++) {
var ch = channels[c];
if(ch.Length == 0 || ch.InstrumentIndex != (uint)index) continue;
if(ch.Pos >= ch.Length) continue;
double r = (double)ch.Pos / ch.Length;
bool dup = false;
for(int k = 0; k < n; k++) if(tmp[k] == r) { dup = true; break; }
if(!dup) tmp[n++] = r;
}
double[] result = new double[n];
for(int k = 0; k < n; k++) result[k] = tmp[k];
return result;
}
private static double ReadFrame(byte[] data, int frame, bool is16, bool stereo, int stride) {
int off = frame * stride;
if(off + stride > data.Length) return 0;
if(is16) {
double l = (short)(data[off] | (data[off + 1] << 8)) / 32768.0;
if(stereo) {
double r = (short)(data[off + 2] | (data[off + 3] << 8)) / 32768.0;
return (l + r) * 0.5;
}
return l;
}
double l8 = (sbyte)data[off] / 128.0;
if(stereo) {
double r8 = (sbyte)data[off + 1] / 128.0;
return (l8 + r8) * 0.5;
}
return l8;
}
}