Skip to content

Commit aab4127

Browse files
committedMar 25, 2019
applet.audio.yamaha_opl: implement WAV export.
This also makes the applet more efficient by performing the unsigned- to-signed conversion with numpy, or avoiding a roundtrip, in case libsamplerate is used.
1 parent 68bd607 commit aab4127

File tree

2 files changed

+136
-9
lines changed

2 files changed

+136
-9
lines changed
 

‎software/glasgow/applet/audio/yamaha_opl/__init__.py

+14-7
Original file line numberDiff line numberDiff line change
@@ -559,16 +559,21 @@ async def serve_index(self, request):
559559
return web.Response(text=index_html, content_type="text/html")
560560

561561
def _make_resampler(self, actual, preferred):
562+
import numpy
563+
562564
try:
563-
import numpy
564565
import samplerate
565566
except ImportError as e:
566567
self._logger.warning("samplerate not installed; expect glitches during playback")
567568
async def resample(input_queue, output_queue):
568569
while True:
569-
data = await input_queue.get()
570-
await output_queue.put(data)
571-
if not data:
570+
input_data = await input_queue.get()
571+
input_array = numpy.frombuffer(input_data, dtype="<u2")
572+
output_array = (output_array - 32768).astype(numpy.int16)
573+
if input_data:
574+
await output_queue.put(output_array.tobytes())
575+
if not input_data:
576+
await output_queue.put(b"")
572577
break
573578
return resample, actual
574579

