#=====================================================================================================
# DSF/miniDSF auto-timer v0.01 (2008-10-13) by kingshriek
# Command-line interface script to automatically time DSF/miniDSF files
# For looping tracks, the script times 2 loops with a 10 second fade.
# Requires psfpoint (http://www.neillcorlett.com/psf/utilities.html)
# Caution: this will overwrite the length and fade tags of the specified files
# Version history located near end of file
#=====================================================================================================

#=====================================================================================================
import os
import sys
import zlib
from struct import *
from array import *
from glob import *
#import dsftrackprint    # DEBUG
#=====================================================================================================

#=====================================================================================================
# loads sound memory from ssf/minissf file and associated ssflibs into an array
def dsfload(ndsf):
	szpsfhdr = 0x10    # size of PSF header
	# Initialize sound memory array
	dsfbin = array('B','x00'*0x800000)
	fdsf = open(ndsf,'rb')
	szdsf = os.path.getsize(ndsf)
	if szdsf <= 0x1600000:    # no dsf file should ever be above 16 MB in size
		data = fdsf.read(szdsf)
	else:
		data = 'invalid'    # dummy file data so that the next test fails
	fdsf.close()
	# Check for valid DSF header
	if data[:0x3] != 'PSF' or data[0x3] != '\x12':
		print '%s - Error: Invalid dsf file.' % ndsf
		return array('B',[1])
	offtag = unpack('<I',data[0x8:0xC])[0] + szpsfhdr
	libs = ['']*9 + [os.path.basename(ndsf)]
	# Get dsflib names
	if offtag < szdsf:
		tagdata = data[offtag:]
		for k in range (1,10):
			if k > 1:
				sstr = '_lib%d=' % k
			else:
				sstr = '_lib='
			offlib = tagdata.find(sstr)
			if offlib > -1:
				sztag = tagdata[offlib:].find('\x0A')
				if sztag > -1:
					libs[k-1] = tagdata[offlib+len(sstr):offlib+sztag]
	# Load dsf/minidsf and any dsflibs if they exist
	for lib in libs:
		if lib != '':
			nlib = os.path.join(os.path.dirname(ndsf),lib)
			flib = open(nlib, 'rb')
			hdr = flib.read(0x10)
			szlib = unpack('<I',hdr[0x8:0xC])[0]
			data = zlib.decompress(flib.read(szlib))
			szdata = len(data)
			flib.close()
			offset = unpack('<I',data[:0x4])[0]
			dsfbin[offset:offset+szdata-4] = array('B',data[0x4:])
	if len(dsfbin) > 0x800000:
		dsfbin = dsfbin[:0x800000]
	return dsfbin
#=====================================================================================================

#=====================================================================================================
# extract track data out of sound memory specified by the SEQUENCE_START command in the command area
def gettrack(dsfbin):
	# Read through host commands to get sequence bank and track number
	commandlist = dsfbin[0x12200:0x12400] + dsfbin[0x13200:0x13400]    # run through command areas of all known driver versions (hope for no false positives!)
	for k in range(0,64):
		command = commandlist[0x10*k:0x10*k+0x10]
		if command[0] == 0x01:
			bank = command[0x3]
			track = command[0x4]
			#print 'DEBUG: bank %d, track %d' % (bank, track)
			break
	# Look up sequence bank in area map and extract it
	areamap = dsfbin[0x14000:0x14080]
	offseq = unpack('<I',areamap[0x8*bank:0x8*bank+4])[0]
	seqbank = dsfbin[offseq:]
	szseq = unpack('<I',seqbank[0x8:0xC])[0]
	seqbank = seqbank[:szseq]
	# Extract track data from sequence bank
	offtrack = unpack('<I',seqbank[4*track+0x10:4*track+0x14])[0]
	trackdata = seqbank[offtrack:]
	endtrack = trackdata.tostring().find('ENDD')    # very small chance of a false positive here - if so, autotime_process will probably crash
	if endtrack >= 0:
		trackdata = trackdata[:endtrack+4]
	return trackdata
#=====================================================================================================

#=====================================================================================================
# converts tempo to units of microseconds/quarter-note
def convert_tempo(tempo):
	# algo RE'd from DGconv 0.5 (md5 4354D3C43B45CC42B327E1177DFF8286)
	#   - calculation begins at virtual address 0x402967 (input in EAX), ends at virtual address 0x4029d8 (output in EAX)
	# tempo = tempo * 1001.92 + 0.5
	# tempo = 6.0e3/int(tempo)*10.0e3
	# tempo = 60.0/int(tempo)*1.0e6
	# tempo = int(tempo)
	tempo *= 1000    # This actually seems more accurate (and makes more sense)
	return tempo
