jjy-py

JJY40 emulator in Python
git clone git://git.luxferre.top/jjy-py.git
Log | Files | Refs | README

jjy.py (7097B)


      1 #!/usr/bin/env python3
      2 # JJY.py: a port of jjy.js library and a standalone script
      3 # to synchronize time on JJY-enabled wristwatches via headphones
      4 # Depends on pyaudio and ntplib
      5 # Created by Luxferre in 2024, released into public domain
      6 
      7 import pyaudio, ntplib, math, array, time, datetime
      8 
      9 OP_FREQ = 40000/3 # emitted frequency, Hz
     10 
     11 # BCD and parity calculation helpers
     12 
     13 def toBCD(val: int):
     14   return (val%10) + ((int(val/10)%10)<<4) + (int(val/100)<<8)
     15 
     16 def calc_parity(val: int):
     17   i = 0
     18   while val != 0:
     19     i ^= (val & 1)
     20     val >>= 1
     21   return i
     22 
     23 # internal time representation from unix time
     24 # only fetches the fields necessary for JJY implementation
     25 def intreptime(unixtm):
     26   res = {}
     27   tm = time.gmtime(unixtm)
     28   res['year'] = toBCD(tm.tm_year % 100)
     29   res['yday'] = toBCD(tm.tm_yday)
     30   res['hour'] = toBCD(tm.tm_hour)
     31   res['minute'] = toBCD(tm.tm_min)
     32   res['second'] = tm.tm_sec
     33   # in Python, Monday is 0; in JJY, Sunday is 0
     34   res['wday'] = (tm.tm_wday + 1) % 7
     35   res['unix'] = int(unixtm) # save the unix time representation
     36   return res
     37 
     38 # time fetching part (returns JST)
     39 def fetchtime(params = {}):
     40   delta = 0.0
     41   offset = 32400
     42   if 'delta' in params:
     43     delta = float(params['delta']) / 1000
     44   if 'offset' in params: # base offset from UTC in seconds
     45     offset = int(params['offset'])
     46   if 'server' in params and params['server'] is not None: # NTP server set
     47     ntpver = 3
     48     if 'version' in params:
     49       ntpver = params['version']
     50     c = ntplib.NTPClient()
     51     resp = c.request(params['server'], version=ntpver)
     52     unixtm = resp.tx_time
     53   else: # use current system time by default
     54     unixtm = time.time()
     55   unixtm += offset + delta # account for delta
     56   return intreptime(unixtm)
     57 
     58 # timecode generation part
     59 # accepts the result of fetchtime function
     60 def gentimecode(ts):
     61   timecode = [2,0,0,0,0,0,0,0,0,2,0,0,0,0,0,0,0,0,0,2,0,0,0,0,0,0,0,
     62     0,0,2,0,0,0,0,0,0,0,0,0,2,0,0,0,0,0,0,0,0,0,2,0,0,0,0,0,0,0,0,0,2]
     63   # populate minute
     64   timecode[1] = (ts['minute']>>6)&1
     65   timecode[2] = (ts['minute']>>5)&1
     66   timecode[3] = (ts['minute']>>4)&1
     67   timecode[5] = (ts['minute']>>3)&1
     68   timecode[6] = (ts['minute']>>2)&1
     69   timecode[7] = (ts['minute']>>1)&1
     70   timecode[8] = ts['minute']&1
     71   # populate hour
     72   timecode[12] = (ts['hour']>>5)&1
     73   timecode[13] = (ts['hour']>>4)&1
     74   timecode[15] = (ts['hour']>>3)&1
     75   timecode[16] = (ts['hour']>>2)&1
     76   timecode[17] = (ts['hour']>>1)&1
     77   timecode[18] = ts['hour']&1
     78   # populate day number
     79   timecode[22] = (ts['yday']>>9)&1
     80   timecode[23] = (ts['yday']>>8)&1
     81   timecode[25] = (ts['yday']>>7)&1
     82   timecode[26] = (ts['yday']>>6)&1
     83   timecode[27] = (ts['yday']>>5)&1
     84   timecode[28] = (ts['yday']>>4)&1
     85   timecode[30] = (ts['yday']>>3)&1
     86   timecode[31] = (ts['yday']>>2)&1
     87   timecode[32] = (ts['yday']>>1)&1
     88   timecode[33] = ts['yday']&1
     89   # populate parity bits
     90   timecode[36] = calc_parity(ts['hour'])
     91   timecode[37] = calc_parity(ts['minute'])
     92   # populate year
     93   timecode[41] = (ts['year']>>7)&1
     94   timecode[42] = (ts['year']>>6)&1
     95   timecode[43] = (ts['year']>>5)&1
     96   timecode[44] = (ts['year']>>4)&1
     97   timecode[45] = (ts['year']>>3)&1
     98   timecode[46] = (ts['year']>>2)&1
     99   timecode[47] = (ts['year']>>1)&1
    100   timecode[48] = ts['year']&1
    101   # populate day of the week
    102   timecode[50] = (ts['wday']>>2)&1
    103   timecode[51] = (ts['wday']>>1)&1
    104   timecode[52] = ts['wday']&1
    105   return timecode
    106 
    107 # generate an audio data chunk of specified duration
    108 def gen_audio(duration, freq=OP_FREQ, sr=48000):
    109   smps = int(sr * duration)
    110   # create the sine wave array for the whole second
    111   rawdata = []
    112   for k in range(0, sr):
    113     v = math.sin(2 * math.pi * k * freq / sr)
    114     if k > smps: # reduced power mode
    115       v *= 0.1
    116     rawdata.append(int(v * 32767)) # max gain
    117   return array.array('h', rawdata).tobytes()
    118 
    119 # global buffers for audio data and current position
    120 curstream = b''
    121 streampos = 0
    122 
    123 # bitcode transmission callback
    124 def bitcode_transmit(in_data, frame_count, time_info, status):
    125   global curstream, streampos
    126   framelen = frame_count << 1 # 2 bytes per frame as we're using int16
    127   framedata = curstream[streampos:streampos+framelen]
    128   streampos += framelen
    129   return (framedata, pyaudio.paContinue)
    130 
    131 # main logic is here
    132 def start_transmission(timeparams):
    133   global curstream
    134   p = pyaudio.PyAudio()
    135   sr = timeparams['sr']
    136   mins = timeparams['duration']
    137   jjy_bit_chunks = [ # pregenerate the chunks
    138     gen_audio(0.8, OP_FREQ, sr), # data bit 0
    139     gen_audio(0.5, OP_FREQ, sr), # data bit 1
    140     gen_audio(0.2, OP_FREQ, sr)  # marker bit
    141   ]
    142   ts = fetchtime(timeparams) # get the current timestamp
    143   print('Time fetched (Unix):', ts['unix'])
    144   bitcode = gentimecode(ts)[ts['second']+1:] # slice the rest of current minute
    145   nextmin = ts['unix'] - ts['second'] # rewind to start of the minute
    146   for i in range(0, mins): # generate bitcode for the next N minutes
    147     nextmin += 60 # calc the next minute
    148     bitcode += gentimecode(intreptime(nextmin))
    149   print("Transmitting... Press Ctrl+C to exit")
    150   # wait for the next second to start (roughly, with all the call overhead)
    151   time.sleep((1 - datetime.datetime.now().microsecond/1000000)/2)
    152   # open a PyAudio stream with callback
    153   stream = p.open(format=pyaudio.paInt16, channels=1, frames_per_buffer=16384,
    154             rate=sr, output=True, stream_callback=bitcode_transmit)
    155   curstream = jjy_bit_chunks[bitcode.pop(0)] # preload the first second
    156   while stream.is_active(): # wait for the stream to finish
    157     if len(bitcode) > 0: # feed the stream in parallel
    158       curstream += jjy_bit_chunks[bitcode.pop(0)]
    159     time.sleep(0.75) # feeding the stream should be faster than realtime
    160   # close audio
    161   stream.stop_stream()
    162   stream.close()
    163   p.terminate()
    164   print("Transmission ended")
    165 
    166 if __name__ == '__main__':
    167   from argparse import ArgumentParser
    168   parser = ArgumentParser(description='JJY.py: an opensource longwave time synchronizer for JJY40-enabled watches and clocks', epilog='(c) Luxferre 2024 --- No rights reserved <https://unlicense.org>')
    169   parser.add_argument('-t', '--duration', type=int, default=30, help='Transmission duration (in minutes, default 30)')
    170   parser.add_argument('-d', '--delta', type=int, default=0, help='Manual delta correction (in ms, must be determined individually, 0 by default)')
    171   parser.add_argument('-o', '--tz-offset', type=float, default=9, help='Timezone offset from UTC to transmit (in hours, default 9 - corresponds to JST)')
    172   parser.add_argument('-r', '--sample-rate', type=int, default=48000, help='Transmission sampling rate (in Hz, default 48000)')
    173   parser.add_argument('-s', '--ntp-server', type=str, default=None, help='NTP server to sync from (if not specified then will sync from the local system time)')
    174   parser.add_argument('-n', '--ntp-version', type=int, default=4, help='NTP protocol version to use (default 4)')
    175   args = parser.parse_args()
    176   params = { # populate parameters from the command line
    177     'server': args.ntp_server, 'version': args.ntp_version,
    178     'delta': args.delta, 'offset': int(args.tz_offset * 3600),
    179     'sr': args.sample_rate, 'duration': args.duration
    180   }
    181   start_transmission(params)