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)