@@ -578,7 +583,7 @@ def resample_worker(input_data, end):
578583
input_array = (input_array.astype(numpy.float32) - 32768) / 32768
579584
output_array = resampler.process(
580585
input_array, ratio=preferred / actual, end_of_input=end)
581-
output_array = (output_array * 32768 + 32768).astype(numpy.uint16)
586+
output_array = (output_array * 32768).astype(numpy.int16)
582587
return output_array.tobytes()
583588
async def resample(input_queue, output_queue):
584589
while True:
@@ -746,8 +751,10 @@ class AudioYamahaOPLApplet(GlasgowApplet, name="audio-yamaha-opl"):
746751
$ play -r 49715 output.u16
747752
748753
For the web interface, the browser dictates the sample rate. Streaming at the sample rate other
749-
than the one requested by the browser is possible, but degrades quality. To stream with
750-
the best possible quality, install the samplerate library.
754+
than the one requested by the browser is possible, but degrades quality. This interface also
755+
has additional Python dependencies:
756+
* numpy (mandatory)
757+
* samplerate (optional, required for best possible quality)
751758
"""
752759

753760
__pin_sets = ("d", "a")

‎software/glasgow/applet/audio/yamaha_opl/index.html

+122-2
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ <h1>Yamaha OP* Web Gateway</h1>
77
<p>This webpage lets you submit commands to a real Yamaha synthesizer and listen to the output. The synthesizer is a <b>shared resource</b> (and isn't very fast, although it does not have to run in real time), and everyone who submits a file is in the same queue, so you might have to wait until it's your turn.</p>
88
<p>Supported chips: {{chips}}.</p>
99
<p>Play a <a href="https://vgmrips.net/packs/chips">VGM/VGZ file</a>: <input type="file" id="file" accept=".vgm,.vgz"> <input type="button" id="play" value="Play"> <input type="button" id="replay" value="Replay" disabled> <input type="checkbox" id="loop"> <label for="loop">Loop</label></p>
10+
<p>Export PCM: <input type="button" id="exportFull" value="Export full"> <input type="button" id="exportLoop" value="Export loop"></p>
1011
<p>Status: <span id="chipStatus">no chip</span>, <span id="netStatus">idle</span>, <span id="playStatus">stopped</span>.</p>
1112
<p id="errorPane" style="color:red; display:none">Error: <span id="error"></span></p>
1213

@@ -27,6 +28,7 @@ <h1>Yamaha OP* Web Gateway</h1>
2728

2829
this._context = new (window.AudioContext || window.webkitAudioContext);
2930
this._buffers = [];
31+
this._buffersRaw = [];
3032

3133
this._queueStart = 0;
3234
this._queueEnd = 0;
@@ -43,12 +45,34 @@ <h1>Yamaha OP* Web Gateway</h1>
4345
dataView.byteLength / 2, sampleRate);
4446
var f32Buffer = audioBuffer.getChannelData(0);
4547
for(var i = 0; i < f32Buffer.length; i++)
46-
f32Buffer[i] = (dataView.getUint16(i * 2, /*littleEndian=*/true) - 32768) / 32768;
48+
f32Buffer[i] = dataView.getInt16(i * 2, /*littleEndian=*/true) / 32768;
4749

4850
this._buffers.push(audioBuffer);
51+
this._buffersRaw.push(dataView.buffer);
4952
this._updateStatus();
5053
}
5154

55+
this.getAllSamples = function() {
56+
return this._buffersRaw;
57+
}
58+
59+
this.getLoopSamples = function() {
60+
var buffers = [];
61+
var skipTo = this.loopSkipTo * 2;
62+
var sourcePos = 0;
63+
for(var i = 0; i < this._buffersRaw.length; i++) {
64+
if(skipTo >= sourcePos + this._buffersRaw[i].byteLength) {
65+
// skip
66+
} else if(skipTo >= sourcePos) {
67+
buffers.push(this._buffersRaw[i].slice(skipTo - sourcePos));
68+
} else {
69+
buffers.push(this._buffersRaw[i]);
70+
}
71+
sourcePos += this._buffersRaw[i].byteLength;
72+
}
73+
return buffers;
74+
}
75+
5276
this.scheduleAtLeast = function(atLeast) {
5377
if(this._buffers.length - this._queueEnd < atLeast) {
5478
console.log("need at least", atLeast, "buffers;",
@@ -143,8 +167,85 @@ <h1>Yamaha OP* Web Gateway</h1>
143167
}
144168
}
145169

170+
function makeWAVFile(fileName, numOfChannels, sampleRate, bytesPerSample, sampleBuffers) {
171+
var totalSampleBytes = 0;
172+
for(var i = 0; i < sampleBuffers.length; i++)
173+
totalSampleBytes += sampleBuffers[i].byteLength;
174+
175+
var id_length = 8,
176+
fmt_subchunk_length = id_length + 16,
177+
data_subchunk_length = id_length + totalSampleBytes,
178+
RIFF_header_length = id_length + 4,
179+
RIFF_chunk_length = RIFF_header_length + fmt_subchunk_length + data_subchunk_length;
180+
181+
var header = new ArrayBuffer(RIFF_header_length + fmt_subchunk_length + id_length);
182+
var headerView = new DataView(header, 0, header.length);
183+
184+
// "RIFF" chunk
185+
// ChunkID
186+
headerView.setUint32(0, /*"RIFF"*/0x52494646,
187+
/*littleEndian=*/false);
188+
// ChunkSize
189+
headerView.setUint32(4, RIFF_chunk_length - id_length,
190+
/*littleEndian=*/true);
191+
// Format
192+
headerView.setUint32(8, /*"WAVE"*/0x57415645,
193+
/*littleEndian=*/false);
194+
195+
// "fmt " subchunk
196+
// Subchunk1ID
197+
headerView.setUint32(12, /*"fmt "*/0x666d7420,
198+
/*littleEndian=*/false);
199+
// Subchunk1Size
200+
headerView.setUint32(16, fmt_subchunk_length - id_length,
201+
/*littleEndian=*/true);
202+
// AudioFormat
203+
headerView.setUint16(20, /*PCM*/1,
204+
/*littleEndian=*/true);
205+
// NumChannels
206+
headerView.setUint16(22, numOfChannels,
207+
/*littleEndian=*/true);
208+
// SampleRate
209+
headerView.setUint32(24, sampleRate,
210+
/*littleEndian=*/true);
211+
// ByteRate
212+
headerView.setUint32(28, sampleRate * numOfChannels * bytesPerSample,
213+
/*littleEndian=*/true);
214+
// BlockAlign
215+
headerView.setUint16(32, numOfChannels * bytesPerSample,
216+
/*littleEndian=*/true);
217+
// BitsPerSample
218+
headerView.setUint16(34, bytesPerSample * 8,
219+
/*littleEndian=*/true);
220+
221+
// "data" subchunk
222+
// Subchunk1ID
223+
headerView.setUint32(36, /*"data"*/0x64617461,
224+
/*littleEndian=*/false);
225+
// Subchunk1Size
226+
headerView.setUint32(40, totalSampleBytes,
227+
/*littleEndian=*/true);
228+
229+
return new File([header].concat(sampleBuffers), fileName, {type: "audio/wav"});
230+
}
231+
232+
function downloadFile(file) {
233+
if(window.fileUrl)
234+
URL.revokeObjectURL(window.fileUrl);
235+
window.fileUrl = URL.createObjectURL(file);
236+
237+
var a = window.document.createElement('a');
238+
a.href = window.fileUrl;
239+
a.download = file.name;
240+
document.body.appendChild(a);
241+
a.click();
242+
document.body.removeChild(a);
243+
}
244+
146245
var playButton = document.getElementById("play");
147246
var replayButton = document.getElementById("replay");
247+
var exportFullButton = document.getElementById("exportFull");
248+
var exportLoopButton = document.getElementById("exportLoop");
148249
var loopCheckbox = document.getElementById("loop");
149250
var fileInput = document.getElementById("file");
150251
var netStatusSpan = document.getElementById("netStatus");
@@ -155,6 +256,8 @@ <h1>Yamaha OP* Web Gateway</h1>
155256
playButton.onclick = function(event) {
156257
playButton.disabled = true;
157258
replayButton.disabled = true;
259+
exportFullButton.disabled = true;
260+
exportLoopButton.disabled = true;
158261

159262
var player = new PCMPlayer();
160263
window.player = player;
@@ -189,6 +292,7 @@ <h1>Yamaha OP* Web Gateway</h1>
189292
var xhr = new XMLHttpRequest();
190293
xhr.open("POST", "vgm", /*async=*/true);
191294
xhr.setRequestHeader("X-Preferred-Sample-Rate", player.preferredSampleRate());
295+
var sampleRate = 0;
192296
var seenBytes = 0;
193297
var totalSamples = 0;
194298
var seenSamples = 0;
@@ -203,6 +307,7 @@ <h1>Yamaha OP* Web Gateway</h1>
203307
errorPane.style.display = "none";
204308

205309
if(totalSamples == 0) {
310+
sampleRate = parseInt(xhr.getResponseHeader("X-Sample-Rate"));
206311
totalSamples = parseInt(xhr.getResponseHeader("X-Total-Samples"));
207312
player.loopSkipTo = parseInt(xhr.getResponseHeader("X-Loop-Skip-To"));
208313
chipStatusSpan.innerText = "chip " + xhr.getResponseHeader("X-Chip");
@@ -217,14 +322,17 @@ <h1>Yamaha OP* Web Gateway</h1>
217322
viewLength = bytes.buffer.byteLength;
218323
var view = new DataView(bytes.buffer, 0, viewLength);
219324
seenSamples += view.byteLength / 2;
220-
player.addSamples(view, parseInt(xhr.getResponseHeader("X-Sample-Rate")));
325+
player.addSamples(view, sampleRate);
221326
if(!player._playing)
222327
player.scheduleAtLeast(BUFFER_AT_LEAST);
223328
seenBytes += CHUNK_SIZE;
224329
}
225330

226331
if(xhr.readyState == 4) {
227332
player.complete = true;
333+
exportFullButton.disabled = false;
334+
if(player.loopSkipTo > 0)
335+
exportLoopButton.disabled = false;
228336
}
229337
}
230338

@@ -239,6 +347,18 @@ <h1>Yamaha OP* Web Gateway</h1>
239347
}
240348
netStatusSpan.innerText = "waiting";
241349
xhr.send(fileInput.files[0]);
350+
351+
exportFullButton.onclick = function(event) {
352+
var fileName = fileInput.files[0].name.replace(/\.vg[mz]$/i, "") + ".wav";
353+
downloadFile(makeWAVFile(fileName, /*numOfChannels=*/1, sampleRate, /*bytesPerSample=*/2,
354+
player.getAllSamples()));
355+
}
356+
357+
exportLoopButton.onclick = function(event) {
358+
var fileName = fileInput.files[0].name.replace(/\.vg[mz]$/i, "") + " (loop).wav";
359+
downloadFile(makeWAVFile(fileName, /*numOfChannels=*/1, sampleRate, /*bytesPerSample=*/2,
360+
player.getLoopSamples()));
361+
}
242362
};
243363
</script>
244364
<script type="text/javascript">

0 commit comments

Comments
 (0)
Please sign in to comment.