Skip to content

Commit 483675f

Browse files
committed
c64: .tap export
1 parent 64ef7cc commit 483675f

File tree

2 files changed

+335
-6
lines changed

2 files changed

+335
-6
lines changed

src/common/audio/CommodoreTape.ts

Lines changed: 305 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,305 @@
1+
/*
2+
https://github.com/eightbitjim/commodore-tape-maker/blob/master/maketape.py
3+
4+
# MIT License
5+
#
6+
# Copyright (c) 2018 eightbitjim
7+
#
8+
# Permission is hereby granted, free of charge, to any person obtaining a copy
9+
# of this software and associated documentation files (the "Software"), to deal
10+
# in the Software without restriction, including without limitation the rights
11+
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
12+
# copies of the Software, and to permit persons to whom the Software is
13+
# furnished to do so, subject to the following conditions:
14+
#
15+
# The above copyright notice and this permission notice shall be included in all
16+
# copies or substantial portions of the Software.
17+
#
18+
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
19+
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
20+
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
21+
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
22+
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
23+
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
24+
# SOFTWARE.
25+
*/
26+
27+
export class OutputSoundFile {
28+
options: any;
29+
sampleRate: number;
30+
soundData: number[];
31+
tapData: number[];
32+
33+
constructor(options: any) {
34+
this.options = options;
35+
this.sampleRate = 44100.0;
36+
this.soundData = [];
37+
//00000000 43 36 34 2d 54 41 50 45 2d 52 41 57 01 00 00 00 |C64-TAPE-RAW....|
38+
//00000010 1e 62 03 00
39+
this.tapData = [0x43,0x36,0x34,0x2d,0x54,0x41,0x50,0x45,0x2d,0x52,0x41,0x57,0x01,0x00,0x00,0x00,0,0,0,0];
40+
}
41+
42+
getWAVHeader() {
43+
const header = new Uint8Array(44);
44+
const view = new DataView(header.buffer);
45+
view.setUint32(0, 0x52494646, false); // "RIFF"
46+
view.setUint32(4, 44 + this.soundData.length, true); // ChunkSize
47+
view.setUint32(8, 0x57415645, false); // "WAVE"
48+
view.setUint32(12, 0x666d7420, false); // "fmt "
49+
view.setUint32(16, 16, true); // Subchunk1Size
50+
view.setUint16(20, 1, true); // AudioFormat (PCM)
51+
view.setUint16(22, 1, true); // NumChannels
52+
view.setUint32(24, this.sampleRate, true); // SampleRate
53+
view.setUint32(28, this.sampleRate * 2, true); // ByteRate
54+
view.setUint16(32, 1, true); // BlockAlign
55+
view.setUint16(34, 8, true); // BitsPerSample
56+
view.setUint32(36, 0x64617461, false); // "data"
57+
view.setUint32(40, this.soundData.length, true); // Subchunk2Size
58+
return header;
59+
}
60+
61+
addSilence(lengthInSeconds: number): void {
62+
const numberOfSamples = Math.floor(this.sampleRate * lengthInSeconds);
63+
for (let i = 0; i < numberOfSamples; i++) {
64+
this.soundData.push(0);
65+
}
66+
//For v1 and v2 TAP files, a $00 value is followed by 3 bytes containing the actual duration measured in clock cycles (not divided by 8). These 3 bytes are in low-high format.
67+
const numCycles = TAPFile.CLOCK_RATE * lengthInSeconds;
68+
this.tapData.push(0);
69+
this.tapData.push(numCycles & 0xff);
70+
this.tapData.push((numCycles >> 8) & 0xff);
71+
this.tapData.push((numCycles >> 16) & 0xff);
72+
}
73+
74+
addCycle(cycles: number): void {
75+
this.tapData.push(cycles);
76+
const numberOfSamples = Math.floor(this.sampleRate * TAPFile.TAP_LENGTH_IN_SECONDS * cycles);
77+
for (let i = 0; i < numberOfSamples; i++) {
78+
let value;
79+
if (this.options.sine_wave) {
80+
value = - Math.sin((i / numberOfSamples) * 2.0 * Math.PI);
81+
} else {
82+
if (i < numberOfSamples / 2) {
83+
value = -1;
84+
} else {
85+
value = 1;
86+
}
87+
}
88+
if (this.options.invert_waveform) {
89+
value = -value;
90+
}
91+
this.soundData.push(Math.round(128 + value * 127));
92+
}
93+
}
94+
95+
updateTAPHeader() {
96+
let datalen = this.tapData.length - 0x14;
97+
// set bytes 0x10-0x13 to length
98+
this.tapData[0x10] = datalen & 0xff;
99+
this.tapData[0x11] = (datalen >> 8) & 0xff;
100+
this.tapData[0x12] = (datalen >> 16) & 0xff;
101+
this.tapData[0x13] = (datalen >> 24) & 0xff;
102+
}
103+
104+
getTAPData(): Uint8Array {
105+
this.updateTAPHeader();
106+
return new Uint8Array(this.tapData);
107+
}
108+
109+
getSoundData(): Uint8Array {
110+
let header = this.getWAVHeader();
111+
let data = new Uint8Array(header.length + this.soundData.length);
112+
data.set(header, 0);
113+
data.set(new Uint8Array(this.soundData), header.length);
114+
return data;
115+
}
116+
}
117+
118+
export class TAPFile {
119+
120+
static CLOCK_RATE = 985248.0;
121+
static TAP_LENGTH_IN_SECONDS = 8.0 / this.CLOCK_RATE;
122+
static FILENAME_BUFFER_SIZE = 0x10;
123+
static FILE_TYPE_NONE = 0;
124+
static FILE_TYPE_RELOCATABLE = 1;
125+
static FILE_TYPE_SEQUENTIAL = 2;
126+
static FILE_TYPE_NON_RELOCATABLE = 3;
127+
static LEADER_TYPE_HEADER = 0;
128+
static LEADER_TYPE_CONTENT = 1;
129+
static LEADER_TYPE_REPEATED = 2;
130+
static NUMBER_OF_PADDING_BYTES = 171;
131+
static PADDING_CHARACTER = 0x20;
132+
static SHORT_PULSE = 0x30;
133+
static MEDIUM_PULSE = 0x42;
134+
static LONG_PULSE = 0x56;
135+
136+
options: any;
137+
checksum: number;
138+
data: Uint8Array;
139+
filenameData: number[];
140+
startAddress: number;
141+
endAddress: number;
142+
fileType: number;
143+
waveFile: OutputSoundFile;
144+
145+
constructor(filename: string, options?: any) {
146+
this.options = options;
147+
this.checksum = 0;
148+
this.data = new Uint8Array(0);
149+
this.filenameData = this.makeFilename(filename);
150+
this.startAddress = 0;
151+
this.endAddress = 0;
152+
this.fileType = TAPFile.FILE_TYPE_NONE;
153+
this.waveFile = null;
154+
}
155+
156+
makeFilename(filename: string): number[] {
157+
const filenameBuffer = [];
158+
const space = 0x20;
159+
filename = filename.toUpperCase(); // for PETSCII
160+
for (let i = 0; i < TAPFile.FILENAME_BUFFER_SIZE; i++) {
161+
if (filename.length <= i) {
162+
filenameBuffer.push(space);
163+
} else {
164+
let ch = filename.charCodeAt(i);
165+
filenameBuffer.push(ch);
166+
}
167+
}
168+
return filenameBuffer;
169+
}
170+
171+
setContent(inputFile: { data: Uint8Array, startAddress: number, type: number }): void {
172+
this.data = inputFile.data;
173+
this.startAddress = inputFile.startAddress;
174+
this.endAddress = inputFile.startAddress + inputFile.data.length;
175+
this.fileType = inputFile.type;
176+
}
177+
178+
generateSound(outputWaveFile: OutputSoundFile): void {
179+
this.waveFile = outputWaveFile;
180+
this.addHeader(false);
181+
this.addHeader(true);
182+
outputWaveFile.addSilence(0.1);
183+
this.addFile();
184+
}
185+
186+
addTapCycle(tapValue: number): void {
187+
this.waveFile.addCycle(tapValue);
188+
}
189+
190+
addBit(value: number): void {
191+
if (value === 0) {
192+
this.addTapCycle(TAPFile.SHORT_PULSE);
193+
this.addTapCycle(TAPFile.MEDIUM_PULSE);
194+
} else {
195+
this.addTapCycle(TAPFile.MEDIUM_PULSE);
196+
this.addTapCycle(TAPFile.SHORT_PULSE);
197+
}
198+
}
199+
200+
addDataMarker(moreToFollow: boolean): void {
201+
if (moreToFollow) {
202+
this.addTapCycle(TAPFile.LONG_PULSE);
203+
this.addTapCycle(TAPFile.MEDIUM_PULSE);
204+
} else {
205+
this.addTapCycle(TAPFile.LONG_PULSE);
206+
this.addTapCycle(TAPFile.SHORT_PULSE);
207+
}
208+
}
209+
210+
resetChecksum(): void {
211+
this.checksum = 0;
212+
}
213+
214+
addByteFrame(value: number, moreToFollow: boolean): void {
215+
let checkBit = 1;
216+
for (let i = 0; i < 8; i++) {
217+
const bit = (value & (1 << i)) !== 0 ? 1 : 0;
218+
this.addBit(bit);
219+
checkBit ^= bit;
220+
}
221+
this.addBit(checkBit);
222+
this.addDataMarker(moreToFollow);
223+
this.checksum ^= value;
224+
}
225+
226+
addLeader(fileType: number): void {
227+
let numberofPulses;
228+
if (fileType === TAPFile.LEADER_TYPE_HEADER) {
229+
numberofPulses = 0x6a00;
230+
} else if (fileType === TAPFile.LEADER_TYPE_CONTENT) {
231+
numberofPulses = 0x1a00;
232+
} else {
233+
numberofPulses = 0x4f;
234+
}
235+
for (let i = 0; i < numberofPulses; i++) {
236+
this.addTapCycle(TAPFile.SHORT_PULSE);
237+
}
238+
}
239+
240+
addSyncChain(repeated: boolean): void {
241+
let value;
242+
if (repeated) {
243+
value = 0x09;
244+
} else {
245+
value = 0x89;
246+
}
247+
let count = 9;
248+
while (count > 0) {
249+
this.addByteFrame(value, true);
250+
value -= 1;
251+
count -= 1;
252+
}
253+
}
254+
255+
addData(): void {
256+
for (let i = 0; i < this.data.length; i++) {
257+
this.addByteFrame(this.data[i], true);
258+
}
259+
}
260+
261+
addFilename(): void {
262+
for (let i = 0; i < this.filenameData.length; i++) {
263+
this.addByteFrame(this.filenameData[i], true);
264+
}
265+
}
266+
267+
addHeader(repeated: boolean): void {
268+
if (repeated) {
269+
this.addLeader(TAPFile.LEADER_TYPE_REPEATED);
270+
} else {
271+
this.addLeader(TAPFile.LEADER_TYPE_HEADER);
272+
}
273+
this.addDataMarker(true);
274+
this.addSyncChain(repeated);
275+
this.resetChecksum();
276+
this.addByteFrame(this.fileType, true);
277+
this.addByteFrame(this.startAddress & 0x00ff, true);
278+
this.addByteFrame((this.startAddress & 0xff00) >> 8, true);
279+
this.addByteFrame(this.endAddress & 0x00ff, true);
280+
this.addByteFrame((this.endAddress & 0xff00) >> 8, true);
281+
this.addFilename();
282+
for (let i = 0; i < TAPFile.NUMBER_OF_PADDING_BYTES; i++) {
283+
this.addByteFrame(TAPFile.PADDING_CHARACTER, true);
284+
}
285+
this.addByteFrame(this.checksum, false);
286+
}
287+
288+
addFile(): void {
289+
let repeated = false;
290+
for (let i = 0; i < 2; i++) {
291+
if (!repeated) {
292+
this.addLeader(TAPFile.LEADER_TYPE_CONTENT);
293+
} else {
294+
this.addLeader(TAPFile.LEADER_TYPE_REPEATED);
295+
}
296+
this.addDataMarker(true);
297+
this.addSyncChain(repeated);
298+
this.resetChecksum();
299+
this.addData();
300+
this.addByteFrame(this.checksum, false);
301+
repeated = true;
302+
}
303+
this.addLeader(1);
304+
}
305+
}

