Skip to content

Instantly share code, notes, and snippets.

@plugnburn
Last active November 26, 2024 05:15
Show Gist options
  • Save plugnburn/52e8bc6e0367f6cc2dd9155a84ac7ced to your computer and use it in GitHub Desktop.
Save plugnburn/52e8bc6e0367f6cc2dd9155a84ac7ced to your computer and use it in GitHub Desktop.
JJY.js: Web Audio API based JJY transmitter

JJY.js: JJY time signal emulation/transmission library

Usage

  1. Make sure that the watch/clock is configured to receive JJY 40 KHz signal (for most Casio Waveceptor/G-Shock watches, the easiest way is to enter the engineer menu by pressing Mode+Light+Receive and select J 40 reception mode, for all other watches you need to set the home city to Tokyo)
  2. Make sure your device clock is in sync before running the emulator and the headphone volume is turned to the maximum.
  3. From the page, run:
var jjy = JJY()
jjy.run()

The library automatically transcodes your local time into Japanese timezone regardless which timezone you're in.

To transmit a single timecode (Date object must be constructed in UTC) steadily: jjy.run(DateObject)

To change the waveform distortion parameter (for debug/wave experiment purposes), pass a numeric float value above 2 to JJY constructor (default is equal to 1/3 of current AudioContext sampling rate).

  1. Start the reception and place the watch/clock close to device speaker or headphones.

Demo

A functional demo (with explanations of internal workings) of JJY.js is available at Fukushima web page. Press any key or click anywhere to start the transmission.

Upd. For the BLE-enabled Casio watch synchronization, see my RCVD project.

Upd 2. This project has finally been ported to Python 3.

/*
JJY.js: JJY time signal emulation/transmission library
Copyright (c) @plugnburn (831337.xyz) 2017
@license ISC
*/
function JJY(distorter) { //JJY constructor
distorter = parseFloat(distorter)
//create pre-filled constant array for efficient copying
var codeProto = '200000000200000000020000000002000000000200000000020000000002'.split('').map(Number)
function toBCD(val) {
return val%10 + (((val/10)%10)<<4) + ((val/100)<<8)
}
function calcParity(val){
var i = 0;
while(val) {
i ^= val & 1
val >>>= 1
}
return i
}
function jjyTimecode(timeObj) { //generate a JJY timecode from any given Date object
var ts = {
min: toBCD(timeObj.getUTCMinutes()),
hour: toBCD(timeObj.getUTCHours()),
day: toBCD(Math.floor((timeObj - new Date(timeObj.getUTCFullYear(),0,0,0,0))/864e5)),
year: toBCD(timeObj.getUTCFullYear() % 100),
weekDay: timeObj.getUTCDay()
}, timeCode = codeProto.slice();
//populate minute
timeCode[1] = (ts.min>>6)&1
timeCode[2] = (ts.min>>5)&1
timeCode[3] = (ts.min>>4)&1
timeCode[5] = (ts.min>>3)&1
timeCode[6] = (ts.min>>2)&1
timeCode[7] = (ts.min>>1)&1
timeCode[8] = ts.min&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.day>>9)&1
timeCode[23] = (ts.day>>8)&1
timeCode[25] = (ts.day>>7)&1
timeCode[26] = (ts.day>>6)&1
timeCode[27] = (ts.day>>5)&1
timeCode[28] = (ts.day>>4)&1
timeCode[30] = (ts.day>>3)&1
timeCode[31] = (ts.day>>2)&1
timeCode[32] = (ts.day>>1)&1
timeCode[33] = ts.day&1
//populate parity bits
timeCode[36] = calcParity(ts.hour)
timeCode[37] = calcParity(ts.min)
//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.weekDay>>2
timeCode[51] = (ts.weekDay>>1)&1
timeCode[52] = ts.weekDay&1
return timeCode
}
function getJJYTimeCode(timeObj) {
var timeRep = timeObj || new Date(Date.now() + 324e5);
return {
bitCode: jjyTimecode(timeRep),
cs: timeRep.getUTCSeconds()
}
}
return {
getTimeCode: getJJYTimeCode, //exposed for getting timecode information in non-browser environments
run: function(tObj) { //browser-only function, requires Web Audio API support
var ctx = new (window.AudioContext || window.webkitAudioContext)(),
sr = ctx.sampleRate,
opFreq = 40000/3,
rp = opFreq/sr, bufSet = [], pwm = [.8, .5, .2];
if(isNaN(distorter) || distorter < 2)
distorter = sr/3
ctx.createBuffer = ctx.createBuffer || ctx.webkitCreateBuffer;
ctx.createBufferSource = ctx.createBufferSource || ctx.webkitCreateBufferSource;
performance.now = performance.now || performance.webkitNow || function(){return (new Date).getTime()}
secondTick = (function(tm){
var px = 0, py = 0, dx = 0;
return function(cb){
px = performance.now()
setTimeout(function(){
dx = (py = performance.now()) - px - tm
px = py
cb()
}, tm - dx/2)
}
})(1000)
// pre-populate the buffers
for(var i=0;i<3;i++) {
var sLen = sr*pwm[i];
bufSet[i] = ctx.createBuffer(1, sLen, sr);
var cData = bufSet[i].getChannelData(0);
for(var k=0;k<sLen;k++)
cData[k] = Math.floor(distorter*Math.sin(2 * Math.PI * k * rp))/distorter
}
// play back the timecode
var renderMinute = function() {
var sigInfo = getJJYTimeCode(tObj), currentIndex = sigInfo.cs,
renderSecond = function() {
secondTick(currentIndex<59 ? renderSecond : renderMinute)
var bs = ctx.createBufferSource()
bs.buffer = bufSet[sigInfo.bitCode[currentIndex]]
bs.connect(ctx.destination)
bs.start()
currentIndex++
}
renderSecond()
}
renderMinute()
}
}
}
if(typeof module !== 'undefined' && module.exports && this.module !== module)
module.exports = JJY()
@Mnkai
Copy link

