jjy-py

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

commit ffe1dbec9ec3a325faacb46291b9e36674b9d434
Author: Luxferre <lux@ferre>
Date:   Tue, 14 May 2024 21:29:40 +0300

first upload

Diffstat:
AREADME | 103+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ajjy.py | 181+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Arequirements.txt | 2++
3 files changed, 286 insertions(+), 0 deletions(-)

diff --git a/README b/README @@ -0,0 +1,103 @@ +JJY.py: sync JJY40-enabled watches and clocks from Python 3 +----------------------------------------------------------- +This is a Python port of the 7-year old "Project Fukushima" JJY40 emulator +for syncing longwave-enabled watches and clocks that support this station. +With the help of any sort of loop antenna (or even headphones or speakers), it +allows you to adjust your watch without having to be close to the Japanese +signal. This program follows the JJY timecode specification and modulation +methods used by the oldest desktop applications for this very purpose, and +transmits on 13333.(3) Hz, whose 3rd harmonic is the reference frequency, +40 KHz, but the base frequency here is within the spectrum supported by any +consumer-grade audio hardware. + +== Dependencies == + +JJY.py depends on PyAudio (>=0.2.14) and ntplib (>=0.4.0). Just install them +by running pip install -r requirements.txt from the project directory. Note +that installing PyAudio will also pull its PortAudio (>=v19) dependency. + +The program has been primarily tested on Python 3.10. + +== Usage == + +JJY.py can be run like this: + +python jjy.py [-t duration] [-d delta] [-s ntp_server] [-n ntp_version] \ + [-o tz_offset] [-r sample_rate] + +All parameters are optional here: + +* -h: display help screen +* -t: transmission duration in minutes (default 30) +* -d: manual delta correction in milliseconds (default 0, see below) +* -o: timezone offset from UTC (in hours, default 9, see below) +* -r: transmission sampling rate (in Hz, default 48000, only change if this + fails to work) +* -s: specify NTP server to fetch time from (if no server is specified, then + local system time is used) +* -n: specify NTP protocol version to use, default is 4, good for most cases + +After running the command, you must enter the synchronization mode on your +watch/clock (making sure that JJY40 is selected if it's multiband) and put it +close enough to your (improvised) loop antenna, headphones or speakers. The +script will fetch the UTC time according to your source, apply the TZ offset, +then the manual delta offset and then will attempt to start the transmission +from the closest second. The TZ offset is set to +9 hours by default because +most JJY-enabled watches/clocks expect the JST time to be sent in order to +then apply their own timezone correction according to your settings. If your +watch/clock doesn't have such correction, you can always use this -o flag to +zero out this offset (with -o 0) and transmit the local time directly onto it. +In case your equipment, software or time source server introduce any delay to +the synchronization process, you can add a constant delta (in milliseconds) +with the -d flag. After the synchronization is successful, you can press the +Ctrl+C combination or wait until the entire sequence (which is 30 minutes long +by default, adjustable with -t flag) gets transmitted. + +== FAQ == + +- How is this even possible? + +To put it simply, to emit any audio signal, electricity has to travel through +many wires and coils. This inevitably creates electromagnetic interference. If +we send the signal of a particular constant frequency with enough intensity +through audio circuits, this interference will turn into radio emission in the +longwave spectrum, which is exactly what we need for syncing radio-controlled +clocks and watches. This emission is too weak to cause any harm outside but +enough to be received by the watch or clock several centimeters apart. + +- Which watches/clocks has this been tested on? + +Some Casio and Citizen models, including Casio GW-B5600BC, GMW-B5000D and +Citizen PMD56-2951. + +- Is my particular watch/clock model supported? + +As long as it can receive JJY40 signal and you know how to make it do this, it +is automatically supported by JJY.py. At this point, I can surely say that if +anything goes wrong, it's not the fault of your watch or your emulator, but +something in between: audio setup, antenna setup or the placement of the watch +relative to the antenna. It might take some trial and error and a great deal +of patience to make sure everything works as expected. + +For most digital Casio models, you can force JJY40 reception by entering one +of the test menus: press and hold first Light, then Receive and then Mode +button. Scroll through with the Receive button to ensure that "J 40" is on the +screen, then start the reception process with the Light button. You should get +a "JOK" message if the process is successful, or "JNG" if unsuccessful. + +- Aren't there enough JJY emulation apps already? + +In 2017, there were almost no cross-platform solutions for this, and this was +the primary reason the Fukushima project started. However, even in 2024, I +could not find any Python solution for this meant for normal desktop and not +for Raspberry Pi, Arduino and other embedded platforms. That's why a port of +the JJY.js library to Python was deemed necessary. + +- Are there still any plans for implementing other longwave time protocols? + +Maybe. DCF77 and WWVB are of the primary interest. + +== Credits == + +Original research, JS library and web demo application by Luxferre, 2017. +Ported to Python in 2024. Released into public domain with no warranties. diff --git a/jjy.py b/jjy.py @@ -0,0 +1,181 @@ +#!/usr/bin/env python3 +# JJY.py: a port of jjy.js library and a standalone script +# to synchronize time on JJY-enabled wristwatches via headphones +# Depends on pyaudio and ntplib +# Created by Luxferre in 2024, released into public domain + +import pyaudio, ntplib, math, array, time, datetime + +OP_FREQ = 40000/3 # emitted frequency, Hz + +# BCD and parity calculation helpers + +def toBCD(val: int): + return (val%10) + ((int(val/10)%10)<<4) + (int(val/100)<<8) + +def calc_parity(val: int): + i = 0 + while val != 0: + i ^= (val & 1) + val >>= 1 + return i + +# internal time representation from unix time +# only fetches the fields necessary for JJY implementation +def intreptime(unixtm): + res = {} + tm = time.gmtime(unixtm) + res['year'] = toBCD(tm.tm_year % 100) + res['yday'] = toBCD(tm.tm_yday) + res['hour'] = toBCD(tm.tm_hour) + res['minute'] = toBCD(tm.tm_min) + res['second'] = tm.tm_sec + # in Python, Monday is 0; in JJY, Sunday is 0 + res['wday'] = (tm.tm_wday + 1) % 7 + res['unix'] = int(unixtm) # save the unix time representation + return res + +# time fetching part (returns JST) +def fetchtime(params = {}): + delta = 0.0 + offset = 32400 + if 'delta' in params: + delta = float(params['delta']) / 1000 + if 'offset' in params: # base offset from UTC in seconds + offset = int(params['offset']) + if 'server' in params and params['server'] is not None: # NTP server set + ntpver = 3 + if 'version' in params: + ntpver = params['version'] + c = ntplib.NTPClient() + resp = c.request(params['server'], version=ntpver) + unixtm = resp.tx_time + else: # use current system time by default + unixtm = time.time() + unixtm += offset + delta # account for delta + return intreptime(unixtm) + +# timecode generation part +# accepts the result of fetchtime function +def gentimecode(ts): + 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, + 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] + # populate minute + timecode[1] = (ts['minute']>>6)&1 + timecode[2] = (ts['minute']>>5)&1 + timecode[3] = (ts['minute']>>4)&1 + timecode[5] = (ts['minute']>>3)&1 + timecode[6] = (ts['minute']>>2)&1 + timecode[7] = (ts['minute']>>1)&1 + timecode[8] = ts['minute']&1 + # populate hour + timecode[12] = (ts['hour']>>5)&1 + timecode[13] = (ts['hour']>>4)&1 + timecode[15] = (ts['hour']>>3)&1 + timecode[16] = (ts['hour']>>2)&1 + timecode[17] = (ts['hour']>>1)&1 + timecode[18] = ts['hour']&1 + # populate day number + timecode[22] = (ts['yday']>>9)&1 + timecode[23] = (ts['yday']>>8)&1 + timecode[25] = (ts['yday']>>7)&1 + timecode[26] = (ts['yday']>>6)&1 + timecode[27] = (ts['yday']>>5)&1 + timecode[28] = (ts['yday']>>4)&1 + timecode[30] = (ts['yday']>>3)&1 + timecode[31] = (ts['yday']>>2)&1 + timecode[32] = (ts['yday']>>1)&1 + timecode[33] = ts['yday']&1 + # populate parity bits + timecode[36] = calc_parity(ts['hour']) + timecode[37] = calc_parity(ts['minute']) + # populate year + timecode[41] = (ts['year']>>7)&1 + timecode[42] = (ts['year']>>6)&1 + timecode[43] = (ts['year']>>5)&1 + timecode[44] = (ts['year']>>4)&1 + timecode[45] = (ts['year']>>3)&1 + timecode[46] = (ts['year']>>2)&1 + timecode[47] = (ts['year']>>1)&1 + timecode[48] = ts['year']&1 + # populate day of the week + timecode[50] = (ts['wday']>>2)&1 + timecode[51] = (ts['wday']>>1)&1 + timecode[52] = ts['wday']&1 + return timecode + +# generate an audio data chunk of specified duration +def gen_audio(duration, freq=OP_FREQ, sr=48000): + smps = int(sr * duration) + # create the sine wave array for the whole second + rawdata = [] + for k in range(0, sr): + v = math.sin(2 * math.pi * k * freq / sr) + if k > smps: # reduced power mode + v *= 0.1 + rawdata.append(int(v * 32767)) # max gain + return array.array('h', rawdata).tobytes() + +# global buffers for audio data and current position +curstream = b'' +streampos = 0 + +# bitcode transmission callback +def bitcode_transmit(in_data, frame_count, time_info, status): + global curstream, streampos + framelen = frame_count << 1 # 2 bytes per frame as we're using int16 + framedata = curstream[streampos:streampos+framelen] + streampos += framelen + return (framedata, pyaudio.paContinue) + +# main logic is here +def start_transmission(timeparams): + global curstream + p = pyaudio.PyAudio() + sr = timeparams['sr'] + mins = timeparams['duration'] + jjy_bit_chunks = [ # pregenerate the chunks + gen_audio(0.8, OP_FREQ, sr), # data bit 0 + gen_audio(0.5, OP_FREQ, sr), # data bit 1 + gen_audio(0.2, OP_FREQ, sr) # marker bit + ] + ts = fetchtime(timeparams) # get the current timestamp + print('Time fetched (Unix):', ts['unix']) + bitcode = gentimecode(ts)[ts['second']+1:] # slice the rest of current minute + nextmin = ts['unix'] - ts['second'] # rewind to start of the minute + for i in range(0, mins): # generate bitcode for the next N minutes + nextmin += 60 # calc the next minute + bitcode += gentimecode(intreptime(nextmin)) + print("Transmitting... Press Ctrl+C to exit") + # wait for the next second to start (roughly, with all the call overhead) + time.sleep((1 - datetime.datetime.now().microsecond/1000000)/2) + # open a PyAudio stream with callback + stream = p.open(format=pyaudio.paInt16, channels=1, frames_per_buffer=16384, + rate=sr, output=True, stream_callback=bitcode_transmit) + curstream = jjy_bit_chunks[bitcode.pop(0)] # preload the first second + while stream.is_active(): # wait for the stream to finish + if len(bitcode) > 0: # feed the stream in parallel + curstream += jjy_bit_chunks[bitcode.pop(0)] + time.sleep(0.75) # feeding the stream should be faster than realtime + # close audio + stream.stop_stream() + stream.close() + p.terminate() + print("Transmission ended") + +if __name__ == '__main__': + from argparse import ArgumentParser + 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>') + parser.add_argument('-t', '--duration', type=int, default=30, help='Transmission duration (in minutes, default 30)') + parser.add_argument('-d', '--delta', type=int, default=0, help='Manual delta correction (in ms, must be determined individually, 0 by default)') + parser.add_argument('-o', '--tz-offset', type=float, default=9, help='Timezone offset from UTC to transmit (in hours, default 9 - corresponds to JST)') + parser.add_argument('-r', '--sample-rate', type=int, default=48000, help='Transmission sampling rate (in Hz, default 48000)') + 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)') + parser.add_argument('-n', '--ntp-version', type=int, default=4, help='NTP protocol version to use (default 4)') + args = parser.parse_args() + params = { # populate parameters from the command line + 'server': args.ntp_server, 'version': args.ntp_version, + 'delta': args.delta, 'offset': int(args.tz_offset * 3600), + 'sr': args.sample_rate, 'duration': args.duration + } + start_transmission(params) diff --git a/requirements.txt b/requirements.txt @@ -0,0 +1,2 @@ +pyaudio>=0.2.14 +ntplib>=0.4.0