src/ide/ui.ts

Lines changed: 30 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import { isMobileDevice } from "./views/baseviews";
2121
import { CallStackView, DebugBrowserView } from "./views/treeviews";
2222
import { saveAs } from "file-saver";
2323
import DOMPurify = require("dompurify");
24+
import { OutputSoundFile, TAPFile } from "../common/audio/CommodoreTape";
2425

2526
// external libs (TODO)
2627
declare var Tour, GIF, Octokat;
@@ -1027,16 +1028,39 @@ function _downloadCassetteFile_vcs(e) {
10271028
});
10281029
}
10291030

1031+
function _downloadCassetteFile_c64(e) {
1032+
var prefix = getFilenamePrefix(getCurrentMainFilename());
1033+
let audpath = prefix + ".tap";
1034+
let tapmaker = new TAPFile(prefix);
1035+
let outfile = new OutputSoundFile({sine_wave:true});
1036+
let data = current_output;
1037+
let startAddress = data[0] + data[1]*256;
1038+
data = data.slice(2); // remove header
1039+
tapmaker.setContent({ data, startAddress, type: TAPFile.FILE_TYPE_NON_RELOCATABLE });
1040+
tapmaker.generateSound(outfile);
1041+
let tapout = outfile.getTAPData();
1042+
//let audout = outfile.getSoundData();
1043+
if (tapout) {
1044+
//let blob = new Blob([audout], { type: "audio/wav" });
1045+
let blob = new Blob([tapout], { type: "application/octet-stream" });
1046+
saveAs(blob, audpath);
1047+
}
1048+
}
1049+
1050+
function _getCassetteFunction() {
1051+
switch (getBasePlatform(platform_id)) {
1052+
case 'vcs': return _downloadCassetteFile_vcs;
1053+
case 'apple2': return _downloadCassetteFile_apple2;
1054+
case 'c64': return _downloadCassetteFile_c64;
1055+
}
1056+
}
1057+
10301058
function _downloadCassetteFile(e) {
10311059
if (current_output == null) {
10321060
alertError("Please fix errors before exporting.");
10331061
return true;
10341062
}
1035-
var fn;
1036-
switch (getBasePlatform(platform_id)) {
1037-
case 'vcs': fn = _downloadCassetteFile_vcs; break;
1038-
case 'apple2': fn = _downloadCassetteFile_apple2; break;
1039-
}
1063+
var fn = _getCassetteFunction();
10401064
if (fn === undefined) {
10411065
alertError("Cassette export is not supported on this platform.");
10421066
return true;
@@ -1949,7 +1973,7 @@ function setupDebugControls() {
19491973
}
19501974
$("#item_download_allzip").click(_downloadAllFilesZipFile);
19511975
$("#item_record_video").click(_recordVideo);
1952-
if (platform_id.startsWith('apple2') || platform_id.startsWith('vcs')) // TODO: look for function
1976+
if (_getCassetteFunction())
19531977
$("#item_export_cassette").click(_downloadCassetteFile);
19541978
else
19551979
$("#item_export_cassette").hide();

0 commit comments

Comments
 (0)