Mnkai commented Jul 22, 2018

On the line 31, you may have intended
day: toBCD(Math.floor((timeObj - new Date(timeObj.getUTCFullYear(),0,1))/864e5)+1),
instead of current
day: toBCD(Math.floor((timeObj - new Date(timeObj.getUTCFullYear(),0,0,0,0))/864e5)),

JS Date object's month index starts from 0, and day index starts from 1. (I know, it's strange)

@plugnburn
Copy link
Author

On the line 31, you may have intended
day: toBCD(Math.floor((timeObj - new Date(timeObj.getUTCFullYear(),0,1))/864e5)+1),
instead of current
day: toBCD(Math.floor((timeObj - new Date(timeObj.getUTCFullYear(),0,0,0,0))/864e5)),

JS Date object's month index starts from 0, and day index starts from 1. (I know, it's strange)

Nope, that was intended, because of the way JJY timecode requires day representation.

@PBCNX
Copy link

PBCNX commented Nov 7, 2020

the demo site is dead. trying to place the JS at a local webhost doesn't load.

@plugnburn
Copy link
Author

the demo site is dead. trying to place the JS at a local webhost doesn't load.

Works for me but I'm going to rehost it somewhere else soon.

@plugnburn
Copy link
Author

the demo site is dead. trying to place the JS at a local webhost doesn't load.

The demo site has been rehosted at https://jjy.luxferre.top

@majurgens
Copy link

majurgens commented Apr 11, 2021

I don't know if you can do a pull request to a gist but I have changed the code so that it adapts to the local time zone instead of being hardcoded to Japan (UTC+9). I have modified a single function "getJJYTimeCode" as follows:

 function getJJYTimeCode(timeObj) {

    // calculate the difference in milli seconds between now, in this time zone, vs in UTC
    const now = new Date();
    const millisecondsSinceEpoch = Math.round(Date.now());
    const utcMilliSecondsSinceEpoch = now.getTime() + (now.getTimezoneOffset() * 60 * 1000);
    zoneDiff=millisecondsSinceEpoch-utcMilliSecondsSinceEpoch;

    // now add the difference to the base date
    var timeRep = timeObj || new Date(Date.now() + zoneDiff);
    return {
      bitCode: jjyTimecode(timeRep),
      cs: timeRep.getUTCSeconds()
    }
  }

