diff --git a/src/common/audio/CommodoreTape.ts b/src/common/audio/CommodoreTape.ts new file mode 100644 index 00000000..d651cc4f --- /dev/null +++ b/src/common/audio/CommodoreTape.ts @@ -0,0 +1,305 @@ +/* +https://github.com/eightbitjim/commodore-tape-maker/blob/master/maketape.py + +# MIT License +# +# Copyright (c) 2018 eightbitjim +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +*/ + +export class OutputSoundFile { + options: any; + sampleRate: number; + soundData: number[]; + tapData: number[]; + + constructor(options: any) { + this.options = options; + this.sampleRate = 44100.0; + this.soundData = []; + //00000000 43 36 34 2d 54 41 50 45 2d 52 41 57 01 00 00 00 |C64-TAPE-RAW....| + //00000010 1e 62 03 00 + this.tapData = [0x43,0x36,0x34,0x2d,0x54,0x41,0x50,0x45,0x2d,0x52,0x41,0x57,0x01,0x00,0x00,0x00,0,0,0,0]; + } + + getWAVHeader() { + const header = new Uint8Array(44); + const view = new DataView(header.buffer); + view.setUint32(0, 0x52494646, false); // "RIFF" + view.setUint32(4, 44 + this.soundData.length, true); // ChunkSize + view.setUint32(8, 0x57415645, false); // "WAVE" + view.setUint32(12, 0x666d7420, false); // "fmt " + view.setUint32(16, 16, true); // Subchunk1Size + view.setUint16(20, 1, true); // AudioFormat (PCM) + view.setUint16(22, 1, true); // NumChannels + view.setUint32(24, this.sampleRate, true); // SampleRate + view.setUint32(28, this.sampleRate * 2, true); // ByteRate + view.setUint16(32, 1, true); // BlockAlign + view.setUint16(34, 8, true); // BitsPerSample + view.setUint32(36, 0x64617461, false); // "data" + view.setUint32(40, this.soundData.length, true); // Subchunk2Size + return header; + } + + addSilence(lengthInSeconds: number): void { + const numberOfSamples = Math.floor(this.sampleRate * lengthInSeconds); + for (let i = 0; i < numberOfSamples; i++) { + this.soundData.push(0); + } + //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. + const numCycles = TAPFile.CLOCK_RATE * lengthInSeconds; + this.tapData.push(0); + this.tapData.push(numCycles & 0xff); + this.tapData.push((numCycles >> 8) & 0xff); + this.tapData.push((numCycles >> 16) & 0xff); + } + + addCycle(cycles: number): void { + this.tapData.push(cycles); + const numberOfSamples = Math.floor(this.sampleRate * TAPFile.TAP_LENGTH_IN_SECONDS * cycles); + for (let i = 0; i < numberOfSamples; i++) { + let value; + if (this.options.sine_wave) { + value = - Math.sin((i / numberOfSamples) * 2.0 * Math.PI); + } else { + if (i < numberOfSamples / 2) { + value = -1; + } else { + value = 1; + } + } + if (this.options.invert_waveform) { + value = -value; + } + this.soundData.push(Math.round(128 + value * 127)); + } + } + + updateTAPHeader() { + let datalen = this.tapData.length - 0x14; + // set bytes 0x10-0x13 to length + this.tapData[0x10] = datalen & 0xff; + this.tapData[0x11] = (datalen >> 8) & 0xff; + this.tapData[0x12] = (datalen >> 16) & 0xff; + this.tapData[0x13] = (datalen >> 24) & 0xff; + } + + getTAPData(): Uint8Array { + this.updateTAPHeader(); + return new Uint8Array(this.tapData); + } + + getSoundData(): Uint8Array { + let header = this.getWAVHeader(); + let data = new Uint8Array(header.length + this.soundData.length); + data.set(header, 0); + data.set(new Uint8Array(this.soundData), header.length); + return data; + } +} + +export class TAPFile { + + static CLOCK_RATE = 985248.0; + static TAP_LENGTH_IN_SECONDS = 8.0 / this.CLOCK_RATE; + static FILENAME_BUFFER_SIZE = 0x10; + static FILE_TYPE_NONE = 0; + static FILE_TYPE_RELOCATABLE = 1; + static FILE_TYPE_SEQUENTIAL = 2; + static FILE_TYPE_NON_RELOCATABLE = 3; + static LEADER_TYPE_HEADER = 0; + static LEADER_TYPE_CONTENT = 1; + static LEADER_TYPE_REPEATED = 2; + static NUMBER_OF_PADDING_BYTES = 171; + static PADDING_CHARACTER = 0x20; + static SHORT_PULSE = 0x30; + static MEDIUM_PULSE = 0x42; + static LONG_PULSE = 0x56; + + options: any; + checksum: number; + data: Uint8Array; + filenameData: number[]; + startAddress: number; + endAddress: number; + fileType: number; + waveFile: OutputSoundFile; + + constructor(filename: string, options?: any) { + this.options = options; + this.checksum = 0; + this.data = new Uint8Array(0); + this.filenameData = this.makeFilename(filename); + this.startAddress = 0; + this.endAddress = 0; + this.fileType = TAPFile.FILE_TYPE_NONE; + this.waveFile = null; + } + + makeFilename(filename: string): number[] { + const filenameBuffer = []; + const space = 0x20; + filename = filename.toUpperCase(); // for PETSCII + for (let i = 0; i < TAPFile.FILENAME_BUFFER_SIZE; i++) { + if (filename.length <= i) { + filenameBuffer.push(space); + } else { + let ch = filename.charCodeAt(i); + filenameBuffer.push(ch); + } + } + return filenameBuffer; + } + + setContent(inputFile: { data: Uint8Array, startAddress: number, type: number }): void { + this.data = inputFile.data; + this.startAddress = inputFile.startAddress; + this.endAddress = inputFile.startAddress + inputFile.data.length; + this.fileType = inputFile.type; + } + + generateSound(outputWaveFile: OutputSoundFile): void { + this.waveFile = outputWaveFile; + this.addHeader(false); + this.addHeader(true); + outputWaveFile.addSilence(0.1); + this.addFile(); + } + + addTapCycle(tapValue: number): void { + this.waveFile.addCycle(tapValue); + } + + addBit(value: number): void { + if (value === 0) { + this.addTapCycle(TAPFile.SHORT_PULSE); + this.addTapCycle(TAPFile.MEDIUM_PULSE); + } else { + this.addTapCycle(TAPFile.MEDIUM_PULSE); + this.addTapCycle(TAPFile.SHORT_PULSE); + } + } + + addDataMarker(moreToFollow: boolean): void { + if (moreToFollow) { + this.addTapCycle(TAPFile.LONG_PULSE); + this.addTapCycle(TAPFile.MEDIUM_PULSE); + } else { + this.addTapCycle(TAPFile.LONG_PULSE); + this.addTapCycle(TAPFile.SHORT_PULSE); + } + } + + resetChecksum(): void { + this.checksum = 0; + } + + addByteFrame(value: number, moreToFollow: boolean): void { + let checkBit = 1; + for (let i = 0; i < 8; i++) { + const bit = (value & (1 << i)) !== 0 ? 1 : 0; + this.addBit(bit); + checkBit ^= bit; + } + this.addBit(checkBit); + this.addDataMarker(moreToFollow); + this.checksum ^= value; + } + + addLeader(fileType: number): void { + let numberofPulses; + if (fileType === TAPFile.LEADER_TYPE_HEADER) { + numberofPulses = 0x6a00; + } else if (fileType === TAPFile.LEADER_TYPE_CONTENT) { + numberofPulses = 0x1a00; + } else { + numberofPulses = 0x4f; + } + for (let i = 0; i < numberofPulses; i++) { + this.addTapCycle(TAPFile.SHORT_PULSE); + } + } + + addSyncChain(repeated: boolean): void { + let value; + if (repeated) { + value = 0x09; + } else { + value = 0x89; + } + let count = 9; + while (count > 0) { + this.addByteFrame(value, true); + value -= 1; + count -= 1; + } + } + + addData(): void { + for (let i = 0; i < this.data.length; i++) { + this.addByteFrame(this.data[i], true); + } + } + + addFilename(): void { + for (let i = 0; i < this.filenameData.length; i++) { + this.addByteFrame(this.filenameData[i], true); + } + } + + addHeader(repeated: boolean): void { + if (repeated) { + this.addLeader(TAPFile.LEADER_TYPE_REPEATED); + } else { + this.addLeader(TAPFile.LEADER_TYPE_HEADER); + } + this.addDataMarker(true); + this.addSyncChain(repeated); + this.resetChecksum(); + this.addByteFrame(this.fileType, true); + this.addByteFrame(this.startAddress & 0x00ff, true); + this.addByteFrame((this.startAddress & 0xff00) >> 8, true); + this.addByteFrame(this.endAddress & 0x00ff, true); + this.addByteFrame((this.endAddress & 0xff00) >> 8, true); + this.addFilename(); + for (let i = 0; i < TAPFile.NUMBER_OF_PADDING_BYTES; i++) { + this.addByteFrame(TAPFile.PADDING_CHARACTER, true); + } + this.addByteFrame(this.checksum, false); + } + + addFile(): void { + let repeated = false; + for (let i = 0; i < 2; i++) { + if (!repeated) { + this.addLeader(TAPFile.LEADER_TYPE_CONTENT); + } else { + this.addLeader(TAPFile.LEADER_TYPE_REPEATED); + } + this.addDataMarker(true); + this.addSyncChain(repeated); + this.resetChecksum(); + this.addData(); + this.addByteFrame(this.checksum, false); + repeated = true; + } + this.addLeader(1); + } +} diff --git a/src/ide/ui.ts b/src/ide/ui.ts index bc018b16..04bbad81 100644 --- a/src/ide/ui.ts +++ b/src/ide/ui.ts @@ -21,6 +21,7 @@ import { isMobileDevice } from "./views/baseviews"; import { CallStackView, DebugBrowserView } from "./views/treeviews"; import { saveAs } from "file-saver"; import DOMPurify = require("dompurify"); +import { OutputSoundFile, TAPFile } from "../common/audio/CommodoreTape"; // external libs (TODO) declare var Tour, GIF, Octokat; @@ -1027,16 +1028,39 @@ function _downloadCassetteFile_vcs(e) { }); } +function _downloadCassetteFile_c64(e) { + var prefix = getFilenamePrefix(getCurrentMainFilename()); + let audpath = prefix + ".tap"; + let tapmaker = new TAPFile(prefix); + let outfile = new OutputSoundFile({sine_wave:true}); + let data = current_output; + let startAddress = data[0] + data[1]*256; + data = data.slice(2); // remove header + tapmaker.setContent({ data, startAddress, type: TAPFile.FILE_TYPE_NON_RELOCATABLE }); + tapmaker.generateSound(outfile); + let tapout = outfile.getTAPData(); + //let audout = outfile.getSoundData(); + if (tapout) { + //let blob = new Blob([audout], { type: "audio/wav" }); + let blob = new Blob([tapout], { type: "application/octet-stream" }); + saveAs(blob, audpath); + } +} + +function _getCassetteFunction() { + switch (getBasePlatform(platform_id)) { + case 'vcs': return _downloadCassetteFile_vcs; + case 'apple2': return _downloadCassetteFile_apple2; + case 'c64': return _downloadCassetteFile_c64; + } +} + function _downloadCassetteFile(e) { if (current_output == null) { alertError("Please fix errors before exporting."); return true; } - var fn; - switch (getBasePlatform(platform_id)) { - case 'vcs': fn = _downloadCassetteFile_vcs; break; - case 'apple2': fn = _downloadCassetteFile_apple2; break; - } + var fn = _getCassetteFunction(); if (fn === undefined) { alertError("Cassette export is not supported on this platform."); return true; @@ -1949,7 +1973,7 @@ function setupDebugControls() { } $("#item_download_allzip").click(_downloadAllFilesZipFile); $("#item_record_video").click(_recordVideo); - if (platform_id.startsWith('apple2') || platform_id.startsWith('vcs')) // TODO: look for function + if (_getCassetteFunction()) $("#item_export_cassette").click(_downloadCassetteFile); else $("#item_export_cassette").hide();