beepy

BPC watch/clock synchronization utility in Python
git clone git://git.luxferre.top/beepy.git
Log | Files | Refs | README

bee.py (7595B)


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