@majurgens
Copy link

I have also created a sample html file to run the code:

<!DOCTYPE html>
<html lang=en>
<head>
   <meta charset=utf-8>
   <title>Fukushima: online JJY time signal emulator</title>
   <meta name=viewport content="width=device-width,initial-scale=1,shrink-to-fit=no">
</head>
<body>
   <p>
   </p>
   <script src="jjy.js"></script>
   <script type="text/javascript" >
   var jjy = JJY()
   jjy.run()
   </script>
</body>
</html>

@Amplified0709823849
Copy link

I'm using a local copy of this script to broadcast a JJY signal from my desktop computer to an AirPort Express that sits on my WiFi network with a loop antenna connected to it. That works, and I can leave a watch in the loop antenna overnight and it will sync automatically. The one issue I'm having is that because of the transmission delay between the JJY emulation being generated on my desktop and the antenna receiving it, my watch syncs to a time 0.5 to 1.0 seconds behind the actual time. Can you please tell me what I would have to change in this script to add half a second or a whole second to the time it broadcasts? I don't want to have to learn enough JavaScript just to be able to make this one tiny change to one script. Thanks.

@plugnburn
Copy link
Author

my watch syncs to a time 0.5 to 1.0 seconds behind the actual time. Can you please tell me what I would have to change in this script to add half a second or a whole second to the time it broadcasts? I don't want to have to learn enough JavaScript

But you should. Add the +500 or +1000 (in milliseconds, whatever you need) in the new Date(Date.now() + zoneDiff) constructor call, as in new Date(Date.now() + zoneDiff + 500).
Happy new year!

@Artoria2e5
Copy link

I came from the fukushima website. On the website it says

feel free to contact the author on GitHub.

I believe it would be more appropriate to replace the link with one to this gist, https://gist.github.com/plugnburn/52e8bc6e0367f6cc2dd9155a84ac7ced.


PS. Honestly I still don't get how the harmonics are maximized.

  • The Japanese website linked from jjy.luxferre.top says some overdrive distortion is required to make the sine look a little more square, but since we have no instruction to maximize the volume I can't be sure that this sort of distortion happens. It also doesn't explain why square and triangle, which should contain more of that 3rd fundamental, doesn't work.
  • I don't quite get why the distorter should default to a value that's a multiple of the sample rate either. A higher sample rate should make the system better at reproducing a sine, which... I think should mean a stronger (lower number) distorter should be used??

@plugnburn
Copy link
Author

plugnburn commented Apr 29, 2024

I came from the fukushima website. On the website it says

feel free to contact the author on GitHub.

I believe it would be more appropriate to replace the link with one to this gist, https://gist.github.com/plugnburn/52e8bc6e0367f6cc2dd9155a84ac7ced.

Hello, yes, that page is quite old, will be updated soon. Also, a Python port (with pyaudio or something) is being planned to use this algorithm on headless systems.

PS. Honestly I still don't get how the harmonics are maximized.

* The Japanese website linked from jjy.luxferre.top says some overdrive distortion is required to make the sine look a little more square, but since we have no instruction to maximize the volume I can't be sure that this sort of distortion happens. It also doesn't explain why square and triangle, which should contain more of that 3rd fundamental, doesn't work.

* I don't quite get why the distorter should default to a value that's a multiple of the sample rate either. A higher sample rate should make the system better at reproducing a sine, which... I think should mean a stronger (lower number) distorter should be used??

I'm not sure either, this came out as a result of experiments with some Casio watches: pure sine and pure square don't work as expected, the best performance is achieved somewhere in between. Feel free to experiment with other waveforms. But yes, it is assumed that the headphone volume is put to the max. I've updated this gist to reflect this requirement.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment