A => .hgignore +3 -0
@@ 0,0 1,3 @@
+^bin/
+^node_modules/
+glob:.DS_Store
A => LICENSE +22 -0
@@ 0,0 1,22 @@
+Copyright (c) 2015, Laurens Holst
+All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are met:
+
+1. Redistributions of source code must retain the above copyright notice, this
+ list of conditions and the following disclaimer.
+2. Redistributions in binary form must reproduce the above copyright notice,
+ this list of conditions and the following disclaimer in the documentation
+ and/or other materials provided with the distribution.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
+ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
+ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
+(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
+LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
+ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
A => Makefile +22 -0
@@ 0,0 1,22 @@
+COPY_TARGET = /Volumes/MSXDOS2
+
+all: #node_modules
+ mkdir -p bin
+ node --unhandled-rejections=strict tools/resources.js res/resources.json
+ java -jar tools/glass.jar src/COM.asm bin/gngplay.com bin/gngplay.sym
+
+node_modules: package.json
+ npm install
+ touch -c -m node_modules
+
+dist: all
+ rm -f bin/gngplay.zip
+ zip -j bin/gngplay.zip bin/gngplay.com LICENSE README.md
+
+copy: all
+ cp bin/gngplay.com $(COPY_TARGET)/gng/
+ cp bin/*.gng $(COPY_TARGET)/gng/
+ diskutil umount $(COPY_TARGET)
+
+run: all
+ openmsx -machine Panasonic_FS-A1GT -ext Yamaha_SFG-05 -diska bin/ -script openmsx.tcl
A => README.md +111 -0
@@ 0,0 1,111 @@
+Ghosts ’n Goblins music converter & player
+==========================================
+
+Copyright 2020 Laurens Holst
+
+Project information
+-------------------
+
+ * Author: Laurens Holst <laurens@grauw.nl>
+ * Source: <https://hg.sr.ht/~grauw/gngplay>
+ * License: Simplified BSD License
+
+Converts the Ghosts ’n Goblins arcade music in VGM format to a data stream
+suitable for play back on Yamaha SFG, Neotron or Makoto. The format aims to be
+both efficient for playback and small in size. Due to a compact representation
+for the most frequent event types to reduce the individual tracks are all
+below 8K and the total size is less than 64K.
+
+
+System requirements
+-------------------
+
+ * MSX, MSX2, MSX2+ or MSX turboR
+ * 64K main RAM
+ * 16K video RAM
+ * MSX-DOS
+
+If the replayer core and music data is embedded in a ROM, the RAM requirement is
+lowered to 16K and the MSX-DOS requirement is dropped.
+
+
+Converter functions
+-------------------
+
+The converter has the following functions:
+
+ * Compact data format
+ * Simple playback on three sound chips
+ * Coalescing instrument settings per channel
+ * Grouping into five event types (key, frequency, instrument, wait, loop)
+ * Clock frequency conversion
+ * Channel remapping
+ * Small wait removal
+ * Timing quantisation
+ * Trimming of leading silence
+ * Moving frequency events before key on
+
+It also outputs VGM files for the YM2151 OPM and YM2610 OPN for easy testing
+with VGMPlay and debugging with vgm2txt.
+
+
+Music data format
+-----------------
+
+The data format consists of five types of events:
+
+ * 00-7F: Key on / off event.
+ Bit 0-2: channel, bit 3-6: slots.
+ * 80-87: Note frequency event.
+ Bit 0-2: channel, byte 1-2: OPM frequency, byte 3-4: OPN frequency.
+ * 88-8F: Instrument settings.
+ Bit 0-2: channel, byte 1-25: slot parameters, byte 26: feedback / algorithm.
+ * 90: Loop offset.
+ Bytes 1-2: offset to loop point.
+ * 91-FF: Wait.
+ n - 90H: nr. of 60Hz ticks.
+
+
+Additional notes
+----------------
+
+The arcade game music uses two OPNs clocked at 1.5 MHz. But because it sets the
+prescaler to a frequency divider of 3 instead of the usual 6, they act like OPNs
+clocked at 3 MHz with the default prescaling.
+
+The game sets the frequency after it sets key on. Due to this you sometimes get
+a wrong note at the start of a track on real hardware, depending on the initial
+values. This was probably a bug in the original game’s replayer code which did
+not get exposed during normal play. A processing step was added to put frequency
+in front of the key on commands.
+
+Track 01 is the same as Track 27, but with two channels muted. It is recommended
+to use track 27 instead of track 01.
+
+
+Usage instructions
+------------------
+
+Extract the Ghosts ’n Goblins arcade
+[VGM pack](https://vgmrips.net/packs/pack/ghosts-n-goblins-ghosts-n-goblins),
+rename the .vgz files to .vgm.gz and extract each of them again, and then put
+them in the `res/` directory. It is recommended to re-loop track 24 so that it
+becomes smaller and the loop sounds better. Use vgm_trim with parameters
+start: 0, loop: 35111, end: 364391.
+
+The `resources.json` file in the `res` directory contains a manifest of the
+files to convert and various settings.
+
+Then, on macOS and Linux execute the `make` command to convert the files and
+compile the replayer. Test by putting the MSX-DOS system files in the `bin/`
+directory and executing the `make run` command. OpenMSX 0.16.0 or later is
+required, it currently only supports the Yamaha SFG sound chip.
+
+On Windows, execute the build commands from the Makefile manually.
+
+Note that the [glass](http://www.grauw.nl/projects/glass/) assembler which is
+embedded in the project requires [Java 8](https://adoptopenjdk.net/). To check
+your Java version, invoke the `java -version` command.
+
+Additionally, the conversion tools are written in JavaScript for Node.js.
+You need to have Node.js and NPM installed.
A => openmsx.tcl +21 -0
@@ 0,0 1,21 @@
+# source lib/neonlib/tools/symbols.tcl
+# source lib/neonlib/tools/profile.tcl
+
+# symbols::load bin/vgmplay.sym
+
+ext debugdevice
+set debugoutput stdout
+debug set_watchpoint read_io 0x2E
+# debug set_watchpoint write_mem 0x0000 {([debug read ioports 0xA8] & 0x0C) == 0x04}
+# debug set_watchpoint read_mem 0x0000 {([debug read ioports 0xA8] & 0x0C) == 0x04}
+
+# diskmanipulator create /tmp/vgmplay.dsk 32M
+# virtual_drive /tmp/vgmplay.dsk
+# diskmanipulator format virtual_drive
+# diskmanipulator import virtual_drive bin/
+# virtual_drive eject
+# hda /tmp/vgmplay.dsk
+
+set maxframeskip 100
+set throttle off
+after time 20 "set throttle on"
A => package-lock.json +5 -0
@@ 0,0 1,5 @@
+{
+ "name": "gngplay",
+ "version": "0.0.0",
+ "lockfileVersion": 1
+}
A => package.json +14 -0
@@ 0,0 1,14 @@
+{
+ "name": "gngplay",
+ "version": "0.0.0",
+ "description": "Ghosts ’n Goblins music converter & player",
+ "author": "Laurens Holst",
+ "license": "BSD-2-Clause",
+ "repository": {
+ "type": "hg",
+ "url": "https://hg.sr.ht/~grauw/gngplay"
+ },
+ "type": "commonjs",
+ "dependencies": {},
+ "private": true
+}
A => res/resources.json +143 -0
@@ 0,0 1,143 @@
+{
+ "tickFrequency": 59.922743404312307,
+ "opnbFrequency": 8000000,
+ "opmFrequency": 3579545,
+ "waitThreshold": 50,
+ "tracks": [
+ {
+ "name": "01",
+ "path": "01 Credit.vgm",
+ "channelMapping": [1, -1, 2, -1, 5, -1, 6, -1]
+ },
+ {
+ "name": "02",
+ "path": "02 Start Demo.vgm",
+ "channelMapping": [1, 0, 2, -1, 5, 4, 6, -1]
+ },
+ {
+ "name": "03",
+ "path": "03 Stage Introduction Map.vgm",
+ "channelMapping": [1, 0, 2, -1, 4, 5, 6, -1]
+ },
+ {
+ "name": "04",
+ "path": "04 Ground BGM (1, 2 Stage).vgm",
+ "channelMapping": [1, 2, 0, -1, 4, 5, 6, -1]
+ },
+ {
+ "name": "05",
+ "path": "05 Theme of Giant (1, 2 Stage Boss).vgm",
+ "channelMapping": [1, 4, 2, -1, 5, 6, 0, -1]
+ },
+ {
+ "name": "06",
+ "path": "06 Stage Clear (1).vgm",
+ "channelMapping": [1, 0, 2, -1, 5, 4, 6, -1]
+ },
+ {
+ "name": "07",
+ "path": "07 Stage Clear (2).vgm",
+ "channelMapping": [1, 2, 4, -1, 5, 6, 0, -1]
+ },
+ {
+ "name": "08",
+ "path": "08 Cave BGM (3, 4 Stage).vgm",
+ "channelMapping": [1, 2, 0, -1, 4, 5, 6, -1]
+ },
+ {
+ "name": "09",
+ "path": "09 Theme of Dragon (3, 4 Stage Boss).vgm",
+ "channelMapping": [1, 2, -1, -1, -1, 5, 6, -1]
+ },
+ {
+ "name": "10",
+ "path": "10 Final Stage Inside Great Demon King's Castle BGM (5, 6 Stage).vgm",
+ "channelMapping": [1, 2, 0, -1, 5, 6, 4, -1]
+ },
+ {
+ "name": "11",
+ "path": "11 Theme of Satan (5, 6 Stage Boss).vgm",
+ "channelMapping": [1, 2, 5, -1, 0, 6, 4, -1]
+ },
+ {
+ "name": "12",
+ "path": "12 Theme of Great Demon King.vgm",
+ "channelMapping": [1, 0, 2, -1, 5, 4, 6, -1]
+ },
+ {
+ "name": "13",
+ "path": "13 Great Demon King Stage (7 Stage Boss).vgm",
+ "channelMapping": [1, 0, 2, -1, 5, 4, 6, -1]
+ },
+ {
+ "name": "14",
+ "path": "14 1st Lap Clear.vgm",
+ "channelMapping": [1, 0, 2, -1, 5, 4, 6, -1]
+ },
+ {
+ "name": "15",
+ "path": "15 2nd Lap Clear.vgm",
+ "channelMapping": [1, 0, 2, -1, 4, 5, 6, -1]
+ },
+ {
+ "name": "16",
+ "path": "16 Hurry Up!.vgm",
+ "channelMapping": [1, 2, 0, -1, 5, 4, 6, -1]
+ },
+ {
+ "name": "17",
+ "path": "17 Player Out.vgm",
+ "channelMapping": [1, 2, 0, -1, 5, 6, 4, -1]
+ },
+ {
+ "name": "18",
+ "path": "18 Game Over.vgm",
+ "channelMapping": [1, 0, 2, -1, 5, 4, 6, -1]
+ },
+ {
+ "name": "19",
+ "path": "19 1st Place Name Registration.vgm",
+ "channelMapping": [1, 2, 0, -1, 5, 6, -1, -1]
+ },
+ {
+ "name": "20",
+ "path": "20 1st Place Entry End.vgm",
+ "channelMapping": [1, 2, 0, -1, 4, 5, 6, -1]
+ },
+ {
+ "name": "21",
+ "path": "21 Below 2nd Place Name Registration.vgm",
+ "channelMapping": [1, 0, 2, -1, 5, 6, 4, -1]
+ },
+ {
+ "name": "22",
+ "path": "22 Below 2nd Place Entry End.vgm",
+ "channelMapping": [1, 0, 2, -1, 4, 5, 6, -1]
+ },
+ {
+ "name": "23",
+ "path": "23 Unused Jingle.vgm",
+ "channelMapping": [1, 0, 2, -1, 5, 6, -1, -1]
+ },
+ {
+ "name": "24",
+ "path": "24 Start Demo (1).vgm",
+ "channelMapping": [1, 2, 5, -1, -1, -1, -1, -1]
+ },
+ {
+ "name": "25",
+ "path": "25 Start Demo (2).vgm",
+ "channelMapping": [1, 0, 2, -1, 5, 4, 6, -1]
+ },
+ {
+ "name": "26",
+ "path": "26 Extend.vgm",
+ "channelMapping": [1, 0, 2, -1, 5, 4, 6, -1]
+ },
+ {
+ "name": "27",
+ "path": "27 Credit.vgm",
+ "channelMapping": [1, 0, 2, -1, 5, 4, 6, -1]
+ }
+ ]
+}
A => src/COM.asm +78 -0
@@ 0,0 1,78 @@
+;
+;
+;
+BDOS: equ 0005H
+FCB1: equ 005CH
+JIFFY: equ 0FC9EH
+
+FCB_RECORD_SIZE: equ 0EH
+FCB_FILE_SIZE: equ 10H
+FCB_RANDOM_RECORD_NUMBER: equ 21H
+
+_INNOE: equ 08H
+_CONST: equ 0BH
+_FOPEN: equ 0FH
+_FCLOSE: equ 10H
+_SETDTA: equ 1AH
+_RDBLK: equ 27H
+
+SoundData: equ 6000H
+
+ org 100H
+
+ jp Main
+
+ INCLUDE "Player.asm"
+ INCLUDE "Memory.asm"
+
+Main:
+ ld de,FCB1
+ ld c,_FOPEN
+ call BDOS
+ and a
+ ret nz
+ ld de,SoundData
+ ld c,_SETDTA
+ call BDOS
+ ld hl,1
+ ld (FCB1 + FCB_RECORD_SIZE),hl
+ ld hl,0
+ ld (FCB1 + FCB_RANDOM_RECORD_NUMBER),hl
+ ld (FCB1 + FCB_RANDOM_RECORD_NUMBER + 2),hl
+ ld hl,(FCB1 + FCB_FILE_SIZE)
+ ld de,FCB1
+ ld c,_RDBLK
+ call BDOS
+ ld de,FCB1
+ ld c,_FCLOSE
+ call BDOS
+ and a
+ ret nz
+ call Player_Detect
+ ld hl,SoundData
+ call Player_Play
+ ld a,(JIFFY)
+ ld c,a
+Main_Loop:
+ halt
+ ld a,(JIFFY)
+ ld b,a
+ sub c
+ jr z,Main_Loop
+ ld c,b
+ ld b,a
+Main_Tick_Loop:
+ push bc
+ call Player_Tick
+ pop bc
+ djnz Main_Tick_Loop
+ push bc
+ ld c,_CONST
+ call BDOS
+ pop bc
+ and a
+ jr z,Main_Loop
+ call Player_Stop
+ ld c,_INNOE
+ call BDOS
+ ret
A => src/Makoto.asm +195 -0
@@ 0,0 1,195 @@
+;
+; Makoto - Yamaha YM2608 OPNA
+;
+Makoto_BASE: equ 14H
+Makoto_STATUS0: equ Makoto_BASE ; do not use, bus conflict with Music Module
+Makoto_STATUS1: equ Makoto_BASE + 2
+Makoto_FM1_ADDRESS: equ Makoto_BASE
+Makoto_FM1_DATA: equ Makoto_BASE + 1
+Makoto_FM2_ADDRESS: equ Makoto_BASE + 2
+Makoto_FM2_DATA: equ Makoto_BASE + 3
+Makoto_ADPCM_CONTROL: equ 00H
+
+; f <- c: found
+Makoto_Detect:
+ in a,(Makoto_STATUS1)
+ and 00111111B ; ignore BUSY, D6 (N/C)
+ ret nz
+ ld a,Makoto_ADPCM_CONTROL
+ out (Makoto_FM2_ADDRESS),a
+ ld a,10000000B
+ out (Makoto_FM2_DATA),a
+ in a,(Makoto_STATUS1)
+ and 00111111B ; ignore BUSY, D6 (N/C)
+ push af
+ ld a,Makoto_ADPCM_CONTROL
+ out (Makoto_FM2_ADDRESS),a
+ ld a,00000000B
+ out (Makoto_FM2_DATA),a
+ pop af
+ xor 00100000B
+ ret nz
+ ld a,1
+ ld (Makoto_found),a
+ ld de,2980H
+ call Makoto_WriteRegister1
+ scf
+ ret
+
+; hl = sound data
+; a <- wait amount
+Makoto_Process:
+Makoto_Process_Loop:
+ ld a,(hl)
+ inc hl
+ cp 80H
+ jr c,Makoto_Key
+ cp 88H
+ jr c,Makoto_Frequency
+ sub 90H
+ jr c,Makoto_Instrument
+ ret nz
+ ld e,(hl)
+ inc hl
+ ld d,(hl)
+ inc hl
+ add hl,de
+ jr Makoto_Process_Loop
+
+; hl = sound data
+Makoto_Key:
+ ld e,a
+ and 0F8H
+ add a,e ; shift bits 3-7 to the left
+ ld e,a
+ ld d,28H
+ call Makoto_WriteRegister1
+ jr Makoto_Process_Loop
+
+; hl = sound data
+Makoto_Frequency:
+ bit 2,a
+ jr nz,Makoto_Frequency2
+Makoto_Frequency1:
+ and 3
+ add a,0A0H
+ push af
+ add a,4
+ ld d,a
+ inc hl
+ inc hl
+ ld e,(hl)
+ inc hl
+ call Makoto_WriteRegister1
+ pop de
+ ld e,(hl)
+ inc hl
+ call Makoto_WriteRegister1
+ jr Makoto_Process_Loop
+Makoto_Frequency2:
+ and 3
+ add a,0A0H
+ push af
+ add a,4
+ ld d,a
+ inc hl
+ inc hl
+ ld e,(hl)
+ inc hl
+ call Makoto_WriteRegister2
+ pop de
+ ld e,(hl)
+ inc hl
+ call Makoto_WriteRegister2
+ jr Makoto_Process_Loop
+
+; hl = sound data
+Makoto_Instrument:
+ bit 2,a
+ jr nz,Makoto_Instrument2
+Makoto_Instrument1:
+ and 3
+ add a,30H
+ ld b,24
+Makoto_Instrument1_Loop:
+ ld d,a
+ ld e,(hl)
+ inc hl
+ call Makoto_WriteRegister1
+ add a,4
+ djnz Makoto_Instrument1_Loop
+ add a,20H
+ ld d,a
+ ld e,(hl)
+ inc hl
+ call Makoto_WriteRegister1
+ jp Makoto_Process_Loop
+Makoto_Instrument2:
+ and 3
+ add a,30H
+ ld b,24
+Makoto_Instrument2_Loop:
+ ld d,a
+ ld e,(hl)
+ inc hl
+ call Makoto_WriteRegister2
+ add a,4
+ djnz Makoto_Instrument2_Loop
+ add a,20H
+ ld d,a
+ ld e,(hl)
+ inc hl
+ call Makoto_WriteRegister2
+ jp Makoto_Process_Loop
+
+Makoto_Mute:
+ ld b,10H
+ ld de,800FH
+Makoto_Mute_ReleaseRateLoop:
+ call Makoto_WriteRegister1
+ call Makoto_WriteRegister2
+ inc d
+ djnz Makoto_Mute_ReleaseRateLoop
+ ld b,08H
+ ld de,2800H
+Makoto_Mute_KeyOffLoop:
+ call Makoto_WriteRegister1
+ inc e
+ djnz Makoto_Mute_KeyOffLoop
+ ret
+
+; d = register
+; e = value
+Makoto_WriteRegister1:
+ push af
+Makoto_WriteRegister1_Wait:
+ in a,(Makoto_STATUS1)
+ rla
+ jr c,Makoto_WriteRegister1_Wait
+ ld a,d
+ out (Makoto_FM1_ADDRESS),a
+ jp $ + 3 ; wait 17 cycles (7.6 bus cycles)
+ ld a,e
+ out (Makoto_FM1_DATA),a
+ pop af
+ ret
+
+; d = register
+; e = value
+Makoto_WriteRegister2:
+ push af
+Makoto_WriteRegister2_Wait:
+ in a,(Makoto_STATUS1)
+ rla
+ jr c,Makoto_WriteRegister2_Wait
+ ld a,d
+ out (Makoto_FM2_ADDRESS),a
+ jp $ + 3 ; wait 17 cycles (7.6 bus cycles)
+ ld a,e
+ out (Makoto_FM2_DATA),a
+ pop af
+ ret
+
+; Data in RAM
+Makoto_found:
+ db 0
A => src/Memory.asm +130 -0
@@ 0,0 1,130 @@
+;
+;
+;
+RDSLT: equ 000CH
+WRSLT: equ 0014H
+CALSLT: equ 001CH
+ENASLT: equ 0024H
+EXPTBL: equ 0FCC1H
+
+; h = memory address high byte (bits 6-7: page)
+; a <- slot ID formatted FxxxSSPP
+; Modifies: f, bc, de
+Memory_GetSlot:
+ in a,(0A8H)
+ bit 7,h
+ jr z,Memory_GetSlot_PrimaryShiftContinue
+ rrca
+ rrca
+ rrca
+ rrca
+Memory_GetSlot_PrimaryShiftContinue:
+ bit 6,h
+ jr z,Memory_GetSlot_PrimaryShiftDone
+ rrca
+ rrca
+Memory_GetSlot_PrimaryShiftDone:
+ and 00000011B
+ ld c,a
+ ld b,0
+ ex de,hl
+ ld hl,EXPTBL
+ add hl,bc
+ ld a,(hl)
+ and 80H
+ or c
+ ld c,a
+ inc hl ; move to SLTTBL
+ inc hl
+ inc hl
+ inc hl
+ ld a,(hl)
+ ex de,hl
+ bit 7,h
+ jr z,Memory_GetSlot_SecondaryShiftContinue
+ rrca
+ rrca
+ rrca
+ rrca
+Memory_GetSlot_SecondaryShiftContinue:
+ bit 6,h
+ jr nz,Memory_GetSlot_SecondaryShiftDone
+ rlca
+ rlca
+Memory_GetSlot_SecondaryShiftDone:
+ and 00001100B
+ or c
+ ret
+
+; Search all slots and subslots for a match.
+; Invoke Continue to continue searching where a previous search left off.
+; hl = detection routine (receives a = slot ID, can modify all)
+; f <- c: found
+; a <- slot number
+; Modifies: af, bc, de
+Memory_SearchSlots:
+ ld a,0
+Memory_SearchSlots_PrimaryLoop:
+ ex de,hl
+ ld hl,EXPTBL
+ ld b,0
+ ld c,a
+ add hl,bc
+ ld a,(hl)
+ ex de,hl
+ and 10000000B
+ or c
+Memory_SearchSlots_SecondaryLoop:
+ push af
+ push hl
+ call Memory_SearchSlots_JumpHL
+ pop hl
+ jp c,Memory_SearchSlots_Found
+ pop af
+ add a,00000100B
+ jp p,Memory_SearchSlots_NextPrimary
+ bit 4,a
+ jp z,Memory_SearchSlots_SecondaryLoop
+Memory_SearchSlots_NextPrimary:
+ inc a
+ and 00000011B
+ ret z ; not found
+ jp Memory_SearchSlots_PrimaryLoop
+Memory_SearchSlots_Found:
+ pop af
+ scf
+ ret
+Memory_SearchSlots_JumpHL:
+ jp hl
+
+; Match a string in a slot.
+; a = slot
+; bc = string length
+; de = string
+; hl = address
+; f <- c: found
+; Modifies: f, bc, de, hl
+Memory_MatchSlotString:
+ push af
+ push bc
+ push de
+ call RDSLT
+ ei
+ pop de
+ pop bc
+ ex de,hl
+ cpi
+ jr nz,Memory_MatchSlotString_NotFound
+ jp po,Memory_MatchSlotString_Found
+ inc de
+ ex de,hl
+ pop af
+ jp Memory_MatchSlotString
+Memory_MatchSlotString_Found:
+ pop af
+ scf
+ ret
+Memory_MatchSlotString_NotFound:
+ pop af
+ and a
+ ret
A => src/Neotron.asm +232 -0
@@ 0,0 1,232 @@
+;
+; Supersoniqs Neotron / JunSoft OSC1N0 - Yamaha YM2610(B) OPNB
+;
+Neotron_BASE: equ 0BC00H
+Neotron_STATUS0: equ Neotron_BASE
+Neotron_STATUS1: equ Neotron_BASE + 2
+Neotron_FM1_ADDRESS: equ Neotron_BASE
+Neotron_FM1_DATA: equ Neotron_BASE + 1
+Neotron_FM2_ADDRESS: equ Neotron_BASE + 2
+Neotron_FM2_DATA: equ Neotron_BASE + 3
+Neotron_ID_ADDRESS: equ 806CH
+Neotron_SIOS_ENABLE_IO: equ 801CH
+
+; f <- c: found
+Neotron_Detect:
+ ld hl,Neotron_Detect_MatchID
+ call Memory_SearchSlots
+ ret nc
+ push af
+ ld iyh,a
+ ld (Neotron_slot),a
+ ld a,1
+ ld (Neotron_found),a
+ ld ix,Neotron_SIOS_ENABLE_IO
+ and a
+ call CALSLT
+ ei
+ pop af
+ ret
+
+; a = slot ID
+; f <- c: found
+Neotron_Detect_MatchID:
+ ld de,Neotron_id
+ ld hl,Neotron_ID_ADDRESS
+ ld bc,18
+ jp Memory_MatchSlotString
+
+; hl = sound data
+; a <- wait amount
+Neotron_Process:
+ exx
+ ld h,Neotron_BASE >> 8
+ call Memory_GetSlot
+ ex af,af'
+ ld a,(Neotron_slot)
+ ld h,Neotron_BASE >> 8
+ call ENASLT
+ ei
+ exx
+ call Neotron_Process_Loop
+ exx
+ ex af,af'
+ ld h,Neotron_BASE >> 8
+ call ENASLT
+ ei
+ ex af,af'
+ exx
+ ret
+
+; hl = sound data
+; a <- wait amount
+Neotron_Process_Loop:
+ ld a,(hl)
+ inc hl
+ cp 80H
+ jr c,Neotron_Key
+ cp 88H
+ jr c,Neotron_Frequency
+ sub 90H
+ jr c,Neotron_Instrument
+ ret nz
+ ld e,(hl)
+ inc hl
+ ld d,(hl)
+ inc hl
+ add hl,de
+ jr Neotron_Process_Loop
+
+; hl = sound data
+Neotron_Key:
+ ld e,a
+ and 0F8H
+ add a,e ; shift bits 3-7 to the left
+ ld e,a
+ ld d,28H
+ call Neotron_WriteRegister1
+ jr Neotron_Process_Loop
+
+; hl = sound data
+Neotron_Frequency:
+ bit 2,a
+ jr nz,Neotron_Frequency2
+Neotron_Frequency1:
+ and 3
+ add a,0A0H
+ push af
+ add a,4
+ ld d,a
+ inc hl
+ inc hl
+ ld e,(hl)
+ inc hl
+ call Neotron_WriteRegister1
+ pop de
+ ld e,(hl)
+ inc hl
+ call Neotron_WriteRegister1
+ jr Neotron_Process_Loop
+Neotron_Frequency2:
+ and 3
+ add a,0A0H
+ push af
+ add a,4
+ ld d,a
+ inc hl
+ inc hl
+ ld e,(hl)
+ inc hl
+ call Neotron_WriteRegister2
+ pop de
+ ld e,(hl)
+ inc hl
+ call Neotron_WriteRegister2
+ jr Neotron_Process_Loop
+
+; hl = sound data
+Neotron_Instrument:
+ bit 2,a
+ jr nz,Neotron_Instrument2
+ and 3
+ add a,30H
+ ld b,24
+Neotron_Instrument1_Loop:
+ ld d,a
+ ld e,(hl)
+ inc hl
+ call Neotron_WriteRegister1
+ add a,4
+ djnz Neotron_Instrument1_Loop
+ add a,20H
+ ld d,a
+ ld e,(hl)
+ inc hl
+ call Neotron_WriteRegister1
+ jp Neotron_Process_Loop
+Neotron_Instrument2:
+ and 3
+ add a,30H
+ ld b,24
+Neotron_Instrument2_Loop:
+ ld d,a
+ ld e,(hl)
+ inc hl
+ call Neotron_WriteRegister2
+ add a,4
+ djnz Neotron_Instrument2_Loop
+ add a,20H
+ ld d,a
+ ld e,(hl)
+ inc hl
+ call Neotron_WriteRegister2
+ jp Neotron_Process_Loop
+
+Neotron_Mute:
+ ld h,Neotron_BASE >> 8
+ call Memory_GetSlot
+ ex af,af'
+ ld a,(Neotron_slot)
+ ld h,Neotron_BASE >> 8
+ call ENASLT
+ ei
+ ld b,10H
+ ld de,800FH
+Neotron_Mute_ReleaseRateLoop:
+ call Neotron_WriteRegister1
+ call Neotron_WriteRegister2
+ inc d
+ djnz Neotron_Mute_ReleaseRateLoop
+ ld b,08H
+ ld de,2800H
+Neotron_Mute_KeyOffLoop:
+ call Neotron_WriteRegister1
+ inc e
+ djnz Neotron_Mute_KeyOffLoop
+ ex af,af'
+ ld h,Neotron_BASE >> 8
+ call ENASLT
+ ei
+ ret
+
+; d = register
+; e = value
+Neotron_WriteRegister1:
+ push af
+Neotron_WriteRegister1_Wait:
+ ld a,(Neotron_STATUS0)
+ rla
+ jr c,Neotron_WriteRegister1_Wait
+ ld a,d
+ ld (Neotron_FM1_ADDRESS),a
+ jp $ + 3 ; wait 17 cycles (7.6 bus cycles)
+ ld a,e
+ ld (Neotron_FM1_DATA),a
+ pop af
+ ret
+
+; d = register
+; e = value
+Neotron_WriteRegister2:
+ push af
+Neotron_WriteRegister2_Wait:
+ ld a,(Neotron_STATUS0)
+ rla
+ jr c,Neotron_WriteRegister2_Wait
+ ld a,d
+ ld (Neotron_FM2_ADDRESS),a
+ jp $ + 3 ; wait 17 cycles (7.6 bus cycles)
+ ld a,e
+ ld (Neotron_FM2_DATA),a
+ pop af
+ ret
+
+;
+Neotron_id:
+ db "OSC YM OPNBYM2610"
+
+; Data in RAM
+Neotron_found:
+ db 0
+Neotron_slot:
+ db 0
A => src/Player.asm +71 -0
@@ 0,0 1,71 @@
+;
+;
+;
+ INCLUDE "SFG.asm"
+ INCLUDE "Makoto.asm"
+ INCLUDE "Neotron.asm"
+
+; f <- c: found
+Player_Detect:
+ call SFG_Detect
+ ld bc,SFG_Process
+ ld de,SFG_Mute
+ jr c,Player_SetProcessAndMute
+ call Makoto_Detect
+ ld bc,Makoto_Process
+ ld de,Makoto_Mute
+ jr c,Player_SetProcessAndMute
+ call Neotron_Detect
+ ld bc,Neotron_Process
+ ld de,Neotron_Mute
+ jr c,Player_SetProcessAndMute
+ ld a,0C9H ; ret
+ ld (Player_Process),a
+ ld (Player_Mute),a
+ ret
+
+; bc = process function
+; de = mute function
+Player_SetProcessAndMute:
+ ld a,0C3H ; jp
+ ld (Player_Process),a
+ ld (Player_Process + 1),bc
+ ld (Player_Mute),a
+ ld (Player_Mute + 1),de
+ ret
+
+; hl = sound data
+Player_Play:
+ ld (Player_position),hl
+ ld a,1
+ ld (Player_wait),a
+ ret
+
+Player_Stop:
+ ld hl,0
+ ld (Player_position),hl
+ call Player_Mute
+ ret
+
+Player_Tick:
+ ld hl,Player_wait
+ dec (hl)
+ ret nz
+ ld hl,(Player_position)
+ ld a,l
+ or h
+ ret z
+ call Player_Process
+ ld (Player_wait),a
+ ld (Player_position),hl
+ ret
+
+; Data in RAM
+Player_position:
+ dw 0
+Player_wait:
+ db 0
+Player_Process:
+ db 0C9H, 0, 0
+Player_Mute:
+ db 0C9H, 0, 0
A => src/SFG.asm +163 -0
@@ 0,0 1,163 @@
+;
+; Yamaha SFG-01 / SFG-05 - Yamaha YM2151 OPM
+;
+SFG_BASE: equ 0BFF0H
+SFG_ADDRESS: equ SFG_BASE
+SFG_DATA: equ SFG_BASE + 1
+SFG_STATUS: equ SFG_BASE + 1
+SFG_ID_ADDRESS: equ 80H
+
+; f <- c: found
+SFG_Detect:
+ ld hl,SFG_Detect_MatchID
+ call Memory_SearchSlots
+ ret nc
+ ld (SFG_slot),a
+ ld a,1
+ ld (SFG_found),a
+ ret
+
+; a = slot ID
+; f <- c: found
+SFG_Detect_MatchID:
+ ld de,SFG_id
+ ld hl,SFG_ID_ADDRESS
+ ld bc,6
+ jp Memory_MatchSlotString
+
+; hl = sound data
+; a <- wait amount
+SFG_Process:
+ exx
+ ld h,SFG_BASE >> 8
+ call Memory_GetSlot
+ ex af,af'
+ ld a,(SFG_slot)
+ ld h,SFG_BASE >> 8
+ call ENASLT
+ ei
+ exx
+ call SFG_Process_Loop
+ exx
+ ex af,af'
+ ld h,SFG_BASE >> 8
+ call ENASLT
+ ei
+ ex af,af'
+ exx
+ ret
+
+; hl = sound data
+; a <- wait amount
+SFG_Process_Loop:
+ ld a,(hl)
+ inc hl
+ cp 80H
+ jr c,SFG_Key
+ cp 88H
+ jr c,SFG_Frequency
+ sub 90H
+ jr c,SFG_Instrument
+ ret nz
+ ld e,(hl)
+ inc hl
+ ld d,(hl)
+ inc hl
+ add hl,de
+ jr SFG_Process_Loop
+
+; hl = sound data
+SFG_Key:
+ ld e,a
+ ld d,08H
+ call SFG_WriteRegister
+ jr SFG_Process_Loop
+
+; hl = sound data
+SFG_Frequency:
+ and 7
+ add a,28H
+ push af
+ add a,8
+ ld d,a
+ ld e,(hl)
+ inc hl
+ call SFG_WriteRegister
+ pop de
+ ld e,(hl)
+ inc hl
+ call SFG_WriteRegister
+ inc hl
+ inc hl
+ jr SFG_Process_Loop
+
+; hl = sound data
+SFG_Instrument:
+ and 7
+ add a,40H
+ ld b,24
+SFG_Instrument_Loop:
+ ld d,a
+ ld e,(hl)
+ inc hl
+ call SFG_WriteRegister
+ add a,8
+ djnz SFG_Instrument_Loop
+ add a,20H
+ ld d,a
+ ld e,(hl)
+ inc hl
+ call SFG_WriteRegister
+ jr SFG_Process_Loop
+
+SFG_Mute:
+ ld h,SFG_BASE >> 8
+ call Memory_GetSlot
+ ex af,af'
+ ld a,(SFG_slot)
+ ld h,SFG_BASE >> 8
+ call ENASLT
+ ei
+ ld b,20H
+ ld de,0E00FH
+SFG_Mute_ReleaseRateLoop:
+ call SFG_WriteRegister
+ inc d
+ djnz SFG_Mute_ReleaseRateLoop
+ ld b,08H
+ ld de,0800H
+SFG_Mute_KeyOffLoop:
+ call SFG_WriteRegister
+ inc e
+ djnz SFG_Mute_KeyOffLoop
+ ex af,af'
+ ld h,SFG_BASE >> 8
+ call ENASLT
+ ei
+ ret
+
+; d = register
+; e = value
+SFG_WriteRegister:
+ push af
+SFG_WriteRegister_Wait:
+ ld a,(SFG_STATUS)
+ rla
+ jr c,SFG_WriteRegister_Wait
+ ld a,d
+ ld (SFG_ADDRESS),a
+ cp (hl) ; R800 wait: ~4 bus cycles
+ ld a,e
+ ld (SFG_DATA),a
+ pop af
+ ret
+
+;
+SFG_id:
+ db "MCHFM0"
+
+; Data in RAM
+SFG_found:
+ db 0
+SFG_slot:
+ db 0
A => tools/audio.js +553 -0
@@ 0,0 1,553 @@
+"use strict"
+const fs = require('fs');
+const { checkTypes } = require('./utils');
+
+class AudioTrack {
+ constructor(name) {
+ checkTypes(arguments, "string");
+ this.name = name;
+ this.commands = [];
+ this.loop = -1;
+ }
+
+ addCommand(command) {
+ checkTypes(arguments, AudioCommand);
+ if (command instanceof AudioCommandWait && this.commands.length > 0) {
+ if (this.commands[this.commands.length - 1] instanceof AudioCommandWait) {
+ this.commands.push(command.add(this.commands.pop()));
+ return;
+ }
+ } else if (command instanceof AudioCommandLoop) {
+ this.loop = this.commands.length;
+ } else if (command instanceof AudioCommandFMInstrument) {
+ let tail = [];
+ while (this.commands.length > 0 && this.commands[this.commands.length - 1] instanceof AudioCommandFMInstrument) {
+ const lastCommand = this.commands.pop();
+ if (lastCommand.channel == command.channel) {
+ break;
+ } else {
+ tail.push(lastCommand);
+ }
+ }
+ while (tail.length > 0) {
+ this.commands.push(tail.pop());
+ }
+ }
+ this.commands.push(command);
+ }
+
+ addCommands(commands) {
+ checkTypes(arguments, Array);
+ for (const command of commands)
+ this.addCommand(command);
+ }
+
+ moveWaitAfterLoop() {
+ if (this.loop >= 0 && this.commands[this.loop + 1] instanceof AudioCommandWait) {
+ const track = new AudioTrack(this.name);
+ track.addCommands(this.commands.slice(0, this.loop));
+ track.addCommand(this.commands[this.loop + 1]);
+ track.addCommand(this.commands[this.loop]);
+ track.addCommands(this.commands.slice(this.loop + 2, this.commands.length - 1));
+ track.addCommand(this.commands[this.loop + 1]);
+ track.addCommand(this.commands[this.commands.length - 1]);
+ return track;
+ } else {
+ return this;
+ }
+ }
+
+ removeWaitsUnder(threshold) {
+ checkTypes(arguments, "number");
+ const track = new AudioTrack(this.name);
+ let accumulator = 0;
+ for (const command of this.commands) {
+ if (command instanceof AudioCommandWait) {
+ if (command.time >= threshold) {
+ track.addCommand(new AudioCommandWait(command.time + accumulator, 44100));
+ accumulator = 0;
+ } else {
+ accumulator += command.time;
+ }
+ } else if (command instanceof AudioCommandLoop || command instanceof AudioCommandEnd) {
+ if (accumulator > 0) {
+ track.addCommand(new AudioCommandWait(accumulator, 44100));
+ accumulator = 0;
+ }
+ track.addCommand(command);
+ } else {
+ track.addCommand(command);
+ }
+ }
+ return track;
+ }
+
+ quantise(newTickFrequency) {
+ checkTypes(arguments, "number");
+ const track = new AudioTrack(this.name);
+ let time = 0;
+ for (const command of this.commands) {
+ if (command instanceof AudioCommandWait) {
+ time += command.time * newTickFrequency;
+ if (time >= command.base) {
+ const newTime = Math.floor(time / command.base) * command.base;
+ track.addCommand(new AudioCommandWait(newTime / newTickFrequency, command.base));
+ time -= newTime;
+ }
+ } else {
+ track.addCommand(command);
+ }
+ }
+ return track;
+ }
+
+ trimLeadingSilence() {
+ const track = new AudioTrack(this.name);
+ for (let index = 0; index < this.commands.length; ++index) {
+ if (!(this.commands[index] instanceof AudioCommandWait)) {
+ track.addCommands(this.commands.slice(index, this.commands.length));
+ break;
+ }
+ }
+ return track;
+ }
+
+ toFMCommands() {
+ const track = new AudioTrack(this.name);
+ const registers = new Array(512).fill(0);
+ let clockDivider = 6;
+ for (const command of this.commands) {
+ if (command instanceof AudioCommandOPNB) {
+ registers[command.address] = command.value;
+ const addressLSB = command.address & 0xFF;
+ const channel = (command.address >> 8) << 2 | command.address & 3;
+ if (command.address == 0x28) {
+ track.addCommand(new AudioCommandFMKey(command.value & 7, command.value >> 4));
+ } else if (addressLSB >= 0xA0 && addressLSB < 0xA4) {
+ const value = registers[command.address + 4] << 8 | registers[command.address];
+ track.addCommand(AudioCommandFMFrequency.ofFNum(channel, (value & 0x7FF) << (value >> 11), command.clockFrequency / clockDivider));
+ } else if (addressLSB >= 0x30 && addressLSB < 0x90 || addressLSB >= 0xB0 && addressLSB < 0xB4) {
+ const offset = (channel >> 2) << 8 | channel & 3;
+ track.addCommand(new AudioCommandFMInstrument(channel, [
+ registers[0x30 + offset], registers[0x34 + offset], registers[0x38 + offset], registers[0x3C + offset],
+ registers[0x40 + offset], registers[0x44 + offset], registers[0x48 + offset], registers[0x4C + offset],
+ registers[0x50 + offset], registers[0x54 + offset], registers[0x58 + offset], registers[0x5C + offset],
+ registers[0x60 + offset], registers[0x64 + offset], registers[0x68 + offset], registers[0x6C + offset],
+ registers[0x70 + offset], registers[0x74 + offset], registers[0x78 + offset], registers[0x7C + offset],
+ registers[0x80 + offset], registers[0x84 + offset], registers[0x88 + offset], registers[0x8C + offset],
+ registers[0xB0 + offset] | 0xC0
+ ]));
+ } else if (command.address >= 0x2D && command.address < 0x30) {
+ clockDivider = [6, 3, 2][command.address - 0x2D];
+ }
+ } else {
+ track.addCommand(command);
+ }
+ }
+ return track;
+ }
+
+ mapChannels(mapping) {
+ checkTypes(arguments, Array);
+ const track = new AudioTrack(this.name);
+ for (const command of this.commands) {
+ if (command instanceof AudioCommandFMChannel) {
+ const newChannel = mapping[command.channel];
+ if (newChannel < 0) {
+ // skip
+ } else if (command instanceof AudioCommandFMKey) {
+ track.addCommand(new AudioCommandFMKey(mapping[command.channel], command.slots));
+ } else if (command instanceof AudioCommandFMFrequency) {
+ track.addCommand(new AudioCommandFMFrequency(mapping[command.channel], command.frequency));
+ } else if (command instanceof AudioCommandFMInstrument) {
+ track.addCommand(new AudioCommandFMInstrument(mapping[command.channel], command.parameters));
+ }
+ } else {
+ track.addCommand(command);
+ }
+ }
+ return track;
+ }
+
+ frequencyBeforeKeyOn() {
+ const track = new AudioTrack(this.name);
+ let lastCommand = null;
+ for (const command of this.commands) {
+ if (command instanceof AudioCommandFMFrequency && lastCommand instanceof AudioCommandFMKey &&
+ lastCommand.slots && command.channel == lastCommand.channel) {
+ track.commands.pop();
+ track.addCommand(command);
+ track.addCommand(lastCommand);
+ } else {
+ track.addCommand(command);
+ }
+ lastCommand = command;
+ }
+ return track;
+ }
+
+ async toGNG(tickFrequency, opnFrequency, opmFrequency) {
+ checkTypes(arguments, "number", "number");
+ const word = value => [value & 0xFF, (value >> 8) & 0xFF];
+ let samples = 0;
+ let loop = -1;
+ const data = [];
+ for (const command of this.commands) {
+ if (command instanceof AudioCommandFMKey) {
+ data.push(0x00 | command.slots << 3 | command.channel);
+ } else if (command instanceof AudioCommandFMFrequency) {
+ const keyCodeFraction = command.getKeyCodeFraction(opmFrequency);
+ const fNumBlock = command.getFNumBlock(opnFrequency / 6);
+ data.push(0x80 + command.channel, ...word(keyCodeFraction), fNumBlock >> 8, fNumBlock & 0xFF);
+ } else if (command instanceof AudioCommandFMInstrument) {
+ data.push(0x88 + command.channel, ...command.parameters);
+ } else if (command instanceof AudioCommandWait) {
+ samples += Math.round(command.getTime(44100));
+ while (samples >= 44100 / tickFrequency) {
+ const waitTicks = Math.min(Math.floor(samples * tickFrequency / 44100), 0x6F);
+ data.push(0x90 + waitTicks);
+ samples -= waitTicks * 44100 / tickFrequency;
+ }
+ } else if (command instanceof AudioCommandLoop) {
+ loop = data.length;
+ } else if (command instanceof AudioCommandEnd) {
+ if (loop >= 0) {
+ data.push(0x90, ...word(loop - data.length - 3));
+ } else {
+ data.push(0x90 + 1); // wait 1
+ data.push(0x90, ...word(-4)); // loop to wait
+ }
+ }
+ }
+ await fs.promises.writeFile(`bin/${this.name}.gng`, Buffer.from(data));
+ }
+
+ async toVGMOPNB(opnbFrequency) {
+ checkTypes(arguments, "number");
+ let samples = 0;
+ let loopOffset = -1;
+ let loopSamples = -1;
+ const commandData = [];
+ for (const command of this.commands) {
+ if (command instanceof AudioCommandOPNB) {
+ commandData.push(0x58 | command.address >> 8, command.address & 0xFF, command.value);
+ } else if (command instanceof AudioCommandFMKey) {
+ commandData.push(0x58, 0x28, command.slots << 4 | command.channel);
+ } else if (command instanceof AudioCommandFMFrequency) {
+ const fNumBlock = command.getFNumBlock(opnbFrequency / 6);
+ commandData.push(0x58 + (command.channel >> 2), 0xA4 + (command.channel & 3), fNumBlock >> 8);
+ commandData.push(0x58 + (command.channel >> 2), 0xA0 + (command.channel & 3), fNumBlock & 0xFF);
+ } else if (command instanceof AudioCommandFMInstrument) {
+ for (let i = 0; i < 24; i++)
+ commandData.push(0x58 + (command.channel >> 2), 0x30 + i * 4 + (command.channel & 3), command.parameters[i]);
+ commandData.push(0x58 + (command.channel >> 2), 0xB0 + (command.channel & 3), command.parameters[24]);
+ } else if (command instanceof AudioCommandWait) {
+ let ticks = Math.round(command.getTime(44100));
+ samples += ticks;
+ while (ticks > 0) {
+ const ticksCapped = Math.min(ticks, 65535);
+ commandData.push(0x61, ticksCapped & 0xFF, ticksCapped >> 8);
+ ticks -= ticksCapped;
+ }
+ } else if (command instanceof AudioCommandLoop) {
+ loopOffset = commandData.length;
+ loopSamples = samples;
+ } else if (command instanceof AudioCommandEnd) {
+ commandData.push(0x66);
+ }
+ }
+
+ const data = [];
+ const doubleWord = value => [value & 0xFF, (value >> 8) & 0xFF, (value >> 16) & 0xFF, (value >> 24) & 0xFF];
+ data.push(0x56, 0x67, 0x6d, 0x20); // 00 VGM ID
+ data.push(...doubleWord(0x80 + commandData.length - 0x04)); // 04 EOF offset
+ data.push(...doubleWord(0x151)); // 08 version number
+ data.push(0x00, 0x00, 0x00, 0x00); // 0C SN76489 clock
+ data.push(0x00, 0x00, 0x00, 0x00); // 10 YM2413 clock
+ data.push(0x00, 0x00, 0x00, 0x00); // 14 GD3 offset
+ data.push(...doubleWord(samples)); // 18 Total # samples
+ data.push(...doubleWord(loopOffset >= 0 ? 0x80 + loopOffset - 0x1C : 0)); // 1C Loop offset
+ data.push(...doubleWord(loopSamples >= 0 ? samples - loopSamples : 0)); // 20 Loop # samples
+ data.push(0x00, 0x00, 0x00, 0x00); // 24 Rate
+ data.push(0x00, 0x00, 0x00, 0x00); // 28 SN76489 flags
+ data.push(0x00, 0x00, 0x00, 0x00); // 2C YM2612 / YM3438 clock
+ data.push(0x00, 0x00, 0x00, 0x00); // 30 YM2151 / YM2164 clock
+ data.push(...doubleWord(0x80 - 0x34)); // 34 VGM data offset
+ data.push(0x00, 0x00, 0x00, 0x00); // 38 Sega PCM clock
+ data.push(0x00, 0x00, 0x00, 0x00); // 3C Sega PCM interface register
+ data.push(0x00, 0x00, 0x00, 0x00); // 40 RF5C68 clock
+ data.push(0x00, 0x00, 0x00, 0x00); // 44 YM2203 clock
+ data.push(0x00, 0x00, 0x00, 0x00); // 48 YM2608 clock
+ // data.push(...doubleWord(opnbFrequency | Math.pow(2, 31))); // 4C YM2610 / YM2610B clock
+ data.push(...doubleWord(opnbFrequency)); // 4C YM2610 / YM2610B clock
+ data.push(0x00, 0x00, 0x00, 0x00); // 50 YM3812 clock
+ data.push(0x00, 0x00, 0x00, 0x00); // 54 YM3526 clock
+ data.push(0x00, 0x00, 0x00, 0x00); // 58 Y8950 clock
+ data.push(0x00, 0x00, 0x00, 0x00); // 5C YMF262 clock
+ data.push(0x00, 0x00, 0x00, 0x00); // 60 YMF278B clock
+ data.push(0x00, 0x00, 0x00, 0x00); // 64 YMF271 clock
+ data.push(0x00, 0x00, 0x00, 0x00); // 68 YMZ280B clock
+ data.push(0x00, 0x00, 0x00, 0x00); // 6C RF5C164 clock
+ data.push(0x00, 0x00, 0x00, 0x00); // 70 PWM clock
+ data.push(0x00, 0x00, 0x00, 0x00); // 74 AY8910 clock
+ data.push(0x00, 0x00, 0x00, 0x00); // 78 YM2203 / YM2608/AY8910 flags
+ data.push(0x00, 0x00, 0x00, 0x00); // 7C Volume / loop modifiers
+ data.push(...commandData);
+
+ await fs.promises.writeFile(`bin/${this.name}-opnb.vgm`, Buffer.from(data));
+ }
+
+ async toVGMOPM(opmFrequency) {
+ checkTypes(arguments, "number");
+ let samples = 0;
+ let loopOffset = -1;
+ let loopSamples = -1;
+ const commandData = [];
+ for (const command of this.commands) {
+ if (command instanceof AudioCommandOPNB) {
+ throw new Error("Use toFMCommands() first.");
+ } else if (command instanceof AudioCommandFMKey) {
+ commandData.push(0x54, 0x08, command.slots << 3 | command.channel);
+ } else if (command instanceof AudioCommandFMFrequency) {
+ const keyCodeFraction = command.getKeyCodeFraction(opmFrequency);
+ commandData.push(0x54, 0x30 + command.channel, keyCodeFraction & 0xFF);
+ commandData.push(0x54, 0x28 + command.channel, keyCodeFraction >> 8);
+ } else if (command instanceof AudioCommandFMInstrument) {
+ for (let i = 0; i < 24; i++)
+ commandData.push(0x54, 0x40 + i * 8 + command.channel, command.parameters[i]);
+ commandData.push(0x54, 0x20 + command.channel, command.parameters[24]);
+ } else if (command instanceof AudioCommandWait) {
+ let ticks = Math.round(command.getTime(44100));
+ samples += ticks;
+ while (ticks > 0) {
+ const ticksCapped = Math.min(ticks, 65535);
+ commandData.push(0x61, ticksCapped & 0xFF, ticksCapped >> 8);
+ ticks -= ticksCapped;
+ }
+ } else if (command instanceof AudioCommandLoop) {
+ loopOffset = commandData.length;
+ loopSamples = samples;
+ } else if (command instanceof AudioCommandEnd) {
+ commandData.push(0x66);
+ }
+ }
+
+ const data = [];
+ const doubleWord = value => [value & 0xFF, (value >> 8) & 0xFF, (value >> 16) & 0xFF, (value >> 24) & 0xFF];
+ data.push(0x56, 0x67, 0x6d, 0x20); // 00 VGM ID
+ data.push(...doubleWord(0x40 + commandData.length - 0x04)); // 04 EOF offset
+ data.push(...doubleWord(0x151)); // 08 version number
+ data.push(0x00, 0x00, 0x00, 0x00); // 0C SN76489 clock
+ data.push(0x00, 0x00, 0x00, 0x00); // 10 YM2413 clock
+ data.push(0x00, 0x00, 0x00, 0x00); // 14 GD3 offset
+ data.push(...doubleWord(samples)); // 18 Total # samples
+ data.push(...doubleWord(loopOffset >= 0 ? 0x40 + loopOffset - 0x1C : 0)); // 1C Loop offset
+ data.push(...doubleWord(loopSamples >= 0 ? samples - loopSamples : 0)); // 20 Loop # samples
+ data.push(0x00, 0x00, 0x00, 0x00); // 24 Rate
+ data.push(0x00, 0x00, 0x00, 0x00); // 28 SN76489 flags
+ data.push(0x00, 0x00, 0x00, 0x00); // 2C YM2612 / YM3438 clock
+ data.push(...doubleWord(opmFrequency)); // 30 YM2151 / YM2164 clock
+ data.push(...doubleWord(0x40 - 0x34)); // 34 VGM data offset
+ data.push(0x00, 0x00, 0x00, 0x00); // 38 Sega PCM clock
+ data.push(0x00, 0x00, 0x00, 0x00); // 3C Sega PCM interface register
+ data.push(...commandData);
+
+ await fs.promises.writeFile(`bin/${this.name}-opm.vgm`, Buffer.from(data));
+ }
+
+ getUnusedChannels() {
+ const usedChannels = new Array(8).fill(false);
+ const keyOnChannels = new Array(8).fill(false);
+ for (const command of this.commands) {
+ if (command instanceof AudioCommandFMChannel) {
+ usedChannels[command.channel] = true;
+ if (command instanceof AudioCommandFMKey && command.slots) {
+ keyOnChannels[command.channel] = true;
+ }
+ }
+ }
+ const unusedChannels = [];
+ for (let i = 0; i < 8; ++i) {
+ if (usedChannels[i] && !keyOnChannels[i]) {
+ unusedChannels.push(i);
+ }
+ }
+ return unusedChannels;
+ }
+}
+
+class AudioCommand {
+ constructor() {
+ }
+}
+
+class AudioCommandOPNB extends AudioCommand {
+ constructor(address, value, clockFrequency) {
+ checkTypes(arguments, "number", "number", "number");
+ if (address >= 0xC0 && address < 0x100 || address >= 0x1C0)
+ throw new Error("Invalid OPN register.");
+ if ((address & 0xFF) >= 0x90 && (address & 0xFF) < 0xA0)
+ throw new Error("SSG-EG not supported.");
+ if (address == 0x27 && value & 0xC0)
+ throw new Error("CSM or 3ch modes not supported.");
+ super();
+ this.address = address;
+ this.value = value;
+ this.clockFrequency = clockFrequency;
+ }
+}
+
+class AudioCommandFMChannel extends AudioCommand {
+ constructor(channel) {
+ checkTypes(arguments, "number");
+ if (channel < 0 || channel >= 8)
+ throw new Error("Channel out of range.");
+ super();
+ this.channel = channel;
+ }
+}
+
+class AudioCommandFMKey extends AudioCommandFMChannel {
+ constructor(channel, slots) {
+ checkTypes(arguments, "number", "number");
+ super(channel);
+ this.slots = slots;
+ }
+}
+
+class AudioCommandFMFrequency extends AudioCommandFMChannel {
+ constructor(channel, frequency) {
+ checkTypes(arguments, "number", "number");
+ super(channel);
+ this.frequency = frequency;
+ }
+
+ getFNumBlock(opnFrequency) {
+ checkTypes(arguments, "number");
+ const fNum = Math.min(this.frequency * ((24 << 21) / opnFrequency), 0x3FFFF);
+ const block = Math.max(Math.floor(Math.log2(fNum) - 10), 0);
+ const fNumRounded = fNum + Math.pow(2, block - 1);
+ const blockRounded = Math.max(Math.floor(Math.log2(fNumRounded) - 10), 0);
+ return blockRounded << 11 | fNumRounded >> block;
+ }
+
+ getKeyCodeFraction(opmFrequency) {
+ checkTypes(arguments, "number");
+ const logarithm = Math.log2(this.frequency / 440 * (3579545 / opmFrequency)) + 4 + 8 / 12;
+ const keyFraction = Math.min(Math.max(Math.round(logarithm * 12 * 64), 0), 8 * 12 * 64 - 1);
+ const note = Math.floor(keyFraction / 64);
+ const octave = Math.floor(note / 12);
+ const noteMap = [0, 1, 2, 4, 5, 6, 8, 9, 10, 12, 13, 14];
+ return octave << 12 | noteMap[note % 12] << 8 | keyFraction % 64 << 2;
+ }
+
+ static ofFNum(channel, fNum, opnFrequency) {
+ checkTypes(arguments, "number", "number", "number");
+ if (fNum < 0 || fNum > 0x3FFFF)
+ throw new Error("FNum out of range.");
+ return new AudioCommandFMFrequency(channel, fNum * (opnFrequency / (24 << 21)));
+ }
+}
+
+class AudioCommandFMInstrument extends AudioCommandFMChannel {
+ constructor(channel, parameters) {
+ checkTypes(arguments, "number", Array);
+ if (parameters.length != 25)
+ throw new Error("Incorrect number of parameters.");
+ super(channel);
+ this.parameters = parameters;
+ }
+}
+
+class AudioCommandWait extends AudioCommand {
+ constructor(time, tickFrequency) {
+ checkTypes(arguments, "number", "number");
+ super();
+ this.time = time;
+ this.tickFrequency = tickFrequency;
+ }
+
+ getTime(tickFrequency) {
+ checkTypes(arguments, "number");
+ return this.time * tickFrequency / this.tickFrequency;
+ }
+
+ add(other) {
+ checkTypes(arguments, AudioCommandWait);
+ if (other.tickFrequency != this.tickFrequency)
+ throw new Error("Base timing frequency mismatch.");
+ return new AudioCommandWait(this.time + other.time, this.tickFrequency);
+ }
+}
+
+class AudioCommandLoop extends AudioCommand {
+ constructor() {
+ super();
+ }
+}
+
+class AudioCommandEnd extends AudioCommand {
+ constructor() {
+ super();
+ }
+}
+
+class VGMLoader {
+ constructor(name, path) {
+ checkTypes(arguments, "string", "string");
+ this.name = name;
+ this.path = path;
+ }
+
+ async loadTrack() {
+ const vgm = await fs.promises.readFile(this.path);
+ const getByte = address => vgm[address];
+ const getWord = address => getByte(address) | getByte(address + 1) << 8;
+ const getDoubleWord = address => getWord(address) | getWord(address + 2) << 16;
+ if (getDoubleWord(0x00) != 0x206d6756)
+ throw new Error("Not a VGM file.");
+ const version = getDoubleWord(0x08);
+ const loop = getDoubleWord(0x1C) ? getDoubleWord(0x1C) + 0x1C : 0;
+ const headerSize = version >= 0x150 ? getDoubleWord(0x34) + 0x34 : 0x40;
+ const opnFrequency = version >= 0x150 && headerSize >= 0x48 ? getDoubleWord(0x44) & 0x3FFFFFFF : 0;
+ const tickFrequency = 44100;
+ const track = new AudioTrack(this.name);
+ let address = headerSize;
+ while (address < vgm.length) {
+ if (address == loop)
+ track.addCommand(new AudioCommandLoop());
+ const type = getByte(address++);
+ if (type == 0x55) {
+ const register = getByte(address++);
+ const value = getByte(address++);
+ track.addCommand(new AudioCommandOPNB(register, value, opnFrequency * 2));
+ } else if (type == 0xA5) {
+ const register = getByte(address++);
+ const value = getByte(address++);
+ if (register == 0x28) {
+ track.addCommand(new AudioCommandOPNB(register, value | 4, opnFrequency * 2));
+ } else if (register >= 0x30) {
+ track.addCommand(new AudioCommandOPNB(register | 0x100, value, opnFrequency * 2));
+ }
+ } else if (type == 0x61) {
+ track.addCommand(new AudioCommandWait(getWord(address), tickFrequency));
+ address += 2;
+ } else if (type == 0x62) {
+ track.addCommand(new AudioCommandWait(735, tickFrequency));
+ } else if (type == 0x63) {
+ track.addCommand(new AudioCommandWait(882, tickFrequency));
+ } else if (type == 0x66) {
+ track.addCommand(new AudioCommandEnd());
+ break;
+ } else if (type >= 0x70 && type < 0x80) {
+ track.addCommand(new AudioCommandWait((type & 15) + 1, tickFrequency));
+ } else {
+ throw new Error(`Unsupported command ${type.toString(16)} at address ${(address - 1).toString(16)}`);
+ }
+ }
+ return track;
+ }
+}
+
+exports.AudioTrack = AudioTrack;
+exports.VGMLoader = VGMLoader;
A => tools/glass.jar +0 -0
A => tools/resources.js +86 -0
@@ 0,0 1,86 @@
+"use strict"
+const fs = require('fs');
+const path = require('path');
+const { checkTypes } = require('./utils');
+const { AudioTrack, VGMLoader } = require('./audio');
+
+async function main() {
+ if (process.argv.length < 3) {
+ console.log(`Usage: node ${process.argv[1]} resources.json`);
+ process.exit(1);
+ }
+
+ const path = process.argv[2];
+
+ const resources = await new ResourcesLoader(path).parse();
+ await resources.write();
+
+ for (const track of resources.tracks) {
+ const unusedChannels = track.toFMCommands().getUnusedChannels();
+ if (unusedChannels.length > 0) {
+ console.log(`Warning: Track ${track.name} has unused channels: ${unusedChannels.join(", ")}`);
+ }
+ }
+}
+
+class Resources {
+ constructor(tickFrequency, opnbFrequency, opmFrequency) {
+ checkTypes(arguments, "number", "number", "number");
+ this.tracks = [];
+ this.tickFrequency = tickFrequency;
+ this.opnbFrequency = opnbFrequency;
+ this.opmFrequency = opmFrequency;
+ }
+
+ addAudioTrack(track) {
+ checkTypes(arguments, AudioTrack);
+ this.tracks.push(track);
+ }
+
+ async write() {
+ return Promise.all([
+ Promise.all(this.tracks.map(async track => track.toVGMOPNB(this.opnbFrequency))),
+ Promise.all(this.tracks.map(async track => track.toVGMOPM(this.opmFrequency))),
+ Promise.all(this.tracks.map(async track => track.toGNG(this.tickFrequency, this.opnbFrequency, this.opmFrequency)))
+ ]);
+ }
+}
+
+class ResourcesLoader {
+ constructor(path) {
+ checkTypes(arguments, "string");
+ this.path = path;
+ }
+
+ async parse() {
+ const json = JSON.parse(await fs.promises.readFile(this.path));
+
+ const resources = new Resources(json.tickFrequency, json.opnbFrequency, json.opmFrequency);
+
+ const tracks = Promise.all(json.tracks.map(async jsonTrack => {
+ const trackPath = path.resolve(path.dirname(this.path), jsonTrack.path);
+ const track = await new VGMLoader(jsonTrack.name, trackPath).loadTrack();
+ const waitThreshold = "waitThreshold" in jsonTrack ? jsonTrack.waitThreshold : json.waitThreshold || 0;
+ const channelMapping = "channelMapping" in jsonTrack ? jsonTrack.channelMapping : json.channelMapping || [0, 1, 2, 3, 4, 5, 6, 7];
+ return (track
+ .moveWaitAfterLoop()
+ .removeWaitsUnder(waitThreshold)
+ .toFMCommands()
+ .mapChannels(channelMapping)
+ .frequencyBeforeKeyOn()
+ .trimLeadingSilence()
+ // .quantise(60)
+ );
+ }));
+
+ for (const track of await tracks)
+ resources.addAudioTrack(track);
+
+ return resources;
+ }
+}
+
+exports.Resources = Resources;
+exports.ResourcesLoader = ResourcesLoader;
+
+main().catch(reason => { throw reason; });
A => tools/utils.js +35 -0
@@ 0,0 1,35 @@
+"use strict"
+
+function toAsmHex(value) {
+ const hex = value.toString(16).toUpperCase();
+ return hex.charCodeAt(0) > 64 ? `0${hex}H` : `${hex}H`;
+}
+
+/**
+ * Type checker, intended for use at the start of any function.
+ * Primitive types are checked by a string, objects by their constructor.
+ * Use an array to denote optionality. Example:
+ *
+ * function myFunction(aNumber, aFunction, anOptionalString) {
+ * checkTypes(arguments, "number", Function, ["string"])
+ * }
+ *
+ * @param {Array} args An array-like list of arguments.
+ * @param {string|Function|Array} types... The types to check against.
+ */
+function checkTypes(args) {
+ for (let i = 1; i < arguments.length; i++) {
+ let type = arguments[i], arg = args[i - 1];
+ if (!(type instanceof Array && (type = type[0]) && arg == null) &&
+ !(typeof type == "string" && typeof arg == type && arg == arg) &&
+ !(type instanceof Function && arg instanceof type)) {
+ if (typeof type != "string" && !(type instanceof Function))
+ throw new Error(`Unsupported type ${type}.`);
+ throw new Error(`Argument of type ${typeof type != "string" && type.constructor.name || type} expected at index ${i - 1}. ` +
+ `Was: ${arg && typeof arg != "string" && arg.constructor && arg.constructor.name || arg}.`);
+ }
+ }
+}
+
+exports.checkTypes = checkTypes;
+exports.toAsmHex = toAsmHex;