File size: 6,921 Bytes
b0bfea8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
/**
 * @typedef {Object} WaveMetadata
 * @property {string|undefined} title - the song's title
 * @property {string|undefined} artist - the song's artist
 * @property {string|undefined} album - the song's album
 * @property {string|undefined} genre - the song's genre
 */

import { combineArrays, IndexedByteArray } from "./indexed_array.js";
import { getStringBytes, writeStringAsBytes } from "./byte_functions/string.js";
import { writeRIFFOddSize } from "../soundfont/basic_soundfont/riff_chunk.js";
import { writeLittleEndian } from "./byte_functions/little_endian.js";

/**
 *
 * @param audioBuffer {AudioBuffer}
 * @param normalizeAudio {boolean} find the max sample point and set it to 1, and scale others with it
 * @param channelOffset {number} channel offset and channel offset + 1 get saved
 * @param metadata {WaveMetadata}
 * @param loop {{start: number, end: number}} loop start and end points in seconds. Undefined if no loop
 * @returns {Blob}
 */
export function audioBufferToWav(audioBuffer, normalizeAudio = true, channelOffset = 0, metadata = {}, loop = undefined)
{
    const channel1Data = audioBuffer.getChannelData(channelOffset);
    const channel2Data = audioBuffer.getChannelData(channelOffset + 1);
    const length = channel1Data.length;
    
    const bytesPerSample = 2; // 16-bit PCM
    
    // prepare INFO chunk
    let infoChunk = new IndexedByteArray(0);
    const infoOn = Object.keys(metadata).length > 0;
    // INFO chunk
    if (infoOn)
    {
        const encoder = new TextEncoder();
        const infoChunks = [
            getStringBytes("INFO"),
            writeRIFFOddSize("ICMT", encoder.encode("Created with SpessaSynth"), true)
        ];
        if (metadata.artist)
        {
            infoChunks.push(
                writeRIFFOddSize("IART", encoder.encode(metadata.artist), true)
            );
        }
        if (metadata.album)
        {
            infoChunks.push(
                writeRIFFOddSize("IPRD", encoder.encode(metadata.album), true)
            );
        }
        if (metadata.genre)
        {
            infoChunks.push(
                writeRIFFOddSize("IGNR", encoder.encode(metadata.genre), true)
            );
        }
        if (metadata.title)
        {
            infoChunks.push(
                writeRIFFOddSize("INAM", encoder.encode(metadata.title), true)
            );
        }
        infoChunk = writeRIFFOddSize("LIST", combineArrays(infoChunks));
    }
    
    // prepare CUE chunk
    let cueChunk = new IndexedByteArray(0);
    const cueOn = loop?.end !== undefined && loop?.start !== undefined;
    if (cueOn)
    {
        const loopStartSamples = Math.floor(loop.start * audioBuffer.sampleRate);
        const loopEndSamples = Math.floor(loop.end * audioBuffer.sampleRate);
        
        const cueStart = new IndexedByteArray(24);
        writeLittleEndian(cueStart, 0, 4); // dwIdentifier
        writeLittleEndian(cueStart, 0, 4); // dwPosition
        writeStringAsBytes(cueStart, "data"); // cue point ID
        writeLittleEndian(cueStart, 0, 4); // chunkStart, always 0
        writeLittleEndian(cueStart, 0, 4); // BlockStart, always 0
        writeLittleEndian(cueStart, loopStartSamples, 4); // sampleOffset
        
        const cueEnd = new IndexedByteArray(24);
        writeLittleEndian(cueEnd, 1, 4); // dwIdentifier
        writeLittleEndian(cueEnd, 0, 4); // dwPosition
        writeStringAsBytes(cueEnd, "data"); // cue point ID
        writeLittleEndian(cueEnd, 0, 4); // chunkStart, always 0
        writeLittleEndian(cueEnd, 0, 4); // BlockStart, always 0
        writeLittleEndian(cueEnd, loopEndSamples, 4); // sampleOffset
        
        const out = combineArrays([
            new IndexedByteArray([2, 0, 0, 0]), // cue points count,
            cueStart,
            cueEnd
        ]);
        cueChunk = writeRIFFOddSize("cue ", out);
    }
    
    // Prepare the header
    const headerSize = 44;
    const dataSize = length * 2 * bytesPerSample; // 2 channels, 16-bit per channel
    const fileSize = headerSize + dataSize + infoChunk.length + cueChunk.length - 8; // total file size minus the first 8 bytes
    const header = new Uint8Array(headerSize);
    
    // 'RIFF'
    header.set([82, 73, 70, 70], 0);
    // file length
    header.set(
        new Uint8Array([fileSize & 0xff, (fileSize >> 8) & 0xff, (fileSize >> 16) & 0xff, (fileSize >> 24) & 0xff]),
        4
    );
    // 'WAVE'
    header.set([87, 65, 86, 69], 8);
    // 'fmt '
    header.set([102, 109, 116, 32], 12);
    // fmt chunk length
    header.set([16, 0, 0, 0], 16); // 16 for PCM
    // audio format (PCM)
    header.set([1, 0], 20);
    // number of channels (2)
    header.set([2, 0], 22);
    // sample rate
    const sampleRate = audioBuffer.sampleRate;
    header.set(
        new Uint8Array([sampleRate & 0xff, (sampleRate >> 8) & 0xff, (sampleRate >> 16) & 0xff, (sampleRate >> 24) & 0xff]),
        24
    );
    // byte rate (sample rate * block align)
    const byteRate = sampleRate * 2 * bytesPerSample; // 2 channels, 16-bit per channel
    header.set(
        new Uint8Array([byteRate & 0xff, (byteRate >> 8) & 0xff, (byteRate >> 16) & 0xff, (byteRate >> 24) & 0xff]),
        28
    );
    // block align (channels * bytes per sample)
    header.set([4, 0], 32); // 2 channels * 16-bit per channel / 8
    // bits per sample
    header.set([16, 0], 34); // 16-bit
    
    // data chunk identifier 'data'
    header.set([100, 97, 116, 97], 36);
    // data chunk length
    header.set(
        new Uint8Array([dataSize & 0xff, (dataSize >> 8) & 0xff, (dataSize >> 16) & 0xff, (dataSize >> 24) & 0xff]),
        40
    );
    
    let wavData = new Uint8Array(fileSize + 8);
    let offset = headerSize;
    wavData.set(header, 0);
    
    // Interleave audio data (combine channels)
    let multiplier = 32767;
    if (normalizeAudio)
    {
        // find min and max values to prevent clipping when converting to 16 bits
        const maxAbsValue = channel1Data.map((v, i) => Math.max(Math.abs(v), Math.abs(channel2Data[i])))
            .reduce((a, b) => Math.max(a, b));
        multiplier = maxAbsValue > 0 ? (32767 / maxAbsValue) : 1;
    }
    for (let i = 0; i < length; i++)
    {
        // interleave both channels
        const sample1 = Math.min(32767, Math.max(-32768, channel1Data[i] * multiplier));
        const sample2 = Math.min(32767, Math.max(-32768, channel2Data[i] * multiplier));
        
        // convert to 16-bit
        wavData[offset++] = sample1 & 0xff;
        wavData[offset++] = (sample1 >> 8) & 0xff;
        wavData[offset++] = sample2 & 0xff;
        wavData[offset++] = (sample2 >> 8) & 0xff;
    }
    
    if (infoOn)
    {
        wavData.set(infoChunk, offset);
        offset += infoChunk.length;
    }
    if (cueOn)
    {
        wavData.set(cueChunk, offset);
    }
    
    return new Blob([wavData.buffer], { type: "audio/wav" });
}