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)