diff --git a/Makefile b/Makefile index 76b0977..69ad089 100644 --- a/Makefile +++ b/Makefile @@ -8,18 +8,23 @@ all : sample0.xex sample1.xex sample2.xex sample3.xex sample4.xex sample5.xex sa %.s : %.jpg dither-image.js node dither-image.js $< $@ $@.png +chickens.s : chickens.wav pack-wav.js + node pack-wav.js $< $@ + %.o : %.s ca65 -v -t atari -o $@ $< -%.xex : %.o dither4.o - ld65 -v -C atari-asm-xex.cfg -o $@ $< dither4.o +%.xex : %.o dither4.o chickens.o + ld65 -v -C atari-asm-xex.cfg -o $@ $< dither4.o chickens.o clean : rm -f dither4.o rm -f dither4.xex - rm -f sample[0-6].o rm -f sample[0-6].s + rm -f sample[0-6].o rm -f sample[0-6].xex - rm -f sample[0-6].png + rm -f sample[0-6].s.png + rm -f chickens.s + rm -f chickens.o -.dummy: sample0.s sample1.s sample2.s sample3.s sample4.s sample5.s sample6.s \ No newline at end of file +.dummy: sample0.s sample1.s sample2.s sample3.s sample4.s sample5.s sample6.s chickens.s \ No newline at end of file diff --git a/chickens.wav b/chickens.wav new file mode 100644 index 0000000..9808d96 Binary files /dev/null and b/chickens.wav differ diff --git a/dither4.js b/dither4.js deleted file mode 100644 index cb9aa83..0000000 --- a/dither4.js +++ /dev/null @@ -1,660 +0,0 @@ -function toLinear(val) { - // use a 2.4 gamma approximation - // this is BT.1886 compatible - // and simpler than sRGB - let unit = val / 255; - unit **= 2.4; - return unit * 255; -} - -function fromLinear(val) { - let unit = val / 255; - unit **= (1 / 2.4); - return unit * 255; -} - -class RGB { - constructor(r, g, b) { - this.r = r; - this.g = g; - this.b = b; - } - - static fromHex(val) { - let r = val & 0xff; - let g = (val >> 8) & 0xff; - let b = (val >> 16) & 0xff; - return new RGB(r,g,b); - } - - toLinear() { - return new RGB( - toLinear(this.r), - toLinear(this.g), - toLinear(this.b) - ); - } - - fromLinear() { - return new RGB( - fromLinear(this.r), - fromLinear(this.g), - fromLinear(this.b) - ); - } - - cap() { - if (this.r < 0) { - this.r = 0; - } - if (this.g < 0) { - this.g = 0; - } - if (this.b < 0) { - this.b = 0; - } - if (this.r > 255) { - this.r = 255; - } - if (this.g > 255) { - this.g = 255; - } - if (this.b > 255) { - this.b = 255; - } - } - - add(other) { - return new RGB( - this.r + other.r, - this.g + other.g, - this.b + other.b - ); - } - - difference(other) { - return new RGB( - this.r - other.r, - this.g - other.g, - this.b - other.b - ); - } - - magnitude() { - return Math.sqrt( - this.r * this.r + - this.g * this.g + - this.b * this.b - ); - } - - distance(other) { - return this.difference(other).magnitude(); - } -} - -// snarfed from https://lospec.com/palette-list/atari-8-bit-family-gtia -// which was calculated with Retrospecs App's Atari 800 emulator -let palette256 = [ - 0x000000, - 0x111111, - 0x222222, - 0x333333, - 0x444444, - 0x555555, - 0x666666, - 0x777777, - 0x888888, - 0x999999, - 0xaaaaaa, - 0xbbbbbb, - 0xcccccc, - 0xdddddd, - 0xeeeeee, - 0xffffff, - 0x190700, - 0x2a1800, - 0x3b2900, - 0x4c3a00, - 0x5d4b00, - 0x6e5c00, - 0x7f6d00, - 0x907e09, - 0xa18f1a, - 0xb3a02b, - 0xc3b13c, - 0xd4c24d, - 0xe5d35e, - 0xf7e46f, - 0xfff582, - 0xffff96, - 0x310000, - 0x3f0000, - 0x531700, - 0x642800, - 0x753900, - 0x864a00, - 0x975b0a, - 0xa86c1b, - 0xb97d2c, - 0xca8e3d, - 0xdb9f4e, - 0xecb05f, - 0xfdc170, - 0xffd285, - 0xffe39c, - 0xfff4b2, - 0x420404, - 0x4f0000, - 0x600800, - 0x711900, - 0x822a0d, - 0x933b1e, - 0xa44c2f, - 0xb55d40, - 0xc66e51, - 0xd77f62, - 0xe89073, - 0xf9a183, - 0xffb298, - 0xffc3ae, - 0xffd4c4, - 0xffe5da, - 0x410103, - 0x50000f, - 0x61001b, - 0x720f2b, - 0x83203c, - 0x94314d, - 0xa5425e, - 0xb6536f, - 0xc76480, - 0xd87591, - 0xe986a2, - 0xfa97b3, - 0xffa8c8, - 0xffb9de, - 0xffcaef, - 0xfbdcf6, - 0x330035, - 0x440041, - 0x55004c, - 0x660c5c, - 0x771d6d, - 0x882e7e, - 0x993f8f, - 0xaa50a0, - 0xbb61b1, - 0xcc72c2, - 0xdd83d3, - 0xee94e4, - 0xffa5e4, - 0xffb6e9, - 0xffc7ee, - 0xffd8f3, - 0x1d005c, - 0x2e0068, - 0x400074, - 0x511084, - 0x622195, - 0x7332a6, - 0x8443b7, - 0x9554c8, - 0xa665d9, - 0xb776ea, - 0xc887eb, - 0xd998eb, - 0xe9a9ec, - 0xfbbaeb, - 0xffcbef, - 0xffdff9, - 0x020071, - 0x13007d, - 0x240b8c, - 0x351c9d, - 0x462dae, - 0x573ebf, - 0x684fd0, - 0x7960e1, - 0x8a71f2, - 0x9b82f7, - 0xac93f7, - 0xbda4f7, - 0xceb5f7, - 0xdfc6f7, - 0xf0d7f7, - 0xffe8f8, - 0x000068, - 0x000a7c, - 0x081b90, - 0x192ca1, - 0x2a3db2, - 0x3b4ec3, - 0x4c5fd4, - 0x5d70e5, - 0x6e81f6, - 0x7f92ff, - 0x90a3ff, - 0xa1b4ff, - 0xb2c5ff, - 0xc3d6ff, - 0xd4e7ff, - 0xe5f8ff, - 0x000a4d, - 0x001b63, - 0x002c79, - 0x023d8f, - 0x134ea0, - 0x245fb1, - 0x3570c2, - 0x4681d3, - 0x5792e4, - 0x68a3f5, - 0x79b4ff, - 0x8ac5ff, - 0x9bd6ff, - 0xace7ff, - 0xbdf8ff, - 0xceffff, - 0x001a26, - 0x002b3c, - 0x003c52, - 0x004d68, - 0x065e7c, - 0x176f8d, - 0x28809e, - 0x3991af, - 0x4aa2c0, - 0x5bb3d1, - 0x6cc4e2, - 0x7dd5f3, - 0x8ee6ff, - 0x9ff7ff, - 0xb0ffff, - 0xc1ffff, - 0x01250a, - 0x023610, - 0x004622, - 0x005738, - 0x05684d, - 0x16795e, - 0x278a6f, - 0x389b80, - 0x49ac91, - 0x5abda2, - 0x6bceb3, - 0x7cdfc4, - 0x8df0d5, - 0x9effe5, - 0xaffff1, - 0xc0fffd, - 0x04260d, - 0x043811, - 0x054713, - 0x005a1b, - 0x106b1b, - 0x217c2c, - 0x328d3d, - 0x439e4e, - 0x54af5f, - 0x65c070, - 0x76d181, - 0x87e292, - 0x98f3a3, - 0xa9ffb3, - 0xbaffbf, - 0xcbffcb, - 0x00230a, - 0x003510, - 0x044613, - 0x155613, - 0x266713, - 0x377813, - 0x488914, - 0x599a25, - 0x6aab36, - 0x7bbc47, - 0x8ccd58, - 0x9dde69, - 0xaeef7a, - 0xbfff8b, - 0xd0ff97, - 0xe1ffa3, - 0x001707, - 0x0e2808, - 0x1f3908, - 0x304a08, - 0x415b08, - 0x526c08, - 0x637d08, - 0x748e0d, - 0x859f1e, - 0x96b02f, - 0xa7c140, - 0xb8d251, - 0xc9e362, - 0xdaf473, - 0xebff82, - 0xfcff8e, - 0x1b0701, - 0x2c1801, - 0x3c2900, - 0x4d3b00, - 0x5f4c00, - 0x705e00, - 0x816f00, - 0x938009, - 0xa4921a, - 0xb2a02b, - 0xc7b43d, - 0xd8c64e, - 0xead760, - 0xf6e46f, - 0xfffa84, - 0xffff99, -].map((hex) => RGB.fromHex(hex).toLinear()); - -function decimate(input, palette, n) { - // to brute-force, the possible palettes are: - // 255 * 254 * 253 = 16,386,810 - // - // we could brute force it but that's a lot :D - // but can do some bisection :D - // - // need a fitness metric. - // each pixel in the dithered line gives a distance - // sum/average them? median? maximum? - // summing evens out the ups/downs from dithering - // but doesn't distinguish between two close and two distant options - // consider median, 90th-percentile, and max of abs(distance) - // consider doing the distance for each channel? - - let line = input.slice(); - - // Apply dithering with given palette - let dither = (palette) => { - let fitness = new Float64Array(line.length); - let error = { - right: new RGB(0, 0, 0), - red: new Float64Array(line.length), - green: new Float64Array(line.length), - blue: new Float64Array(line.length), - } - - let output = new Int32Array(line.length); - let popularity = new Int32Array(palette.length); - - // Try dithering with this palette. - for (let x = 0; x < line.length; x++) { - let rgb = line[x]; - rgb = rgb.add(error.right); - //rgb.cap(); - - // find the closest possible color - let shortest = Infinity; - let pick = 1; - - for (let i = 0; i < palette.length; i++) { - let diff = rgb.difference(palette[i]); - let dist = diff.magnitude(); - if (dist < shortest) { - nextError = diff; - shortest = dist; - pick = i; - } - } - - output[x] = pick; - popularity[pick]++; - - /* - // horiz only - error.right.r = nextError.r; - error.right.g = nextError.g; - error.right.b = nextError.b; - */ - - /* - error.red[x] += nextError.r; - error.green[x] += nextError.g; - error.blue[x] += nextError.b; - */ - - /* - error.right.r = nextError.r / 2; - error.right.g = nextError.g / 2; - error.right.b = nextError.b / 2; - - error.red[x] += nextError.r / 2; - error.green[x] += nextError.g / 2; - error.blue[x] += nextError.b / 2; - */ - - if (x == 159) { - error.red[x] += error.right.r; - error.green[x] += error.right.g; - error.blue[x] += error.right.b; - } else { - error.right.r = nextError.r / 4; - error.right.g = nextError.g / 4; - error.right.b = nextError.b / 4; - - error.red[x - 1] += nextError.r / 4; - error.green[x - 1] += nextError.g / 4; - error.blue[x - 1] += nextError.b / 4; - - error.red[x] += nextError.r / 4; - error.green[x] += nextError.g / 4; - error.blue[x] += nextError.b / 4; - - error.red[x + 1] += nextError.r / 4; - error.green[x + 1] += nextError.g / 4; - error.blue[x + 1] += nextError.b / 4; - } - - /* - error.right.r = nextError.r / 4; - error.right.g = nextError.g / 4; - error.right.b = nextError.b / 4; - - error.red[x - 1] += nextError.r / 4; - error.green[x - 1] += nextError.g / 4; - error.blue[x - 1] += nextError.b / 4; - - error.red[x] += nextError.r / 4; - error.green[x] += nextError.g / 4; - error.blue[x] += nextError.b / 4; - - error.red[x + 1] += nextError.r / 4; - error.green[x + 1] += nextError.g / 4; - error.blue[x + 1] += nextError.b / 4; - */ - - // 442 is the 3d distance across the rgb cube - //fitness[x] = 442 - (nextError.magnitude()); - //fitness[x] = 442 / (442 - nextError.magnitude()); - fitness[x] = 255 / (256 - Math.max(0, nextError.r, nextError.g, nextError.b)); - - /* - fitness[x] = Math.max( - 255 - Math.abs(nextError.r), - 255 - Math.abs(nextError.g), - 255 - Math.abs(nextError.b), - ); - */ - } - return { - output, - palette, - fitness, - popularity, - error - }; - }; - - - // black, red, blue, white - let rbw = [ - palette256[0x00], - palette256[0x87], - palette256[0xf7], - palette256[0x0f], - ]; - - let rgb = [ - palette256[0x00], - palette256[0x87], - palette256[0xc7], - palette256[0xf7], - ]; - - // grayscale - let gray = [ - palette256[0x00], - palette256[0x05], - palette256[0x0a], - palette256[0x0f], - ]; - - //palette = rgb; - //palette = rbw; - //palette = gray; - - let start = Date.now(); - let decimated = palette.slice(); - - while (decimated.length > n) { - let {popularity, fitness, output} = dither(decimated); - - // Try dropping least used color on each iteration - let least = Infinity; - let pick = -1; - for (let i = 1; i < decimated.length; i++) { - if (decimated[i] === palette256[0]) { - continue; // keep black always - } - if (decimated[i] === palette256[0x0f]) { - continue; // keep white always - } - - //let coolFactor = popularity[i]; - - let coolFactor = 0; - if (popularity[i]) { - for (let x = 0; x < line.length; x++) { - if (output[x] == i) { - //coolFactor += (fitness[x] ** 2); - coolFactor += (fitness[x] ** 4); - } - } - } - - if (coolFactor < least) { - pick = i; - least = coolFactor; - } - } - let old = decimated.length; - //decimated.splice(pick, 1); - decimated = decimated.filter((rgb, i) => { - if (i == pick) { - return false; - } - if (rgb !== palette256[0] && popularity[i] == 0) { - return false; - } - if (rgb !== palette256[0x0f] && popularity[i] == 0) { - return false; - } - return true; - }); - if (decimated.length >= old) { - console.log(decimated); - debugger; - throw new Error('logic error'); - } - } - let delta = Date.now() - start; - console.log(`${delta}ms for line`); - - // Palette fits - if (decimated.length > 4) { - debugger; - } - console.log(decimated); - return dither(decimated); -} - -function convert(source, sink) { - - let width = 320; - let height = 192; - - let canvas = sink; - let ctx = canvas.getContext('2d'); - - // Draw the source image down, then grab it - // and re-draw it with custom palette & dither. - - ctx.drawImage(source, 0, 0); - - let imageData = ctx.getImageData(0, 0, width, height); - let {data} = imageData; - let nextError; - - for (let y = 0; y < height; y++) { - let line = new Uint8Array(data.buffer, y * width * 4, width * 4); - let input = []; - - // Note we take two pixels because we're using the 160-wide 4-color mode - for (let x = 0; x < width; x += 2) { - let i = x >> 1; - let rgb = new RGB( - (line[x * 4 + 0] + line[x * 4 + 4]) / 2, - (line[x * 4 + 1] + line[x * 4 + 5]) / 2, - (line[x * 4 + 2] + line[x * 4 + 6]) / 2 - ).toLinear(); - if (nextError) { - rgb.r += nextError.red[i]; - rgb.g += nextError.green[i]; - rgb.b += nextError.blue[i]; - //rgb.cap(); - } - input.push(rgb); - } - - let {output, palette, error} = decimate(input, palette256, 4); - nextError = error; - - for (let x = 0; x < width; x++) { - let rgb = palette[output[x >> 1]].fromLinear(); - line[x * 4 + 0] = rgb.r; - line[x * 4 + 1] = rgb.g; - line[x * 4 + 2] = rgb.b; - line[x * 4 + 3] = 0xff; - } - } - - ctx.putImageData(imageData, 0, 0); -} - -function run() { - for (let i = 0; i < 7; i++) { - let source = document.querySelector('#source' + i); - let sink = document.querySelector('#sink' + i); - - let doit = () => convert(source, sink); - - if (source.complete) { - doit(); - } else { - source.addEventListener('load', doit); - } - } -} - -if (document.readyState === 'loading') { - addEventListener('DOMContentLoaded', run); -} else { - run(); -} diff --git a/dither4.s b/dither4.s index 43ba541..5e0495d 100644 --- a/dither4.s +++ b/dither4.s @@ -1,13 +1,11 @@ SAVMSC = $58 -;SDMCTL = $22F -;SDLSTL = $230 -;SDLSTH = $231 COLPF0 = $D016 COLPF1 = $D017 COLPF2 = $D018 COLPF3 = $D019 COLBK = $D01A +AUDC1 = $D201 DMACTL = $D400 DLISTL = $D402 DLISTH = $D403 @@ -24,10 +22,14 @@ temp1 = temp1l temp2l = $82 temp2h = $83 temp2 = temp2l +sample_ptrl = $84 +sample_ptrh = $85 +sample_ptr = sample_ptrl height = 192 bytes_per_line = 40 pages_per_frame = 32 +lines_per_frame = 262 .data @@ -35,6 +37,8 @@ pages_per_frame = 32 .import palette2 .import palette3 .import bitmap +.import audio_samples +.import audio_samples_end displaylist: ; 24 lines overscan @@ -70,18 +74,24 @@ displaylist: lda #.hibyte(bitmap) sta temp1h lda #.lobyte(framebuffer) - sta temp2h + sta temp2l lda #.hibyte(framebuffer) sta temp2h jsr copy_half_frame ; Second half of bitmap has to be separately aligned lda #.lobyte(framebuffer2) - sta temp2h + sta temp2l lda #.hibyte(framebuffer2) sta temp2h jsr copy_half_frame + ; Set up the audio sample buffer + lda #.lobyte(audio_samples) + sta sample_ptrl + lda #.hibyte(audio_samples) + sta sample_ptrh + ; Disable display DMA lda #$00 sta DMACTL @@ -106,40 +116,102 @@ wait_vblank: lda #$22 sta DMACTL -wait_loop: - sta WSYNC +; Wait for the next even scanline (VCOUNT is scan line / 2, so look for a change) wait_start: lda VCOUNT - cmp #15 - bne wait_loop - ldy #0 + sta temp1 +wait_loop: + lda VCOUNT + cmp temp1 + beq wait_loop each_scanline: - tya - pha - - lda palette1,y - pha - ldx palette2,y - lda palette3,y + lda VCOUNT + ; Resynchronize the scanline counter + ; it'll fire on unused lines, but harmlessly + clc + sbc #15 + asl tay - pla - ; Wait for horizontal blank - sta WSYNC + .macro audio_prep + ; audio sample; low nybble + ldx #0 + lda (sample_ptr,x) + sta temp2 - ; Update color registers as fast as possible - sta COLPF0 - stx COLPF1 - sty COLPF2 + ; high nybble + lsr a + lsr a + lsr a + lsr a + ; set the volume-only bit + ora #$10 + pha - pla - tay + ; low nybble + lda temp2 + and #$0f + ; set the volume-only bit + ora #$10 + pha + .endmacro + + .macro audio_inc + ; Increment sample ptr + clc + lda sample_ptrl + adc #1 + sta sample_ptrl + lda sample_ptrh + adc #0 + sta sample_ptrh + + lda sample_ptrh + cmp #.hibyte(audio_samples_end) + bne audio_cont + lda sample_ptrl + cmp #.lobyte(audio_samples_end) + bne audio_cont + lda #.lobyte(audio_samples) + sta sample_ptrl + lda #.hibyte(audio_samples) + sta sample_ptrh + + audio_cont: + .endmacro + + .macro inner_scanline + ; Leisurely memory fetches + lda palette1,y + pha + ldx palette2,y + lda palette3,y + tay + pla + + ; Wait for horizontal blank + sta WSYNC + + ; Update color registers as fast as possible + sta COLPF0 + stx COLPF1 + sty COLPF2 + + ; Audio sample + pla + sta AUDC1 + .endmacro + + audio_prep + inner_scanline + + audio_inc iny - cpy #height - bne each_scanline + inner_scanline - jmp wait_start + ;jmp wait_start + jmp each_scanline .endproc diff --git a/pack-wav.js b/pack-wav.js new file mode 100644 index 0000000..0d2e9f9 --- /dev/null +++ b/pack-wav.js @@ -0,0 +1,60 @@ +import wavefile from 'wavefile'; +let WaveFile = wavefile.WaveFile; + +import { + readFileSync, + writeFileSync +} from 'fs'; + +function byte2byte(arr) { + let lines = []; + for (let i=0; i < arr.length; i++) { + lines.push(`.byte ${arr[i]}`); + } + return lines.join('\n'); +} + +function to4bit(val8) { + let val = val8 + 7 >> 4; + if (val > 15) { + return 15; + } + return val; +} + +function pack(audio) { + let packed = []; + for (let i = 0; i < audio.length; i += 2) { + let low = to4bit(audio[i]); + let high = to4bit(audio[i + 1]); + let byte = low | (high << 4); + packed.push(byte); + } + return packed; +} + +function audio2assembly(audio) { + return `.data + .export audio_samples + .export audio_samples_end + +audio_samples: + ${byte2byte(pack(audio))} +audio_samples_end: + +`; +} + +function wav2assembly(buffer) { + let wav = new WaveFile(buffer); + let samples = wav.getSamples(); + return audio2assembly(samples); +} + +let infile = process.argv[2]; +let outfile = process.argv[3]; + +let buffer = readFileSync(infile); +let asm = wav2assembly(buffer); +writeFileSync(outfile, asm, 'utf-8'); + diff --git a/package-lock.json b/package-lock.json index 250f164..d812ae5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5,7 +5,8 @@ "packages": { "": { "dependencies": { - "jimp": "^0.16.2" + "jimp": "^0.16.2", + "wavefile": "^11.0.0" } }, "node_modules/@babel/runtime": { @@ -740,6 +741,17 @@ "pako": "^1.0.5" } }, + "node_modules/wavefile": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/wavefile/-/wavefile-11.0.0.tgz", + "integrity": "sha512-/OBiAALgWU24IG7sC84cDO/KfFuvajWc5Uec0oV2zrpOOZZDgGdOwHwgEzOrwh8jkubBk7PtZfQBIcI1OaE5Ng==", + "bin": { + "wavefile": "bin/wavefile.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/xhr": { "version": "2.6.0", "resolved": "https://registry.npmjs.org/xhr/-/xhr-2.6.0.tgz", @@ -1343,6 +1355,11 @@ "pako": "^1.0.5" } }, + "wavefile": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/wavefile/-/wavefile-11.0.0.tgz", + "integrity": "sha512-/OBiAALgWU24IG7sC84cDO/KfFuvajWc5Uec0oV2zrpOOZZDgGdOwHwgEzOrwh8jkubBk7PtZfQBIcI1OaE5Ng==" + }, "xhr": { "version": "2.6.0", "resolved": "https://registry.npmjs.org/xhr/-/xhr-2.6.0.tgz", diff --git a/package.json b/package.json index cb9010d..fc83ca8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,7 @@ { "type": "module", "dependencies": { - "jimp": "^0.16.2" + "jimp": "^0.16.2", + "wavefile": "^11.0.0" } }