#=====================================================================================================

#=====================================================================================================
# sequence step-time accumulator
# also accounts for tempo - returns time in units of step-microseconds/quarter-note
def autotime_process(trackdata, tempo=0x1F0, offset=0x0C, count=100000):
	extgate = [0x200,0x800,0x1000,0x2000]    # EXT_GATE table
	extdelta = [0x100,0x200,0x800,0x1000]    # EXT_DELTA table
	steps = gate = dgate = 0
	preloopsteps = -1    # -1 used here to indicate no looping
	loop_events = 0
	# Process all sequence events in track data, accumulating the step count
	# Not sure how consistent the Sega Dreamcast's sequence format is between driver versions, but
	# lets just pretend that it is
	while count > 0:
		ctempo = convert_tempo(tempo)    # Convert to microseconds/quarter-note
		event = trackdata[offset]
		# Lots of variable length commands here depending on whether gate/step values are to be interpreted as 8 or 16 bits
		if event in range(0x00,0x10):    # NOTE (8-bit gate, 8-bit step)
			step = trackdata[offset+4]*ctempo
			gate = trackdata[offset+3]*ctempo + dgate
			dgate = 0
			offset += 5
		elif event in range(0x10,0x20):    # NOTE (8-bit gate, 16-bit step)
			step = unpack('>H',trackdata[offset+4:offset+6])[0]*ctempo
			gate = trackdata[offset+3] + dgate
			dgate = 0
			offset += 6
		elif event in range(0x20,0x30):    # NOTE (16-bit gate, 8-bit step)
			step = trackdata[offset+5]*ctempo
			gate = unpack('>H',trackdata[offset+3:offset+5])[0] + dgate
			dgate = 0
			offset += 6
		elif event in range(0x30,0x40):    # NOTE (16-bit gate, 16-bit step)
			step = unpack('>H',trackdata[offset+5:offset+7])[0]*ctempo
			gate = unpack('>H',trackdata[offset+3:offset+5])[0]
			dgate = 0
			offset += 7
		elif event == 0x81:    # REFERENCE
			reference = unpack('>H',trackdata[offset+1:offset+3])[0]
			refcount = trackdata[offset+3]
			# Recursively process any nested layers of reference data
			step = autotime_process(trackdata,tempo,reference,refcount)[0]
			offset += 4
		elif event == 0x82:    # LOOP
			loop_events += 1
			mode = trackdata[offset+1]    # determines whether step is 8-bit or 16-bit
			#print 'DEBUG: loop_events %d' % loop_events
			if loop_events == 1:
				preloopsteps = steps
			if mode < 0x80:
				step = trackdata[offset+2]*ctempo
				offset += 3
			else:
				step = unpack('>H',trackdata[offset+2:offset+4])[0]*ctempo
				offset += 4
			if loop_events > 1:
				step = 0    # step seems to be ignored at loop endpoint
		elif event == 0x83:    # END_OF_TRACK
			break    # end of track reached, stop processing
		elif event == 0x84:    # TEMPO_CHANGE
			tempo = unpack('>H',trackdata[offset+1:offset+3])[0]
			#print 'DEBUG: tempo %d' % tempo
			step = 0
			offset += 4
		elif event in range(0x88,0x8C):    # EXT_GATE - I don't think this applies to Dreamcast sequences, but whatever...
			step = 0
			dgate += extgate[event & 0x3]*ctempo
			offset += 1
			count += 1    # EXT_GATE *NOT* included in REFERENCE count!
		elif event in range(0x8C,0x90):    # EXT_DELTA - I don't think this applies to Dreamcast sequences, but whatever...
			step = extdelta[event&0x3]*ctempo
			offset += 1
			count += 1    # EXT_DELTA *NOT* included in REFERENCE count!
		elif event in range(0xA0,0xB0):    # POLY_KEY_PRESSURE
			step = trackdata[offset+3]*ctempo    # this command probably handles 8-bit or 16-bit step sizes too, not sure exactly how yet
			print 'DEBUG: POLY_KEY_PRESSURE at offset 0x%05X' % offset
			offset += 4
		elif event in range(0xB0,0xC0):    # CONTROL_CHANGE
			mode = trackdata[offset+1] >> 7    # determines whether step is 8-bit or 16-bit
			if mode:
				step = unpack('>H',trackdata[offset+3:offset+5])[0]*ctempo
				offset += 5
			else:
				step = trackdata[offset+3]*ctempo
				offset += 4
		elif event in range(0xC0,0xF0):    # PROGRAM_CHANGE, PRESSURE_CHANGE, PITCH_BEND
			mode = trackdata[offset+1] >> 7    # determines whether step is 8-bit or 16-bit
			if mode:
				step = unpack('>H',trackdata[offset+2:offset+4])[0]*ctempo
				offset += 4
			else:
				step = trackdata[offset+2]*ctempo
				offset += 3
		else:    # OTHER_EVENT
			print 'DEBUG: OTHER_EVENT at offset %05X' % offset
			step = 0
			offset += 1
		count -= 1
		steps += step
	# If the track doesn't loop, add the final gate time
	if count > 0 and loop_events == 0:
		steps += gate + dgate
	return (steps, preloopsteps)   # time and pre-loop time in units of step-microseconds/quarter-note
#=====================================================================================================

#=====================================================================================================
# get track duration in seconds, accounting for looping and fade
def autotime(trackdata, nloops, fade):
	# Get processed times in the track data
	tempo = unpack('<I', trackdata[0x8:0xC])[0]    # Initial tempo value
	(steps, preloopsteps) = autotime_process(trackdata,tempo)    # step-microseconds/quarter-note
	# Assume constant resolution (valid assumption?)
	resolution = 480    # steps/quarter-note
	#print 'DEBUG: resolution %d steps/quarter-note' % resolution
	#print 'DEBUG: steps %d step-microseconds/quarter-note ' % steps
	#print 'DEBUG: preloopsteps %d step-microseconds/quarter-note' % preloopsteps
	# Compute elapsed seconds
	time = float(steps)/float(resolution)/1.0e6    # seconds
	ptime = float(preloopsteps)/float(resolution)/1.0e6    # seconds
	if preloopsteps >= 0:    # time looped tracks, subtracting off multiply-counted pre-loop time
		time = nloops*time - (nloops - 1)*ptime
	else:    # for non-looping tracks, add 1 second
		time += 1.0
		fade = 0
	return (time, fade)
#=====================================================================================================

#=====================================================================================================
def dsftime(nsdf):
	dsfbin = dsfload(ndsf)
	if len(dsfbin) == 0x800000:
		trackdata = gettrack(dsfbin)
		#open('trackdata.bin','wb').write(trackdata)   # DEBUG
		#open('trackdata.txt','w').write(dsftrackprint.printtrackdata(trackdata))    # DEBUG
		(time, fadetime) = autotime(trackdata, nloops, fade)
		minutes = time // 60
		seconds = time - 60.0*minutes
		if seconds > 59.945:
			seconds = 0.0
			minutes += 1
		print 'psfpoint "-length=%d:%.1f" "-fade=%d" "%s"' % (minutes,seconds,fadetime,ndsf)
		os.system('psfpoint "-length=%d:%.1f" "-fade=%d" "%s" > %s' % (minutes,seconds,fadetime,ndsf,os.devnull))
	return
#=====================================================================================================

#=====================================================================================================
# ssf/minissf autotimer main function
if __name__ == '__main__':
	argv = sys.argv
	argc = len(argv)
	nloops = 10
	fade = 0
	flags = {'L':0, 'f':0}
	if argc < 2:
		print 'DSF/miniDSF autotimer v0.01 by kingshriek'
		print 'Requires: psfpoint  (http://www.neillcorlett.com/psf/utilities.html)'
		print 'Caution: this will overwrite the length and fade tags of the specified files'
		print 'Usage: python %s [option(s)] <dsf/minidsf file(s)>' % os.path.basename(argv[0])
		print 'Options:'
		print '          -f <n>    fade time in seconds (default 10)'
		print '          -L <n>    number of loops (default 2)'
		print '          --    turn all previous flags off (resetting them to defaults)'
		print ''
		print '          Note: loop and fade settings only apply to tracks that actually loop.'
		sys.exit(0)
	for arg in argv[1:]:
		for flag in flags:
			if flags[flag] != 0:
				if flag == 'L':
					nloops = int(arg)
				elif flag == 'f':
					fade = int(arg)
			flags[flag] = 0
		if arg[0] == '-':
			for flag in arg[1:]:
				if flag in flags:
					flags[flag] = 1
				elif flag == '-':
					for flag in flags:
						flags[flag] = 0
					nloops = 2
					fade = 10
				else:
					print 'Error: Invalid option -%s' % flag
					sys.exit()
		else:
			dsfs = glob(arg)
			for ndsf in dsfs:
				dsftime(ndsf)
#=====================================================================================================

#=====================================================================================================
# 08-10-13 (v0.01) - Fixed a really stupid bug in the step calculation for PROGRAM_CHANGE, 
#                    PRESSURE_CHANGE, PITCH_BEND.
# 08-10-12 (v0.00) - Initial release.
#=====================================================================================================
