diff --git a/CNAME b/CNAME new file mode 100644 index 00000000..dcb5d90f --- /dev/null +++ b/CNAME @@ -0,0 +1 @@ +graphtool-demo.harutohiroki.com \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..a2b6f9f1 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,6 @@ +# For Contributors +- The code is a mess, I know. I'm working on it. I'm sorry. +- If you want to contribute and help translate the tool, all the source English text is in the `en.json` file. Just copy the file and rename it to your language code (make sure to use ISO 639-1 Language Codes for auto-detection e.g. `fr.json` for French) and translate the text. I'll take care of the rest. +- The translation feature will always prioritize what's in the language files. If a text is missing in the translation file, it will default to the text in the `config.json` file. +- Please don't make any text/value `null`. If you want to not have a text/value, just leave it empty (e.g. `""` or `[]`). +- For targets, each target `"type"` is a key in the language files. The value of the key is the name of the category of targets. diff --git a/Configuring.md b/Configuring.md new file mode 100644 index 00000000..0df7b2ac --- /dev/null +++ b/Configuring.md @@ -0,0 +1,184 @@ +# CrinGraph on your own site + +This guide explains the steps required to make CrinGraph display your +own FR graphs, for example if by some remarkable circumstance you have +managed to measure IEMs not yet on crinacle.com. If you have any trouble +with setting CrinGraph, or any questions about the tool, just ask me! +Use Github or the email in my profile. + +There are two other steps to show people FR graphs that I may not be +able to help with: + +* Creating frequency response graphs in the first place. I have never + done this and don't know all the details. You might try reading + [this thread](https://www.head-fi.org/threads/general-iem-measurements-discussions.903455/) + for some information to get you started, and Crin is generally happy + to answer questions about measurement on [his Discord server](https://discord.gg/CtTqcCb). +* Hosting the pages. Believe it or not, I'm not a web developer and I + don't know that much about setting up websites. One method that may + work for your purposes is to use Github Pages, which allows you to + serve the contents of any Github repository as a website. The main + CrinGraph repository includes sample data so it can function on its + own. Once I had the data I just set the source to "master branch" in + the settings under "Github Pages", and Github put the page at + https://mlochbaum.github.io/CrinGraph/graph.html. You can show your + graphs in the same way by forking my repository and making the changes + described here, then changing the "Github Pages" setting like I did. + +## Checklist + +These are the things you definitely need to change to make sure your +page works and isn't claiming it's crinacle.com. + +* Set `DIR` in `config.js` and place your graphs and `phone_book.json` + there. +* Remove or change the watermark. +* Remove the `targets`, replace them with your own, or get permission + from Crinacle to use the ones in the CrinGraph repository. + +## Configuring CrinGraph + +The main page used to display graphs is [index.html](index.html), which +defines the basic structure of a page and then includes a bunch of +Javascript files that do the real work (at the end of the file). + +Ideally all configuration can be done simply by changing +[config.js](config.js). However, there are currently not very many +configuration options. Ask if there's something you want to change but +can't! + +Here are the current configuration parameters: + +* `DIR` is the location of your FR graphs and the index for them + (see the next section). If you are displaying a cloned repository + using Github Pages, using a directory other than `data/` will make + merging changes from the main CrinGraph repository easier. +* `tsvParse` is a function which takes the text of an FR file and + converts it to the format used internally by CrinGraph. See the next + section. +* `watermark` is a function that is applied once to the graph window on + startup. The argument `svg` is a [d3](https://d3js.org/) selection + and you can use any d3 functionality to draw things in it. This part + must be changed, or you will end up impersonating crinacle.com! To + use no watermark, just delete the whole function body. You can also + delete, move, or change the image and text separately. +* `max_channel_imbalance` controls how sensitive the channel imbalance + detector (that red exclamation mark that can show up in a headphone's + key) is. You probably don't need to change this. +* `targets` lists the available target frequency responses. If you don't + want to display any targets set it to `false`. If you do use targets, + each one should be a file named `... Target.txt` in the `DIR/targets` + directory you specified. The targets which are already there were + provided by Crinacle so make sure you have his permission before using + them. +* `scale_smoothing` (default 1) adjusts the level of smoothing applied + at a given "Smooth:" setting. The setting will always start at 5, but + its value is multiplied by `scale_smoothing` to get the actual level + of smoothing. + +The following parameters are used to allow multiple samples per channel +and different channel configurations than L/R. For example, +`config_hp.js` is intended for headphones and shows only the right +channel with five samples per channel. + +* `default_channels` is a list of channels in each measurement: it + defaults to `["L","R"]`. It's called "default" because I may add a + mechanism to change it for a single sample from `phone_book.json`, + but no such mechanism exists right now. +* `num_samples`, if set, is the number of samples in each channel. + Samples are always numbered 1 to `num_samples`. + +The following parameters are for setting the initial samples to display, +and enabling URL sharing. If enabled, URL sharing updates the page URL +to reflect which samples are on the graph. Copying and opening that URL +will open the page with those samples shown. For these parameters a +headphone or target is identified by its filename. + +* `init_phones` is a list of filenames to open by default. +* `share_url` enables URL sharing. +* `page_title` sets the page title display if URL sharing is enabled. + +## Storing your FR files + +All FR data is stored in its own file in the directory `DIR` you specify +in `config.js`. To use this data, CrinGraph needs two things: an index +which lists the available models, grouped by brand, and the FR curves +themselves. + +### FR index: `phone_book.json` + +The index is a [JSON](https://en.wikipedia.org/wiki/JSON) file called +[phone_book.json](data/phone_book.json). By default it is located in the +headphone directory `DIR`, but the setting `PHONE_BOOK` allows you to +specify a different filepath. The file's contents are a list of brands, +where each brand is a list of models. A simple example of a brand: + +```json + { + "name": "Elysian", + "suffix": "Acoustic Labs", + "phones": [ "Artemis" + , "Eros" + , "Minerva" + , "Terminator" ] + } +``` + +The only required attributes for a brand are its name ("name") and a +list of headphone models ("phones"). You can also add another part of +the name using "suffix". The suffix is included in the brand name when +it's used alone, but not as part of a model name: here the brand is +"Elysian Acoustic Labs", but the first headphone is the "Elysian Artemis". + +Each item in the "phones" array corresponds to a single headphone model. +While an item might just be the model name as shown above, there are +other possibilities as well. Two examples should cover most use cases: + +* To use a different display name ("name") and filename ("file"): `{"name":"Carbo Tenore ZH-DX200-CT","file":"Tenore"}` +* To use show multiple variants of a single model: `{"name":"Gemini","file":["Gemini","Gemini Bass"]}` + +The full list of options is as follows: + +* "name" is the displayed model name. +* "collab" gives the name of a collaborator. If that collaborator is on + the list of brands, the headphone will be categorized under both the + main vendor and the collaborator. +* "file" gives either a single filename or a list. If a list is given, + then "name" is used only to for the headphone's name in the selection + menu. The key will use filenames for display unless one of the following + options is specified. +* "suffix" is a list with the same length as the list of files. The + display name is the model name plus the variant's suffix. So , {"name":"R3","file":["R3","R3 C"],"suffix":["","Custom"]} ] + `{"name":"R3","file":["R3","R3 C"],"suffix":["","Custom"]}` uses files + based on the names `R3` and `R3 C` but shows the names "R3" and + "R3 Custom". +* "prefix" is some string that should appear at the start of each + filename. The display name is then the filename, except that if the + prefix appears at the start of the filename it is replaced with the + display name. So + `{"name":"IER-Z1R","file":["Z1R S2","Z1R S3","Z1R S4","Z1R S5","Z1R S6","Z1R","Z1R Filterless"],"prefix":"Z1R"}` + displays with the names `IER-Z1R S2`, `IER-Z1R S3`, and so on. + You may not need this one because you probably won't measure as many + IER-Z1Rs as Crinacle. + +### FR curves + +Each frequency response curve (each channel of a headphone, and each +target response) is stored in its own file. For targets this file must +be named using the target name from `config.js` followed by " Target.txt", +for instance "Diffuse Field Target.txt". For headphones the file's name +is obtained from `phone_book.json` as described in the previous section. +When a user selects a headphone, CrinGraph figures out its filename—for +concreteness let's say "New Primacy"—and looks for two files, one for +each channel. Here it would try to read "New Primacy L.txt" and +"New Primacy R.txt". If one channel is not found it will ignore it and +display the headphone with only one channel. If neither one is found it +pops up an alert to say "Headphone not found!" and doesn't display +anything. + +The headphone is converted from a text file to a Javascript array +using the setting `tsvParse`. The default setting assumes that the file +is a tab-separated value file, with two header lines which are discarded +followed by 480 measurements (1/48 octave spacing). Each measurement is +a frequency and an SPL value. Of course, you can use any format, as long +as you can write the code to parse it! diff --git a/Documentation.md b/Documentation.md new file mode 100644 index 00000000..6b9b3c36 --- /dev/null +++ b/Documentation.md @@ -0,0 +1,358 @@ +# Documentation + +This page describes in detail what CrinGraph does, and sometimes how it +is implemented. It is not intended to explain how to use CrinGraph—if +you're just trying to figure out how to do some simple things with the +tool, the [readme](README.md) is more likely to help. + +## Technologies + +CrinGraph is written using the Javascript framework +[d3.js](https://d3js.org/). Otherwise the technology used is the +standard web stack: HTML/SVG, CSS, and Javascript. Files are written +directly with no build step. + +CrinGraph targets browsers that support ES6, because d3 requires it. In +particular, the +[fetch API](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API) +is used to load frequency response data, so CrinGraph will not work in +Internet Explorer. + +### Layout + +Most parts of the interface are arranged using flexboxes, and rearranged +with CSS media queries to detect screen width and aspect ratio. + +There are three main layouts: +* The desktop layout places the graph window at the top with the selector and manager side by side below it. +* The mobile layout (for narrow screens) stacks everything vertically with the selector above the manager. +* When the screen is very wide relative to its height, the selector and manager are stacked as in the mobile layout but placed right of the graph window. + +If the screen is narrow enough, the toolbar below the graph window will +collapse to avoid clutter. The entire toolbar can be shown by clicking +the hamburger icon at the right. + +### Searching + +Headphones are searched using the fuzzy search provided by +[Fuse.js](https://fusejs.io/). This tool splits the search string and +each brand and headphone model name into words, and matches words based +on their longest common substring: it removes as few characters as +possible from both words to make them match, and then uses the number of +characters left in that match for a score. Brands and headphone models +are filtered to show only those with a high enough total matching score. + +### Screenshot + +Okay, it's really an export and not a screenshot. The graph is an +[SVG](https://en.wikipedia.org/wiki/Scalable_Vector_Graphics) image +drawn with d3; CrinGraph uses a lightly modified version of the +Javascript library +[saveSvgAsPng](https://github.com/exupero/saveSvgAsPng) to allow your +browser to convert it to a uniformly sized PNG image for convenient +sharing. + +## Graph display + +The graph window follows established standards for displaying frequency +responses. The main differences from other frequency graphs should be +aesthetic only. (Manufacturers sometimes provide graphs which have huge +loudness ranges on the vertical axis, to make their curves appear +flatter, or which display inaudible frequencies from 20kHz to 40kHz or +even higher. Such practices serve to make graphs less informative and +it's hard to take displays that use them seriously.) + +### Axes + +A frequency response graph uses frequency in +[hertz](https://en.wikipedia.org/wiki/Hertz) (Hz) for the horizontal or +x axis, and [sound pressure level](https://en.wikipedia.org/wiki/Sound_pressure#Sound_pressure_level) +or SPL in [decibels](https://en.wikipedia.org/wiki/Decibel) (dB) for the +vertical or y axis. Both physical quantities are plotted +logarithmically: the x axis uses a logarithmic scale in which each +octave (doubling in frequency) spans the same distance, and the y axis +uses a linear scale but the decibel is a logarithmic unit. A doubling in +sound amplitude corresponds to an increase of 6 dB (well, about 6.02). +In both cases the logarithmic scaling corresponds closely to human +perception. For frequencies the correspondence is essentially exact, +since we perceive the difference between two specific notes (say C and +F) to be the the same regardless of which octave they are in. The +difference is not a constant difference of frequencies but a constant +ratio. Using frequency in a logarithmic sense, or pitch, converts this +constant ratio into a constant distance on the screen. In fact, this +display is exactly like a piano keyboard if every key, white or black, +were given the same non-discriminatory amount of space. + +### Smoothing + +Graphs are displayed using a cubic +[smoothing spline](https://en.wikipedia.org/wiki/Smoothing_spline). A +smoothing spline aims to produce a curve which is both accurate to the +data and smooth (that is, not jagged). It is an exact minimum: if you +agree with the definitions of accuracy and smoothness used to define the +spline, and the tradeoff between them, then there is no way to do +better. Unlike many other smoothing strategies, the smoothing parameter +which you can adjust in the graph tool doesn't specify an octave width +or number of samples to use. It just controls how heavily smoothness is +weighted relative to accuracy. The initial value of 5 could be any +number. 5 is chosen so that numbers from 1 to 10 are all sensible +smoothing parameters to use (you could also input a decimal rather than +sticking to whole numbers, but there's not much point). + +Regardless of the smoothing parameter chosen by the user, bass +frequencies are smoothed out much more than treble frequencies, by +adjusting the smoothing parameters so that smoothness is weighted more +heavily at lower frequencies and accuracy is weighted more heavily at +higher frequencies. The weighting for this adjustment was tuned manually +by looking at graphs. It has no physical basis. Technically only the +weighting for accuracy is adjusted (the smoothness weighting has to be +constant), which causes the smoothing to focus mainly on treble values +and ignore bass at very high smoothing levels. You're unlikely to learn +anything interesting about the response by setting a smoothing value in +the thousands. + +If the smoothing parameter is set to 0, no smoothing is performed, and a +cardinal spline, which is something like a mixture between cubic and +linear interpolation, is used. That means the spline is no longer a +smoothing spline, which would be the same as a natural cubic spline when +the smoothing parameter is 0. This choice is made because an exact +natural spline tends to emphasize little bumps in the data, making it +worse even than linear interpolation. + +Mathematically, a smoothing spline minimizes a weighted sum of: +1. All the square differences between the original and smoothed values, and +2. The integral of the square of the second derivative of the smoothed function. + +The differences (1) can be weighted individually at each frequency. The +second derivative is used as a measure of smoothness because it makes +exact minimization possible, tends to correspond well with properties of +real-world data, and is also a good proxy for visual curvature +(curvature is proportional to the second derivative in flat sections of +the graph, but it is lower in sloped sections). The smoothing spline is +a natural cubic spline on a set of points with the same frequencies as +the unsmoothed data but adjusted (smoothed) dB values. + +The smoothed values have the same average as the unsmoothed values. They +are also locally area-preserving, approximately, in that the average of +smoothed values over a large region will tend to be quite close to +average the original ones. This is because, if the averages differ over +a region, then the sum of squared differences over the region can be +decreased by adding the difference of averages to each smoothed value. +If the region is the entire graph, this just shifts the entire graph and +has no effect on smoothness; for a region of the graph it only has a +constant effect on smoothness since it only disrupts smoothness at the +two boundaries between that region and the rest of the graph (in fact, +by tapering off at the edges the effect on smoothness is smaller for +larger regions). In contrast the accuracy is improved by an amount +proportional to the size of the region: for a large enough region the +tradeoff must be worth it. + +Graph SPL values are smoothed directly without converting them from +decibels to a non-logarithmic unit. That means the discussion above +applies only to averages and sums in decibels, which are nonphysical. +Fortunately the differences between smoothed and raw values tend to be +small, so that everything is approximately linear. The graph smoothing +is best thought of as an aesthetic tool only: it softens curves to +remove distracting noise, while representing the original data as well +as possible. + +### Normalization + +Normalization refers to systematically shifting graphs up or down to +align them to some standard or target, just as you might do with the +volume knob. FR curves are always displayed with some kind of +normalization: in fact, Crin's measurement tool outputs curves using its +own normalization based on total (RMS) SPL, so the "original" volume +information just isn't there! You probably aren't too interested in that +information anyway, as you'll just adjust the volume to compensate for +it, and your brain automatically adjusts for volume as well. If you do +want to know how loud a headphone is compared to others, look for a +"sensitivity" specification for the model. + +The normalization settings allow you the option of normalizing according +to response at a particular frequency, or of using a weighted average +intended to measure total music volume. + +Responses are smoothed before normalization, and for two-channel curves +the [average](#averaging) of the channels is used for normalization: the +channels are not normalized independently. + +You can adjust headphones individually by changing the numeric input in +the manager. The number is an offset in decibels. + +#### Frequency normalization + +Choosing to normalize at a particular frequency (the right side, with +"Hz") simply shifts every headphone so its response at that frequency is +60dB. The value of 60dB is arbitrary; it is chosen mainly to keep +loudnesses in a double-digit range which is easier to read and a +sensible listening level. + +Frequency normalization at 1kHz is a common standard for audio research. +However, the response at any particular frequency may not be +representative of the headphone's loudness as a whole. For this reason +the default normalization is based on loudness normalization which uses +the entire response curve. + +#### Loudness normalization + +Setting a "dB" value normalizes headphones to a target listening level +when listening to [pink noise](https://en.wikipedia.org/wiki/Pink_noise) +(which has frequency content reasonably close to ordinary music). The +proper unit for loudness is actually the +[phon](https://en.wikipedia.org/wiki/Phon), but the unit "dB" is shown +instead because of the phon's obscurity. + +Even the use of "phon" is questionable: there is no standard +correspondence between speaker and in-ear sound levels, or research to +indicate how they might correspond. CrinGraph weights graphs using the +[ISO 226:2003](https://en.wikipedia.org/wiki/Equal-loudness_contour) +loudness standard (with linear rather than cubic interpolation, since it +has little effect on the average) with +[free field](https://en.wikipedia.org/wiki/Free_field_(acoustics)) +compensation (which most closely matches the conditions in which that +standard was measured) to convert from speakers to IEMs. The flat bass +response of the free field compensation is set to -7 dB to produce a +graph which looks visually centered around the target loudness, and +because it approximately normalizes the free field itself to 0dB at +1kHz. +See [this Github issue](https://github.com/mlochbaum/CrinGraph/issues/1) +for information about how these decisions were made. + +The resulting compensation differs smoothly by headphone volume. At low +volumes it peaks around 700Hz, and at high volumes it becomes flatter +and the peak shifts slowly down to 200Hz. When applied to an IEM with a +reasonable pinna gain, the upper mids will be most important for +normalization. + +Loudness is computed by averaging the power output at each frequency +(equivalent to a [root mean square](https://en.wikipedia.org/wiki/Root_mean_square) +average of the amplitudes) to obtain the total power of the signal. +That's how physics does it; hopefully something similar is happening +inside your head. Unfortunately there is little research on this topic. +The ISO 226:2003 standard was measured using pure sine wave tones, and +so is not valid for mixtures of tones. + +The headphone's frequency response only determines how it changes the +frequency response of a signal. In order to correctly determine the +loudness of headphones when playing music, you would have to know what +music is playing, and adjust it with the FR. The choice to average the +headphone's FR directly, assuming that measurements are evenly +distributed in logarithmic frequency space, corresponds to playing pink +noise through the headphones, since pink noise has its power evenly +distributed in logarithmic frequency space. + +### Averaging + +Curves are averaged according to sound amplitude rather than using +decibel values directly. This is roughly equivalent to placing both +sides of the headphone together to add their volume but using a source +that is half as loud. + +The amplitude rather than the power is used because the sound of both +sides at a particular frequency should be coherent, or identical in +phase. + +The effect of averaging in linear rather than logarithmic units (using +the amplitude and not decibel values) is that the average on the graph +is above the visual midpoint of the two channels. This makes sense: if +one side of your headphones goes out then its volume is minus infinity +decibels. Averaging directly with the other channel would give minus +infinity decibels again—total silence. But you can still hear something +from the other side! With correct averaging the average can be at most +6dB quieter than the other channel, that is, half as loud. + +### Baseline + +When you choose a baseline, the baseline's response (averaging both +channels if it has two) in decibels is subtracted from every curve. No +special math here. + +### Highlight on mouseover + +Hovering highlights the closest graph to the mouse, provided there is +one within a set maximum distance. Distance is the minimum distance to +any (smoothed) measurement in the graph, not to the cubic interpolation. +It is found by filtering the measurements to a frequency range which is +smaller than the maximum distance, then computing the distance to each +of those points. + +### Channel imbalance marker + +A headphone is marked with a red exclamation mark if the total channel +imbalance over any region is larger than a set maximum. The channel +imbalance is the difference in decibels of the two channels, weighted to +roll off steeply around 10kHz since higher frequencies are not as +reliable or as important for determining imbalance. + +The channel imbalance for a region is signed, so if there are two +regions where the left channel is louder with a region where the right +channel is louder in between then the middle region would count against +the total for all three. The maximum imbalance over all regions is +computed with +[Kadane's algorithm](https://en.wikipedia.org/wiki/Maximum_subarray_problem). + +## Aesthetics + +### Colors + +CrinGraph uses a sequence of colors designed so that nearby colors in +the sequence are perceptually distinct. Colors are chosen in sequence, +with one exception: colors too close in hue to a pinned headphone will +be skipped until a better one is found, giving up after three tries. + +[Martin Ankerl](https://martin.ankerl.com/2009/12/09/how-to-create-random-colors-programmatically/) +describes a method which selects hues with spacing based on the golden +ratio. Spacing in this way gives a one-dimensional *low-discrepancy +sequence*: a sequence in which it takes a while for values similar to +previous ones to appear. CrinGraph extends this technique to the three +dimensions of the +[HCL color space](https://en.wikipedia.org/wiki/HCL_color_space)—hue, +chroma, and luminance. To obtain a three-dimensional low-discrepancy +sequence it uses a variation of the method described by +[Martin Roberts](http://extremelearning.com.au/unreasonable-effectiveness-of-quasirandom-sequences/), +which simply identifies different constants used for spacing in each +dimension. Because hue is by far the most important dimension for visual +distinction, and the golden ratio is the best constant for one +dimension, the method is tweaked to get a value close to it for the hue: +a four-dimensional sequence is used, with the last dimension dropped. + +The low-discrepancy sequence is mapped to the entire hue range, and +sections of the chroma and luminance ranges, in HCL space. This +corresponds to a ring shape in CIELUV space, with a rectangular +cross-section along the ring, like a thick washer. Three modifications +are made to this ring in order to account for human perception, or maybe +imperfect perceptual uniformity of HCL space, or even unsuitability for +lines rather than color fields. +* Hues are shifted so cool colors like blues and greens appear less often, and reds and yellows more often. +* Hues are shifted towards six colors with evenly spaced hues—the primary and secondary colors red, yellow, green, cyan, blue, and purple. +* Chroma and luminance are shifted so that yellows are brighter and bolder, and blues darker. + +Channels are separated from one another primarily by adjusting hue and +chroma. Channels with different luminance don't look related. The +exception is for blues and purples, where luminance is adjusted because +the colors are too dark to distinguish by hue. + +Targets are colored using a much simpler scheme which uses the +unadjusted hue and a fixed chroma and luminance to produce greys which +differ only slightly. + +### Labelling + +Labels are chosen so that the label box is next to the graph it labels +but as far as possible from each other graph. Distance is measured +purely vertically, taking the minimum distance over the length of the +label, and adjusted to try to avoid the sides of the graph. + +For graph peaks, every position which touches the peak and is +sufficiently far from other graphs is grouped together. The largest +distance among those positions is used, but the label is placed at their +midpoint. + +No attempt is made to separate labels from each other. Usually +separating them from other graphs accomplishes this, but in some cases +it does not and they may overlap. + +If there is only one label, or if a suitable position for a label can't +be found, it's placed at the top left corner. If there is a hidden +baseline curve, its label is placed at the bottom of the graph. diff --git a/LICENSE b/LICENSE index 7d9c1ec7..89f66f99 100644 --- a/LICENSE +++ b/LICENSE @@ -1,12 +1,21 @@ -Copyright (c) 2019 Marshall Lochbaum - -Permission to use, copy, modify, and/or distribute this software for any -purpose with or without fee is hereby granted. - -THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH -REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY -AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, -INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM -LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR -OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR -PERFORMANCE OF THIS SOFTWARE. +MIT License + +Copyright (c) 2024 Haruto Hiroki + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/MIGRATING.md b/MIGRATING.md new file mode 100644 index 00000000..cda95e48 --- /dev/null +++ b/MIGRATING.md @@ -0,0 +1,9 @@ +# Migrating to Haruto's GraphTool version of CrinGraph for existing users of Squig.link + +## Steps + +1. Drag and drop your data folder over, overwriting the existing data folder. +2. (RECOMMENDED BUT OPTIONAL) Separate targets in your data folder to a separate folder named "targets" for each data folders. +3. Make changes to `config.js` and `config_hp.js` as necessary, should just be copy pastes from your old files as I've standardized the naming. +4. Make changes to `index.html` (your IEM page), and `index_hp.html` (your headphones page) as necessary, make sure to not touch anything outside the `` tag. +5. Deploy and enjoy your new functionalities! \ No newline at end of file diff --git a/README.md b/README.md index 39792964..da29d0f6 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,6 @@ +# Demo Page +https://graphtool-demo.harutohiroki.com/ + # Changes - Added Equalizer (cred to Rohsa) - Added Uploads @@ -9,7 +12,7 @@ - Removed Restricted mode (cuz I want to keep it free) - Reorganised code - Moved targets to a different folder for organization -- Moved phone_book outside for easier access +- Moved phone_book outside for easier access (reverted for squiglink compatibility) - Added a function to average all active graphs (requested by listener) - Custom Diffuse Field Tilt (requested by listener) - Restyled EQ tab @@ -24,7 +27,15 @@ - Added Treble customisation to custom tilt (requested by listener) - Added a button to swap between different y-axis scales (requested by rollo) - Added Preference Bounds and Preference Bound scaling (requested by listener) - +- Reversed the "any target tiltable" feature, now applying tilt on target automatically if supported (requested by listener) +- Per-measurement compensation (requested by listener) +- Added support for Haruto's Graph Extension to apply eq to browserwide +- Made Preference Bounds better and not relying on a png anymore +- Downloadable CSV of all active graphs +- Per page Y scaling (requested by listener) +- Added a Graph Customisation menu +- Added Translations (Thanks to potatosalad775) (removed for now due to not having enough translations, will be added back soon) +- Added the 90% Inclusion Zone feature (requested and long awaited by the community) # TODO - Implement a way to measure the SPL of an IEM and decide whether to upload it or not, skipping REW @@ -33,6 +44,121 @@ - ability to apply smoothing - Trace Arithmetic - Realtime Analysis +- EQ upload to hardware + +# Contributors + + + # P.S. -- If you do implement code in here, do leave credits to the original author (me) and the contributors (Rohsa, MRS) \ No newline at end of file +- If you do implement code in here, do leave credits to the original author (me) and the contributors (Rohsa, MRS, potatosalad775) + +# The In-Ear Graphing Library + +If you're not weirdly obsessed with headphones you can leave at any time. + +Crinacle is a reviewer famous around the world (at least, I'm on the +opposite side of it as he is) for his extensive reviews and measurements +of in-ear monitors (IEMs). CrinGraph is the tool which allows readers to +compare measurements against each other, and save easily readable images +to share around the internet. Although it was designed for +[Crin's site](https://crinacle.com/graphs/graphtool/), +the code here can be used freely by anyone, with no restrictions. +There are now many instances, including +[Banbeucmas](https://banbeu.com/graph/tool/), +[HypetheSonics](https://www.hypethesonics.com/iemdbc/), +[Rohsa](https://rohsa.gitlab.io/graphtool/), and +[Super\* Review](https://squig.link/), which has links to even more of +them. If you're interested in using it for your own graphs, see +[Configuring.md](Configuring.md) and ask me about any questions that +come up. + +### What are the squiggles? + +If you want the whole story, there's no choice but to get it from +[the man himself](https://crinacle.com/2020/04/08/graphs-101-how-to-read-headphone-measurements/). +5,000 words and you'll still be disappointed when it's over. + +The most informative headphone measurement, and the only one handled by +this tool, is the frequency response (FR) graph. An FR graph shows how +loud the headphone will render sounds at different pitches. The higher +the left portion of the graph, the more your brain will rattle; the +higher the right portion of the graph, the more your ears will bleed. +The current industry standard is a "V-shaped" response which applies +both conditions at once. Using an FR graph you may easily see which +headphones conform to this standard and which are insufficiently "fun". + +### Sample graphs + +This repository includes some sample data so that the tool can be shown +through Github pages. Sometimes I use this to show people features +before they're adopted on Crin's site. + +[View some sample graphs.](https://mlochbaum.github.io/CrinGraph/graph.html) + +Because Crinacle's frequency response measurements are not public, the +sample response curves shown are synthesized. They are not real +headphones and you can't listen to them. To reduce potential +disappointment, steps have been taken to ensure that the curves are as +uninviting as possible. Any resemblance to the exports of a large East +Asian county is purely coincidental. + +## Features + +If you want one that's not here, just ask so I can explain why it's a +bad idea. + +### Layout + +The graph tool displays: +* A **graph window** at the top +* The **toolbar** just below it +* The **selector** at the bottom left, or below the toolbar for narrow windows +* A **target selector** +* The **manager** for active curves + +### Graph window + +* Standard logarithmic frequency (Hz) and sound pressure level (dB) [axes](Documentation.md#axes) +* [Colors](Documentation.md#colors) are persistent and algorithmically generated to ensure contrast +* Use the slider at the left to rescale and adjust the y axis +* [Hover](Documentation.md#highlight-on-mouseover) over or click a curve to see its name and highlight it in the manager + +### Toolbar + +* Zoom into bass, mid, or treble frequencies +* [Normalize](Documentation.md#normalization) with a target loudness or a normalization frequency +* [Smooth](Documentation.md#smoothing) graphs with a configurable parameter +* Toggle inspect mode to see the numeric response values when you mouse over the graph +* [Label](Documentation.md#labelling) curves inside the graph window +* Save a png [screenshot](Documentation.md#screenshot) of the graph (with labels) +* Recolor the active curves in case there is a color conflict +* Toolbar collapses and expands, along with the target selector, when the screen is small + +### Headphone and target selectors + +* Headphones are grouped by brand: select brands to narrow them down +* Click to select one headphone or brand and unselect others; middle or ctrl-click for non-exclusive select +* [Search](Documentation.md#searching) all brands or headphones by name +* Targets are selected the same way but are independent from headphones + +### Headphone manager + +* Curve names and colors are displayed here +* Choose and compare variant measurements of the same model with a dropdown +* Use the wishbone-shaped selector to see left and/or right channels or [average](Documentation.md#averaging) them together +* A red exclamation mark indicates that channels are [imbalanced](Documentation.md#channel-imbalance-marker) +* Change the offset to move graphs up and down (after [normalization](Documentation.md#normalization)) +* Select [BASELINE](Documentation.md#baseline) to adjust all curves so the chosen one is flat +* Temporarily hide or unhide a graph +* PIN a headphone to avoid losing it while adding others +* Click the little dots at the bottom left to change a single headphone's [color](Documentation.md#colors) + +## Contact + +File a Github issue here for topics related to the project. You can also +reach me by the email in my Github profile and the [LICENSE](LICENSE). +I can sometimes be found on +[Crin's Discord server](https://discord.gg/CtTqcCb) where I am +creatively named Marshall. diff --git a/assets/css/extra.css b/assets/css/extra.css index 580c1c67..e621aba7 100644 --- a/assets/css/extra.css +++ b/assets/css/extra.css @@ -38,8 +38,7 @@ button#yscalebtn:active { color: var(--font-color-secondary); } -button#avg-all, -button#yscalebtn { +button#avg-all { margin-left: 6px; } @@ -56,11 +55,14 @@ div.yscaler { div.yscaler > span { margin-right: 10px; + min-width: max-content; + color: var(--background-color-contrast-more); font-family: var(--font-secondary); font-size: 11px; line-height: 1em; text-transform: uppercase; + white-space: nowrap; } div.yscaler > div { @@ -84,6 +86,57 @@ image.graph_logo { filter: var(--svg-filter); } +/** Color Picker Thingy **/ +.colorStylePicker { + display: flex; + align-items: flex-start; + box-sizing: border-box; + flex-wrap: wrap; + flex-direction: row; + background-color: var(--background-color); + border: 1px solid var(--background-color-contrast-more); + border-radius: 6px; + z-index: 1000; +} + +.colorStylePicker .left-side { + display: flex; + align-items: center; + padding: 0 10px 0 0; +} + +.colorStylePicker .right-side { + flex: 1; + align-items: center; +} + +.colorStylePicker .right-side .row { + display: flex; + flex-direction: row; +} + + +.colorStylePicker .right-side>div>span { + color: var(--font-color-inputs); + font-family: var(--font-primary); + font-size: 13.5px; + line-height: 1em; +} + +.tickText { + order: 1 !important; +} +.tickInput { + order: 2 !important; + width: 55px !important; +} +.spaceText { + order: 3 !important; +} +.spaceInput { + order: 4 !important; + width: 55px !important; +} /** Custom DF tilt**/ @@ -125,7 +178,8 @@ div.customDF>button+div { margin-left: 6px } -div.customDF>div>input { +div.customDF>div>input, +.colorStylePicker input { order: 2; box-sizing: border-box; width: 70px; @@ -146,7 +200,16 @@ div.customDF>div>input { padding-left: 11px } -div.customDF>div:after { +.colorStylePicker input { + border: 1px solid var(--background-color-contrast-more); + border-radius: 6px; + border-left: none; + border-top-left-radius: 0; + border-bottom-left-radius: 0; + height: 35.6px; +} + +div.customDF>div:after{ order: 3; content: ''; box-sizing: border-box; @@ -159,7 +222,8 @@ div.customDF>div:after { border-radius: 0 6px 6px 0 } -div.customDF>div>span { +div.customDF>div>span, +.colorStylePicker .right-side>.row>span { order: 1; padding: 11px 16px; background-color: var(--background-color)!important; @@ -179,7 +243,8 @@ div.customDF>div.selected>span { color: var(--font-color-secondary) } -div.customDF>button { +div.customDF>button, +.colorStylePicker button { padding: 11px 16px; background-color: var(--background-color) !important; @@ -194,8 +259,14 @@ div.customDF>button { white-space: nowrap; cursor: pointer; } +.colorStylePicker button { + order: 3; + margin-left: 7px; + height: 35.6px; +} -div.customDF>button:active { +div.customDF>button:active, +.colorStylePicker button:active { box-sizing: border-box; background-color: var(--accent-color) !important; border-color: var(--accent-color) !important; @@ -203,6 +274,32 @@ div.customDF>button:active { color: var(--font-color-secondary); } +div.customDF>button.selected, +.colorStylePicker button.selected { + background-color: var(--accent-color)!important; + border-color: var(--accent-color); + color: var(--font-color-secondary) +} + +.colorStylePicker select { + box-sizing: border-box; + width: 120px; + height: 36px; + padding: 8px 6px 8px 0px; + + background-color: var(--background-color-inputs); + border: 1px solid var(--background-color-contrast-more); + border-radius: 6px; + outline: none; + + color: var(--font-color-inputs); + font-family: var(--font-secondary); + font-size: 11px; + line-height: 1em; + text-transform: uppercase; + text-align: center; +} + /** extra panel **/ div.extra-panel { @@ -405,7 +502,7 @@ span:has(input[name="vol-right"]):after { div.extra-panel div.settings-row, div.extra-panel div.auto-eq-button, div.extra-panel div.filters-button, -div.extra-panel div.extra-upload { +div.extra-panel div.extra-upload-buttons { width: 100%; margin: 4px 0 0 0; box-sizing: border-box; @@ -419,7 +516,7 @@ div.extra-panel h4 { margin-bottom: 4px; } -div.extra-panel div.extra-upload > button { +div.extra-panel div.extra-upload-buttons > button { width: 33.3%; } @@ -574,4 +671,140 @@ div.extra-panel div.filters-button > button[class="pink-noise selected"] { .volume-icon { color: var(--accent-color-contrast) !important; fill: var(--accent-color-contrast) !important; +} + +/** separate target comp **/ +tbody.curves > tr > td.comp { + order: 3; + + padding: 0 0 0 13px; +} + +tbody.curves > tr > td.comp select { + box-sizing: border-box; + width: 120px; + height: 36px; + padding: 8px 6px 8px 0px; + + background-color: var(--background-color-inputs); + border: 1px solid var(--background-color-contrast-more); + border-radius: 6px; + outline: none; + + color: var(--font-color-inputs); + font-family: var(--font-secondary); + font-size: 11px; + line-height: 1em; + text-transform: uppercase; + text-align: center; +} + +@media ( max-width: 1200px ) { + tbody.curves > tr > td.comp { + margin-left: auto; + } +} + +@media ( max-width: 500px) { + tbody.curves > tr:before { + order: 5; + + content:''; + display: block; + flex: 100% 1 1; + + height: 0px; + } + + tbody.curves > tr > td.channels { + order: 3; + } + + tbody.curves > tr > td.levels { + order: 4; + + margin-right: 0px !important; + } + + tbody.curves > tr > td.comp { + order: 4 !important; + + margin-right: 16px; + } + + tbody.curves > tr > td.button-baseline { + margin-left: auto; + } + + tbody.curves > tr > td.button-baseline, + tbody.curves > tr > td.hideIcon, + tbody.curves > tr > td.button-pin { + order: 5; + } +} + +tbody.curves > tr > td.remove { + order: 7 !important; +} + +:root { + --icon-save: url("data:image/svg+xml;charset=utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24'%3E%3Cpath d='M16 11h5l-9 10-9-10h5v-11h8v11zm1 11h-10v2h10v-2z'/%3E%3C/svg%3E"); +} + +tbody.curves > tr > td.button-saveSquig { + order: 6; +} + +tbody.curves > tr > td.button.button-saveSquig { + mask: var(--icon-save); + -webkit-mask: var(--icon-save); + mask-size: 15px; + mask-repeat: no-repeat; + mask-position: center; + -webkit-mask-size: 15px; + -webkit-mask-repeat: no-repeat; + -webkit-mask-position: center; +} + +tbody.curves > tr > td.button-saveSquig:before { + background-color: var(--background-color-contrast); + border-color: transparent; +} + +tbody.curves > tr > td.button-saveSquig:after { + background-color: var(--font-color-primary) !important; +} + +/** color and line weight / line dash style menu **/ +.line-style-menu { + display: flex; + align-items: center; + gap: 10px; + display: inline-block; +} + +.line-style-menu-content { + display: none; + position: absolute; + background-color: var(--background-color); + min-width: 160px; + box-shadow: 0px 8px 16px 0px rgba(0,0,0,0.2); + z-index: 1; +} + +.line-style-menu-content .style-button { + color: var(--font-color-primary); + padding: 12px 16px; + text-decoration: none; + display: block; + cursor: pointer; +} + +.line-style-menu-content input[type="squig_color"] { + width: 100%; + height: 100%; + padding: 0; + margin: 0; + border: none; + cursor: pointer; } \ No newline at end of file diff --git a/assets/css/reinvented-color-wheel.min.css b/assets/css/reinvented-color-wheel.min.css new file mode 100644 index 00000000..fc7d52f8 --- /dev/null +++ b/assets/css/reinvented-color-wheel.min.css @@ -0,0 +1 @@ +.reinvented-color-wheel,.reinvented-color-wheel--hue-handle,.reinvented-color-wheel--hue-wheel,.reinvented-color-wheel--sv-handle,.reinvented-color-wheel--sv-space{touch-action:manipulation;touch-action:none;-webkit-touch-callout:none;-webkit-tap-highlight-color:transparent;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}.reinvented-color-wheel{position:relative;display:inline-block;line-height:0;border-radius:50%}.reinvented-color-wheel--hue-wheel{border-radius:50%}.reinvented-color-wheel--sv-space{position:absolute;left:0;top:0;right:0;bottom:0;margin:auto}.reinvented-color-wheel--hue-handle,.reinvented-color-wheel--sv-handle{position:absolute;box-sizing:border-box;border-radius:50%;border:2px solid #fff;box-shadow:0 0 0 1px #000 inset}.reinvented-color-wheel--hue-handle{pointer-events:none} \ No newline at end of file diff --git a/assets/css/style-alt-theme.css b/assets/css/style-alt-theme.css index 78f047bc..33b7a2c3 100644 --- a/assets/css/style-alt-theme.css +++ b/assets/css/style-alt-theme.css @@ -20,12 +20,16 @@ --scrollbar-color: 255,255,255; --scrollbar-accent: 0,0,0; - + + --header-color: hsl(215, 20%, 10%); + --header-links-color: hsl(0, 0%, 100%); + --header-menu-icon-color: hsl(0, 0%, 100%); + --logo-filter: invert(1.0); --svg-filter: none; } -body.dark-mode { +body.theme-dark { --accent-color: hsl(0, 0%, 100%); --accent-color-contrast: hsl(0, 0%, 50%); @@ -46,6 +50,60 @@ body.dark-mode { --scrollbar-color: 0,0,0; --scrollbar-accent: 255,255,255; + --header-color: hsl(0, 0%, 0%); + --header-links-color: hsl(0, 0%, 100%); + --header-menu-icon-color: hsl(0, 0%, 100%); + + --logo-filter: invert(1.0); + --svg-filter: invert(1); +} + +body.theme-contrast { + --accent-color: hsl(0, 0%, 0%); + --accent-color-contrast: hsl(0, 0%, 0%); + + --background-color: hsl(0, 0%, 100%); + --background-color-contrast: hsl(0, 0%, 0%); + --background-color-contrast-more: hsl(0, 0%, 0%); + --background-color-graph: hsl(0, 0%, 100%); + + --background-color-inputs: hsl(0, 0%, 100%); + + --font-color-primary: hsl(0, 0%, 0%); + --font-color-secondary: hsl(0, 0%, 100%); + --font-color-inputs: hsl(0, 0%, 0%); + + --font-primary: 'Open Sans', sans-serif; + --font-secondary: monospace; + + --scrollbar-color: 255,255,255; + --scrollbar-accent: 0,0,0; + + --header-color: hsl(215, 20%, 10%); + --header-links-color: hsl(0, 0%, 100%); + --header-menu-icon-color: hsl(0, 0%, 100%); + --logo-filter: invert(1.0); - --svg-filter: invert(1.0); + --svg-filter: none; +} + +/***** Squiglink site address *****/ + +image.wm-squiglink-logo { +} + +text.wm-squiglink-address { + color: var(--font-color-primary); + color: magenta; + font-family: var(--font-primary); + font-weight: 700; + font-size: 14px; + + filter: var(--svg-filter); +} + +g.curves-g path.target + path.target { + stroke-dasharray: 20, 10 !important; + stroke-width: 2.5; + opacity: 0.4; } \ No newline at end of file diff --git a/assets/css/style-alt.css b/assets/css/style-alt.css index 77c757bc..d66b9c35 100644 --- a/assets/css/style-alt.css +++ b/assets/css/style-alt.css @@ -36,12 +36,15 @@ Icons *****/ /***** https://yoksel.github.io/url-encoder/ *****/ :root { + --icon-90: url("data:image/svg+xml,%3Csvg%20%20xmlns=%22http://www.w3.org/2000/svg%22%20%20width=%2224%22%20%20height=%2224%22%20%20viewBox=%220%200%2024%2024%22%20%20fill=%22none%22%20%20stroke=%22currentColor%22%20%20stroke-width=%221.75%22%20%20stroke-linecap=%22round%22%20%20stroke-linejoin=%22round%22%20%20class=%22icon%20icon-tabler%20icons-tabler-outline%20icon-tabler-number-90-small%22%3E%3Cpath%20stroke=%22none%22%20d=%22M0%200h24v24H0z%22%20fill=%22none%22/%3E%3Cpath%20d=%22M14%2010v4a2%202%200%201%200%204%200v-4a2%202%200%201%200%20-4%200%22%20/%3E%3Cpath%20d=%22M6%2015a1%201%200%200%200%201%201h2a1%201%200%200%200%201%20-1v-6a1%201%200%200%200%20-1%20-1h-2a1%201%200%200%200%20-1%201v2a1%201%200%200%200%201%201h3%22%20/%3E%3C/svg%3E"); + --icon-remove: url("data:image/svg+xml,%3Csvg id='Layer_1' data-name='Layer 1' xmlns='http://www.w3.org/2000/svg' viewBox='0 0 120 120'%3E%3Cdefs%3E%3Cstyle%3E.cls-1%7Bfill:%23000000;%7D%3C/style%3E%3C/defs%3E%3Cpath class='cls-1' d='M95.36,24.64h0a5,5,0,0,0-7.08,0L60,52.93,31.72,24.64a5,5,0,0,0-7.08,0h0a5,5,0,0,0,0,7.08L52.93,60,24.64,88.28a5,5,0,0,0,0,7.08h0a5,5,0,0,0,7.08,0L60,67.07,88.28,95.36a5,5,0,0,0,7.08,0h0a5,5,0,0,0,0-7.08L67.07,60,95.36,31.72A5,5,0,0,0,95.36,24.64Z'/%3E%3C/svg%3E"); --icon-baseline: url("data:image/svg+xml,%3Csvg id='Layer_1' data-name='Layer 1' xmlns='http://www.w3.org/2000/svg' viewBox='0 0 120 120'%3E%3Crect x='10' y='55' width='100' height='10' rx='5'/%3E%3C/svg%3E"); --icon-squiggle: url("data:image/svg+xml,%3Csvg id='Layer_1' data-name='Layer 1' xmlns='http://www.w3.org/2000/svg' viewBox='0 0 120 120'%3E%3Cdefs%3E%3Cstyle%3E.cls-1%7Bfill:%23000000;%7D%3C/style%3E%3C/defs%3E%3Cpath class='cls-1' d='M80.37,85c-12.63,0-18.92-6.89-22-12.67A30.8,30.8,0,0,1,55,60.18V60a20.12,20.12,0,0,0-2.1-8c-2.46-4.7-6.68-7-12.9-7s-10.44,2.28-12.9,7A20.1,20.1,0,0,0,25,60a5,5,0,0,1-5,5h0a5,5,0,0,1-5-5c0-8.65,5.22-25,25-25S65,51.2,65,59.87C65.1,61.67,66.3,75,80.37,75c6,0,10.16-2.27,12.56-7A20.49,20.49,0,0,0,95,60a5,5,0,0,1,5-5h0a5,5,0,0,1,5,5,30,30,0,0,1-3,12.2C98,80.46,90.29,85,80.37,85Z'/%3E%3C/svg%3E"); --icon-hide: url("data:image/svg+xml,%3Csvg id='Layer_1' data-name='Layer 1' xmlns='http://www.w3.org/2000/svg' viewBox='0 0 120 120'%3E%3Cdefs%3E%3Cstyle%3E.cls-1%7Bfill:%23000000;%7D%3C/style%3E%3C/defs%3E%3Cpath class='cls-1' d='M60,30C32.39,30,10,43.43,10,60S32.39,90,60,90s50-13.43,50-30S87.61,30,60,30ZM90.21,72.64C82.41,77.32,71.4,80,60,80,37.11,80,20,69.44,20,60c0-4.3,3.57-8.91,9.79-12.64C37.59,42.68,48.6,40,60,40c22.89,0,40,10.56,40,20C100,64.3,96.43,68.91,90.21,72.64Z'/%3E%3Ccircle class='cls-1' cx='60' cy='60' r='10'/%3E%3C/svg%3E"); --icon-pin: url("data:image/svg+xml,%3Csvg id='Layer_1' data-name='Layer 1' xmlns='http://www.w3.org/2000/svg' viewBox='0 0 120 120'%3E%3Cdefs%3E%3Cstyle%3E.cls-1%7Bfill:%23000000;%7D%3C/style%3E%3C/defs%3E%3Cpath class='cls-1' d='M78.07,30.48A28.68,28.68,0,0,1,89.52,41.93l-2.33-.64L83.49,45,69.05,59.43l-4.78,4.78,3.27,5.93c2.61,4.74,2.9,7.76,2.79,8.93a3,3,0,0,1-.45,0c-2.64,0-7.89-1.7-14-6.48l-4.75-3.74-3.74-4.75c-5.55-7.09-6.65-12.6-6.48-14.45l.46,0c1,0,3.89.28,8.49,2.81l5.93,3.27L60.57,51,75,36.51l3.71-3.7-.64-2.33M74.41,20.27A6.34,6.34,0,0,0,69.77,22c-1.79,1.79-2.08,4.76-1.13,8.2L54.21,44.58C49.57,42,45.1,40.65,41.37,40.65a9.54,9.54,0,0,0-7,2.51c-5,5-2.27,16.09,5.9,26.51L23,95.58a1,1,0,0,0,.83,1.55,1,1,0,0,0,.55-.17L50.33,79.69c6.87,5.38,14,8.4,19.55,8.4a9.52,9.52,0,0,0,7-2.5c3.93-3.93,3.08-11.62-1.42-19.8L89.85,51.36a13.62,13.62,0,0,0,3.58.53,6.32,6.32,0,0,0,4.62-1.66C102,46.32,98.79,36.83,91,29c-5.54-5.54-11.93-8.75-16.57-8.75Z'/%3E%3C/svg%3E"); --icon-plus: url("data:image/svg+xml,%3Csvg id='Layer_1' data-name='Layer 1' xmlns='http://www.w3.org/2000/svg' viewBox='0 0 120 120'%3E%3Cdefs%3E%3Cstyle%3E.cls-1%7Bfill:%23000000;%7D%3C/style%3E%3C/defs%3E%3Cpath class='cls-1' d='M110,60h0a5,5,0,0,0-5-5H65V15a5,5,0,0,0-5-5h0a5,5,0,0,0-5,5V55H15a5,5,0,0,0-5,5h0a5,5,0,0,0,5,5H55v40a5,5,0,0,0,5,5h0a5,5,0,0,0,5-5V65h40A5,5,0,0,0,110,60Z'/%3E%3C/svg%3E"); + --icon-new-tab: url("data:image/svg+xml,%3Csvg id='Layer_1' data-name='Layer 1' xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cdefs%3E%3Cstyle%3E.cls-1,.cls-2%7Bfill:none;stroke:%23231f20;%7D.cls-2%7Bstroke-linecap:round;%7D%3C/style%3E%3C/defs%3E%3Cpath class='cls-1' d='M21,11v2c0,3.77,0,5.66-1.17,6.83S16.77,21,13,21H11c-3.77,0-5.66,0-6.83-1.17S3,16.77,3,13V11C3,7.23,3,5.34,4.17,4.17S7.23,3,11,3h1'/%3E%3Cpath class='cls-2' d='M21,3.15H16.76m4.24,0V7.39m0-4.24-8.49,8.48'/%3E%3C/svg%3E"); --icon-expand: url("data:image/svg+xml,%3Csvg id='Layer_1' data-name='Layer 1' xmlns='http://www.w3.org/2000/svg' viewBox='0 0 120 120'%3E%3Cdefs%3E%3Cstyle%3E.cls-1%7Bfill:%23252627;%7D%3C/style%3E%3C/defs%3E%3Cpath class='cls-1' d='M24.64,95.36l24.75-3.54-7.07-7.07,3.54-3.54a5,5,0,0,0,0-7.07h0a5,5,0,0,0-7.07,0l-3.54,3.54-7.07-7.07Z'/%3E%3Cpath class='cls-1' d='M95.36,24.64,70.61,28.18l7.07,7.07-3.54,3.54a5,5,0,0,0,0,7.07h0a5,5,0,0,0,7.07,0l3.54-3.54,7.07,7.07Z'/%3E%3Cpath class='cls-1' d='M42.32,35.25l7.07-7.07L24.64,24.64l3.54,24.75,7.07-7.07,3.54,3.54a5,5,0,0,0,7.07,0h0a5,5,0,0,0,0-7.07Z'/%3E%3Cpath class='cls-1' d='M95.36,95.36,91.82,70.61l-7.07,7.07-3.54-3.54a5,5,0,0,0-7.07,0h0a5,5,0,0,0,0,7.07l3.54,3.54-7.07,7.07Z'/%3E%3C/svg%3E"); --icon-collapse: url("data:image/svg+xml,%3Csvg id='Layer_1' data-name='Layer 1' xmlns='http://www.w3.org/2000/svg' viewBox='0 0 120 120'%3E%3Cdefs%3E%3Cstyle%3E.cls-1%7Bfill:%23252627;%7D%3C/style%3E%3C/defs%3E%3Cpath class='cls-1' d='M52.93,67.07,28.18,70.61l7.07,7.07-3.53,3.53a5,5,0,0,0,0,7.07h0a5,5,0,0,0,7.07,0l3.53-3.53,7.07,7.07Z'/%3E%3Cpath class='cls-1' d='M67.07,52.93l24.75-3.54-7.07-7.07,3.53-3.53a5,5,0,0,0,0-7.07h0a5,5,0,0,0-7.07,0l-3.53,3.53-7.07-7.07Z'/%3E%3C/svg%3E"); @@ -147,10 +150,18 @@ g.dBScaler rect[fill="#bbb"] { fill: var(--background-color-graph); } +svg#fr-graph > g > image { + filter: var(--svg-filter); +} + svg#fr-graph rect { pointer-events: all; } +g.inspector rect { + pointer-events: none !important; +} + text.insp_dB { fill: var(--background-color-contrast-more); } @@ -273,8 +284,6 @@ svg#fr-graph[data-labels-position="bottom-right"] > rect + g.lineLabel:nth-last- transform: translate(500px, 208px); } - - /***** Framing *****/ @@ -322,12 +331,11 @@ Alt header *****/ header.header { display: flex; - align-items: center; + align-items: flex-start; box-sizing: border-box; - flex: 48px 0 0; - background-color: #000000; + background-color: var(--header-color); } button.header-button { @@ -340,7 +348,7 @@ div.logo { align-items: center; box-sizing: border-box; - flex: 200px 0 0; + flex: 350px 0 0; height: 48px; padding: 12px 0; overflow: hidden; @@ -374,10 +382,20 @@ div.logo span { color: var(--header-links-color); } +select.language-selector { + border-radius: 4px; + background-color: #000000; + color: #ffffff; + padding: 7px; + margin-right: 10px; +} + ul.header-links { display: flex; justify-content: flex-start; align-items: flex-start; + flex-wrap: wrap; + gap: 0 32px; box-sizing: border-box; flex: 100% 0 1; @@ -423,6 +441,27 @@ ul.header-links a { text-decoration: none; } +ul.header-links a.external { + padding-right: 20px; +} + +ul.header-links a.external:after { + content: ''; + display: block; + flex: 16px 0 0; + height: 16px; + margin: 0 0 0 4px; + background-color: currentColor; + mask: var(--icon-new-tab); + -webkit-mask: var(--icon-new-tab); + mask-size: 14px; + mask-repeat: no-repeat; + mask-position: center; + -webkit-mask-size: 14px; + -webkit-mask-repeat: no-repeat; + -webkit-mask-position: center; +} + /***** @@ -651,12 +690,14 @@ div.zoom { div.zoom > span { margin-right: 10px; + min-width: max-content; color: var(--background-color-contrast-more); font-family: var(--font-secondary); font-size: 11px; line-height: 1em; text-transform: uppercase; + white-space: nowrap; } div.zoom button { @@ -715,12 +756,14 @@ div.normalize { div.normalize > span { margin-right: 10px; + min-width: max-content; color: var(--background-color-contrast-more); font-family: var(--font-secondary); font-size: 11px; line-height: 1em; text-transform: uppercase; + white-space: nowrap; } div.normalize .helptip { @@ -814,12 +857,14 @@ div.smooth { div.smooth > span { margin-right: 10px; + min-width: max-content; color: var(--background-color-contrast-more); font-family: var(--font-secondary); font-size: 11px; line-height: 1em; text-transform: uppercase; + white-space: nowrap; } div.smooth input { @@ -1182,6 +1227,18 @@ tbody.curves > tr > td.button.hideIcon:after { -webkit-mask-position: center; } +tbody.curves > tr > td.button.button-ninety:after { + mask: var(--icon-90); + -webkit-mask: var(--icon-90); + + mask-size: 25px; + mask-repeat: no-repeat; + mask-position: center; + -webkit-mask-size: 25px; + -webkit-mask-repeat: no-repeat; + -webkit-mask-position: center; +} + tbody.curves > tr > td.button.button-baseline:after { mask: var(--icon-squiggle); -webkit-mask: var(--icon-squiggle); @@ -1222,30 +1279,47 @@ tbody.curves > tr > td.button-baseline:after { background-color: var(--font-color-primary); } -/* Hide */ +/* Hide + 90% Inclusion*/ +tbody.curves > tr > td.button-ninety { + position: relative; + + order: 3; + } + tbody.curves > tr > td.hideIcon { position: relative; order: 4; } +tbody.curves > tr > td.button-ninety svg, tbody.curves > tr > td.hideIcon svg { display: none; } +tbody.curves > tr > td.button-ninety svg, tbody.curves > tr > td.hideIcon:before { background-color: var(--background-color); border-color: transparent; } +tbody.curves > tr > td.button-ninety:after { + background-color: var(--background-color-contrast-more); +} + tbody.curves > tr > td.hideIcon:after { background-color: var(--font-color-primary); } +tbody.curves > tr > td.button-ninety.selected:before, tbody.curves > tr > td.hideIcon.selected:before { border-color: var(--background-color-contrast); } +tbody.curves > tr > td.button-ninety.selected:after { + background-color: var(--font-color-primary); +} + tbody.curves > tr > td.hideIcon.selected:after { background-color: var(--background-color-contrast-more); } @@ -1354,6 +1428,7 @@ tbody.curves > tr > td.item-line div.variantName { position: relative; top: auto !important; + width: auto !important; flex: calc(100% - 52px) 0 0; height: 50px; @@ -1594,6 +1669,28 @@ tbody.curves > tr > td.item-line span.variantPopout:nth-of-type(20) { order: 40; } +/* Styles for variantName + variantPopout layout */ +div.variant-names { + flex: auto 1 1; + max-width: calc(100% - 40px); +} + +div.variant-popouts { + flex: 36px 0 0; + padding: 7px 0 0 0; + padding: 0; +} + +span.variantPopout { + margin: 0 0 14px 0; + display: flex !important; +} + +span.variantPopout[style*="display: none;"] { + opacity: 0.0; +} + +/* -- */ tbody.curves > tr > td.item-line div.variants { position: relative; @@ -1886,6 +1983,8 @@ div.scroll { overflow-y: auto; overflow-x: hidden; + + scroll-behavior: smooth; } /* List selected styles */ @@ -1933,7 +2032,7 @@ div.scroll > div { position: relative; box-sizing: border-box; - height: 36px; + min-height: 36px; padding: 11px 12px; overflow: hidden; @@ -2798,6 +2897,7 @@ Responsive styles *****/ } */ + tbody.curves > tr > td.button-ninety.selected:before, tbody.curves > tr > td.hideIcon.selected:before, tbody.curves > tr > td.button-pin:before, tbody.curves > tr > td.remove:before { @@ -2806,6 +2906,7 @@ Responsive styles *****/ } tbody.curves > tr > td.button-baseline:before, + tbody.curves > tr > td.button-ninety:before, tbody.curves > tr > td.hideIcon:before, tbody.curves > tr > td.button-pin[data-pinned="true"]:before { background-color: var(--background-color-contrast); @@ -3005,29 +3106,10 @@ Responsive styles *****/ /***** Alt header mobile *****/ - header.header:before { - position: absolute; - top: 6px; - left: 6px; - - content: ''; - box-sizing: border-box; - display: block; - width: 36px; - height: 36px; - - background-color: var(--background-color); - border: 1px solid var(--background-color-contrast-more); - border-radius: 6px; - cursor: pointer; - display: none; - } - button.header-button { - content: ''; box-sizing: border-box; display: block; - width: 36px; + flex: 36px 0 0; height: 36px; margin: 0 0 0 6px; @@ -3156,6 +3238,7 @@ Responsive styles *****/ display: flex; flex: 50% 1 1; + height: calc(100% - 52px); } div.scroll { @@ -3209,6 +3292,7 @@ Responsive styles *****/ } tbody.curves > tr > td.button-baseline, + tbody.curves > tr > td.button-ninety, tbody.curves > tr > td.hideIcon, tbody.curves > tr > td.button-pin { order: 5; @@ -3258,3 +3342,46 @@ Responsive styles *****/ pointer-events: none; } } + +/***** +Embed mode *****/ +body[embed-mode="true"] header.header { + display: none; +} + +body[embed-mode="true"] section.parts-primary { + z-index: 3; + + flex: 100vh 1 1 !important; + max-height: -webkit-fill-available; +} + +body[embed-mode="true"] section.parts-secondary { + display: none; +} + +body[embed-mode="true"] div.graph-sizer { + position: fixed; + z-index: 5; + top: 0; + left: 0; + + display: flex; + justify-content: center; + align-items: center; + + box-sizing: border-box; + width: 100vw; + height: 100vh; + max-width: none; + max-height: -webkit-fill-available; + margin: 0; + + background-color: var(--background-color); +} + +body[embed-mode="true"] svg#fr-graph { + width: 100%; + + pointer-events: none; +} diff --git a/assets/images/bounds.png b/assets/images/bounds.png deleted file mode 100644 index f169cd71..00000000 Binary files a/assets/images/bounds.png and /dev/null differ diff --git a/assets/images/squiglink-giggle.svg b/assets/images/squiglink-giggle.svg new file mode 100644 index 00000000..6c000ade --- /dev/null +++ b/assets/images/squiglink-giggle.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/js/90inclusion.js b/assets/js/90inclusion.js new file mode 100644 index 00000000..0ca52e50 --- /dev/null +++ b/assets/js/90inclusion.js @@ -0,0 +1,63 @@ +function calculatePercentile(values, percentile) { + const sorted = [...values].sort((a, b) => a - b); + const index = Math.ceil((percentile / 100) * sorted.length) - 1; + return sorted[index]; +} + +function calculateInclusionWindowForFrequency(values) { + const lowerPercentile = calculatePercentile(values, 5); // 5th percentile + const upperPercentile = calculatePercentile(values, 95); // 95th percentile + + return { + lower: lowerPercentile, + upper: upperPercentile, + }; +} + +function calculateInclusionWindows(rawChannels) { + const numFrequencies = rawChannels[0].length; // 480 ppo + + const upperBounds = []; + const lowerBounds = []; + + for (let i = 0; i < numFrequencies; i++) { + const frequency = rawChannels[0][i][0]; // assumes all measurements have the same frequencies + const dbValues = rawChannels.map(channel => channel[i][1]); + + const inclusionWindow = calculateInclusionWindowForFrequency(dbValues); + upperBounds.push([frequency, inclusionWindow.upper]); + lowerBounds.push([frequency, inclusionWindow.lower]); + } + + // Prompt user to download bounds + function downloadFile(data, filename) { + const formattedData = data.map(item => item.join(', ')).join('\n'); + const blob = new Blob([formattedData], { type: 'text/plain' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + } + + // uncomment one at a time to download the lower and upper bounds, limitations of browser + // downloadFile(lowerBounds, 'lower_bounds.txt'); + // downloadFile(upperBounds, 'upper_bounds.txt'); + return [upperBounds, lowerBounds]; +} + +const inclusionName = " 90% Inclusion"; // edit this to change the name of the inclusion zone + +function setBoundsPhone(p, ch) { + let ninetyPercentInclusion = { + brand: p.brand, dispBrand: p.dispBrand, is90Bounds: true, + phone: p.dispName + inclusionName, fullName: p.fullName + inclusionName, + dispName: p.dispName + inclusionName, fileName: p.fullName + inclusionName, + rawChannels: ch, channels: ch, lr: ch, norm: p.norm, id: -69 + } + + return ninetyPercentInclusion; +} \ No newline at end of file diff --git a/assets/js/confidence_intervals.js b/assets/js/confidence_intervals.js new file mode 100644 index 00000000..30749fb8 --- /dev/null +++ b/assets/js/confidence_intervals.js @@ -0,0 +1,72 @@ +function calculateMean(values) { + const sum = values.reduce((acc, val) => acc + val, 0); + return sum / values.length; +} + +function calculateStandardDeviation(values, mean) { + const variance = values.reduce((acc, val) => acc + Math.pow(val - mean, 2), 0) / values.length; + return Math.sqrt(variance); +} + +function calculateConfidenceIntervalForFrequency(values) { + const mean = calculateMean(values); + const stdev = calculateStandardDeviation(values, mean); + const n = values.length; + + const marginOfError = 1.645 * (stdev / Math.sqrt(n)); + + return { + lower: mean - marginOfError, + upper: mean + marginOfError, + }; +} + +function calculateConfidenceIntervals(rawChannels) { + const numFrequencies = rawChannels[0].length; // 480 ppo + + const upperBounds = []; + const lowerBounds = []; + + for (let i = 0; i < numFrequencies; i++) { + const frequency = rawChannels[0][i][0]; // assumes all measurements have the same frequencies + const dBs = rawChannels.map(measurement => measurement[i][1]); + + const confidenceInterval = calculateConfidenceIntervalForFrequency(dBs); + upperBounds.push([frequency, confidenceInterval.upper]); + lowerBounds.push([frequency, confidenceInterval.lower]); + } + + console.log([upperBounds, lowerBounds]); + + // Prompt user to download upper bounds + function downloadFile(data, filename) { + const formattedData = data.map(item => item.join(', ')).join('\n'); + const blob = new Blob([formattedData], { type: 'text/plain' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + } + + // uncomment one at a time to download the lower and upper bounds, limitations of browser + // downloadFile(lowerBounds, 'lower_bounds.txt'); + // downloadFile(upperBounds, 'upper_bounds.txt'); + return [upperBounds, lowerBounds]; +} + + +function setBoundsPhone(p, ch) { + let inclusionName = " 90% Confidence Interval"; // edit this to change the name of the confidence interval + let confidencePhone = { + brand: p.brand, dispBrand: p.dispBrand, is90Bounds: true, + phone: p.dispName + inclusionName, fullName: p.fullName + inclusionName, + dispName: p.dispName + inclusionName, fileName: p.fullName + inclusionName, + rawChannels: ch, channels: ch, lr: ch, norm: p.norm, id: -69 + } + + return confidencePhone; +} diff --git a/assets/js/config.js b/assets/js/config.js index fcff0409..02a7f2e7 100644 --- a/assets/js/config.js +++ b/assets/js/config.js @@ -1,5 +1,5 @@ // Configuration options -const init_phones = [],// Optional. Which graphs to display on initial load. Note: Share URLs will override this set +const init_phones = ["Haruto 2024 Target", "AudioSense DT200"],// Optional. Which graphs to display on initial load. Note: Share URLs will override this set DIR = "data/", // Directory where graph files are stored default_channels = ["L","R"], // Which channels to display. Avoid javascript errors if loading just one channel per phone default_normalization = "dB", // Sets default graph normalization mode. Accepts "dB" or "Hz" @@ -8,26 +8,30 @@ const init_phones = [],// Optional. Which graphs to display on initial load. Not max_channel_imbalance = 5, // Channel imbalance threshold to show ! in the channel selector alt_layout = true, // Toggle between classic and alt layouts alt_sticky_graph = true, // If active graphs overflows the viewport, does the graph scroll with the page or stick to the viewport? - alt_animated = true, // Determines if new graphs are drawn with a 1-second animation, or appear instantly + alt_animated = false, // Determines if new graphs are drawn with a 1-second animation, or appear instantly alt_header = true, // Display a configurable header at the top of the alt layout + alt_header_new_tab = false, // Clicking alt_header links opens in new tab alt_tutorial = true, // Display a configurable frequency response guide below the graph + alt_augment = true, // Display augment card in phone list, e.g. review sore, shop link site_url = '/', // URL of your graph "homepage" share_url = true, // If true, enables shareable URLs - watermark_text = "HarutoHiroki", // Optional. Watermark appears behind graphs + watermark_text = "CrinGraph", // Optional. Watermark appears behind graphs watermark_image_url = "assets/images/haruto.svg", // Optional. If image file is in same directory as config, can be just the filename - page_title = "HarutoHiroki", // Optional. Appended to the page title if share URLs are enabled + rig_description = "clone IEC 711", // Optional. Labels the graph with a description of the rig used to make the measurement, e.g. "clone IEC 711" + page_title = "CrinGraph", // Optional. Appended to the page title if share URLs are enabled page_description = "View and compare frequency response graphs for earphones", accessories = true, // If true, displays specified HTML at the bottom of the page. Configure further below externalLinksBar = true, // If true, displays row of pill-shaped links at the bottom of the page. Configure further below expandable = false, // Enables button to expand iframe over the top of the parent page expandableOnly = false, // Prevents iframe interactions unless the user has expanded it. Accepts "true" or "false" OR a pixel value; if pixel value, that is used as the maximum width at which expandableOnly is used headerHeight = '0px', // Optional. If expandable=true, determines how much space to leave for the parent page header - darkModeButton = true, // Adds a "Dark Mode" button the main toolbar to let users set preference + themingEnabled = true, // Enable user-toggleable themes (dark mode, contrast mode) targetDashed = true, // If true, makes target curves dashed lines targetColorCustom = false, // If false, targets appear as a random gray value. Can replace with a fixed color value to make all targets the specified color, e.g. "black" + targetRestoreLastUsed = false, // Restore user's last-used target settings on load labelsPosition = "bottom-left", // Up to four labels will be grouped in a specified corner. Accepts "top-left," bottom-left," "bottom-right," and "default" stickyLabels = true, // "Sticky" labels - analyticsEnabled = false, // Enables Google Analytics 4 measurement of site usage + analyticsEnabled = true, // Enables Google Analytics 4 measurement of site usage extraEnabled = true, // Enable extra features extraUploadEnabled = true, // Enable upload function extraEQEnabled = true, // Enable parametic eq function @@ -36,25 +40,40 @@ const init_phones = [],// Optional. Which graphs to display on initial load. Not // Specify which targets to display const targets = [ - { type:"Reference", files:["Haruto", "Haruto 🅱️ass"] }, - { type:"Neutral", files:["Diffuse Field","Etymotic","Free Field","IEF Neutral"] }, - { type:"Reviewer", files:["Antdroid","Banbeucmas","HBB","Precogvision","Super Review 22","Super Review 21","Timmy","VSG"] }, - { type:"IEF Members", files:["Brownie", "Brownie Unsmoothened", "Listener (No Bass Shelf)", "Rennsport"]}, + { type:"Reference", files:["Haruto 2024","Haruto 2021"] }, + { type:"Neutral", files:["KEMAR DF","IEF Neutral 2023","Etymotic"] }, + { type:"Reviewer", files:["Antdroid","Banbeucmas","HBB","Precogvision","Super Review 22","Timmy","VSG"] }, { type:"Preference", files:["Harman IE 2019v2","Harman IE 2017v2","AutoEQ","Rtings","Sonarworks"] } ]; // Haruto's Addons -const preference_bounds = "assets/images/bounds.png", // Preference bounds image - PHONE_BOOK = "phone_book.json", // Path to phone book JSON file - default_DF_name = "Diffuse Field", // Default RAW DF name - dfBaseline = true, // If true, DF is used as baseline when custom df tilt is on - default_bass_shelf = 8, // Default Custom DF bass shelf value - default_tilt = -0.8, // Default Custom DF tilt value - default_ear = 0, // Default Custom DF ear gain value - default_treble = 0, // Default Custom DF treble gain value - tiltableTargets = ["Diffuse Field"]; // Targets that are allowed to be tilted +const preference_bounds_name = "Bounds", // Preference bounds name + preference_bounds_dir = "assets/pref_bounds/", // Preference bounds directory + preference_bounds_startup = false, // If true, preference bounds are displayed on startup + allowSquigDownload = false, // If true, allows download of measurement data + // PHONE_BOOK = "phone_book.json", // Path to phone book JSON file /* UNCOMMENT THIS IF YOU WANT TO MOVE PHONEBOOK OUTSIDE AGAIN */ + default_y_scale = "40db", // Default Y scale; values: ["20db", "30db", "40db", "50db", "crin"] + default_DF_name = "KEMAR DF", // Default RAW DF name + dfBaseline = true, // If true, DF is used as baseline when custom df tilt is on + default_bass_shelf = 8, // Default Custom DF bass shelf value + default_tilt = -0.8, // Default Custom DF tilt value + default_ear = 0, // Default Custom DF ear gain value + default_treble = 0, // Default Custom DF treble gain value + tiltableTargets = ["KEMAR DF"], // Targets that are allowed to be tilted + compTargets = ["KEMAR DF"], // Targets that are allowed to be used for compensation + allowCreatorSupport = true; // Allow the creator to have a button top right to support them +const harmanFilters = [ + { name: "Harman C1 2024 IE", tilt: -0.9, bass_shelf: 1, ear: 0, treble: 0.5 }, + { name: "Harman C2 2024 IE", tilt: -0.3, bass_shelf: .5, ear: -0.2, treble: 1 }, + { name: "Harman C3 2024 IE", tilt: -2.1, bass_shelf: 0, ear: 0, treble: 10 }, + { name: "Harman C4 2024 IE", tilt: -2.1, bass_shelf: 0, ear: 0.5, treble: 3.7 }, + { name: "Harman 2013 OE", tilt: 0, bass_shelf: 4.8, ear: 0, treble: -4.4 }, + { name: "Harman 2015 OE", tilt: 0, bass_shelf: 6.6, ear: 0, treble: -1.4 }, + { name: "Harman 2018 OE", tilt: 0, bass_shelf: 6.6, ear: -1.8, treble: -3 }, +] + // ************************************************************* // Functions to support config options set above; probably don't need to change these // ************************************************************* @@ -77,18 +96,22 @@ function watermark(svg) { .attrs({id:'wtext', x:0, y:80, "font-size":28, "text-anchor":"middle", "class":"graph-name"}) .text(watermark_text); } - - if ( preference_bounds ) { - wm.append("image") - .attrs({id:'bounds',x:-385, y:-365, width:770, height:770, "xlink:href":preference_bounds, "display":"none"}); + + if ( rig_description ) { + wm.append("text") + .attrs({x:380, y:-134, "font-size":8, "text-anchor":"end", "class":"rig-description", "style": "filter: var(--svg-filter);"}) + .text("Measured on: " + rig_description); } - - // Extra flair - svg.append("g") - .attr("opacity",0.2) - .append("text") - .attrs({x:765, y:314, "font-size":10, "text-anchor":"end", "class":"site_name"}) - .text("graphtool.harutohiroki.com"); + + let wmSq = svg.append("g") + .attr("opacity",0.2); + + wmSq.append("image") + .attrs({x:652, y:254, width:100, height:94, "class":"wm-squiglink-logo", "xlink:href":"assets/images/squiglink-giggle.svg"}); + + wmSq.append("text") + .attrs({x:641, y:314, "font-size":10, "transform":"translate(0,0)", "text-anchor":"end", "class":"wm-squiglink-address"}) + .text("squig.link/lab/harutohiroki"); } @@ -101,8 +124,6 @@ function tsvParse(fr) { .filter(t => !isNaN(t[0]) && !isNaN(t[1])); } - - // Apply stylesheet based layout options above function setLayout() { function applyStylesheet(styleSheet) { @@ -132,8 +153,8 @@ setLayout(); const // Short text, center-aligned, useful for a little side info, credits, links to measurement setup, etc. simpleAbout = ` -

This graph database is maintained by HarutoHiroki with frequency responses generated via an "IEC60318-4"-compliant ear simulator. This web software is based on the CrinGraph open source software project, with Audio Spectrum's definition source.

- `, +

This web software is based on a heavily modified version of the CrinGraph open source software project by HarutoHiroki, with Audio Spectrum's definition source.

+ `; // Which of the above variables to actually insert into the page whichAccessoriesToUse = simpleAbout; @@ -149,8 +170,24 @@ const linkSets = [ url: "https://iems.audiodiscourse.com/" }, { - name: "In-Ear Fidelity", - url: "https://crinacle.com/graphs/iems/graphtool/" + name: "Bad Guy", + url: "https://hbb.squig.link/" + }, + { + name: "Banbeucmas", + url: "https://banbeu.com/graph/tool/" + }, + { + name: "HypetheSonics", + url: "https://www.hypethesonics.com/iemdbc/" + }, + { + name: "Hangout.Audio", + url: "https://graph.hangout.audio/" + }, + { + name: "HarutoHiroki", + url: "https://graphtool.harutohiroki.com/" }, { name: "Precogvision", @@ -161,9 +198,13 @@ const linkSets = [ url: "https://squig.link/" }, { - name: "Timmy", + name: "Timmy (Gizaudio)", url: "https://timmyv.squig.link/" }, + { + name: "Rohsa", + url: "https://rohsa.gitlab.io/graphtool/" + }, ] }, { @@ -196,7 +237,7 @@ function setupGraphAnalytics() { if ( analyticsEnabled ) { const pageHead = document.querySelector("head"), graphAnalytics = document.createElement("script"), - graphAnalyticsSrc = "graphAnalytics.js"; + graphAnalyticsSrc = "assets/js/graphAnalytics.js"; graphAnalytics.setAttribute("src", graphAnalyticsSrc); pageHead.append(graphAnalytics); @@ -211,25 +252,14 @@ let headerLogoText = "HarutoHiroki", headerLogoImgUrl = "assets/images/haruto.svg", headerLinks = [ { - name: "Home", - url: "https://harutohiroki.com" - }, - { - name: "Ranking", - url: "https://docs.google.com/spreadsheets/d/1DZTac1BxCLdmS2J4DDQyvKSVUZGnNhz2r86qMGcs_Jo/edit?pli=1#gid=330037169" + name: "Sample", + url: "https://sample.com" }, { - name: "Discord", - url: "https://discord.harutohiroki.com" - }, - { - name: "Donate", - url: "https://www.paypal.me/harutohirokiUS" - }, -// { -// name: "GitHub", -// url: "https://github.com/HarutoHiroki" -// }, + name: "Sample External", + url: "https://sample.com", + external: true + } ]; // Source: https://www.teachmeaudio.com/mixing/techniques/audio-spectrum @@ -271,22 +301,7 @@ let tutorialDefinitions = [ } ] -// o == offset -// l == -// p == phone -// id == name -// lr == default curve -// v == valid channels -/* -let phoneObj = { - isTarget: false, - brand: "Average", - dispName: "All SPL", - phone: "All SPL", - fullName: "Average All SPL", - fileName: "Average All SPL", - rawChannels: "R", - isDynamic: false, - id: "AVG" - }; -*/ \ No newline at end of file +// Configure paths to extraEQ plugins here +let extraEQplugins = [ + './devicePEQ/plugin.js' // Path to one or more "extraEQ" plugins +]; \ No newline at end of file diff --git a/assets/js/config_hp.js b/assets/js/config_hp.js index 73fa241e..43754d8d 100644 --- a/assets/js/config_hp.js +++ b/assets/js/config_hp.js @@ -8,53 +8,68 @@ const init_phones = ["IEF Neutral Target"], // Optio max_channel_imbalance = 5, // Channel imbalance threshold to show ! in the channel selector alt_layout = true, // Toggle between classic and alt layouts alt_sticky_graph = true, // If active graphs overflows the viewport, does the graph scroll with the page or stick to the viewport? - alt_animated = true, // Determines if new graphs are drawn with a 1-second animation, or appear instantly + alt_animated = false, // Determines if new graphs are drawn with a 1-second animation, or appear instantly alt_header = true, // Display a configurable header at the top of the alt layout + alt_header_new_tab = false, // Clicking alt_header links opens in new tab alt_tutorial = true, // Display a configurable frequency response guide below the graph + alt_augment = true, // Display augment card in phone list, e.g. review sore, shop link site_url = '/', // URL of your graph "homepage" share_url = true, // If true, enables shareable URLs - watermark_text = "HarutoHiroki", // Optional. Watermark appears behind graphs - watermark_image_url = "assets/images/haruto.svg", // Optional. If image file is in same directory as config, can be just the filename - preference_bounds = "assets/images/bounds.png", // Optional. If png file is present, display bounds image - page_title = "HarutoHiroki", // Optional. Appended to the page title if share URLs are enabled + watermark_text = "CrinGraph", // Optional. Watermark appears behind graphs + watermark_image_url = "assets/images/haruto.svg", // Optional. If image file is in same directory as config, can be just the filename + rig_description = "clone IEC 711", // Optional. Labels the graph with a description of the rig used to make the measurement, e.g. "clone IEC 711" + page_title = "CrinGraph", // Optional. Appended to the page title if share URLs are enabled page_description = "View and compare frequency response graphs for headphones.", accessories = true, // If true, displays specified HTML at the bottom of the page. Configure further below externalLinksBar = true, // If true, displays row of pill-shaped links at the bottom of the page. Configure further below expandable = false, // Enables button to expand iframe over the top of the parent page expandableOnly = false, // Prevents iframe interactions unless the user has expanded it. Accepts "true" or "false" OR a pixel value; if pixel value, that is used as the maximum width at which expandableOnly is used headerHeight = '0px', // Optional. If expandable=true, determines how much space to leave for the parent page header - darkModeButton = true, // Adds a "Dark Mode" button the main toolbar to let users set preference + themingEnabled = true, // Enable user-toggleable themes (dark mode, contrast mode) targetDashed = true, // If true, makes target curves dashed lines targetColorCustom = false, // If false, targets appear as a random gray value. Can replace with a fixed color value to make all targets the specified color, e.g. "black" + targetRestoreLastUsed = false, // Restore user's last-used target settings on load labelsPosition = "bottom-left", // Up to four labels will be grouped in a specified corner. Accepts "top-left," bottom-left," "bottom-right," and "default" stickyLabels = true, // "Sticky" labels - analyticsEnabled = false, // Enables Google Analytics 4 measurement of site usage + analyticsEnabled = true, // Enables Google Analytics 4 measurement of site usage extraEnabled = true, // Enable extra features extraUploadEnabled = true, // Enable upload function extraEQEnabled = true, // Enable parametic eq function extraEQBands = 10, // Default EQ bands available extraEQBandsMax = 20, // Max EQ bands available num_samples = 5, // Number of samples to average for smoothing - scale_smoothing = 0.2, // Smoothing factor for scale transitions - PHONE_BOOK = "phone_book_hp.json", // Phone book file path & name - REWenabled = false, // Enable REW import function - default_bass_shelf = 8, // Default Custom DF bass shelf value - default_tilt = -0.8, // Default Custom DF tilt value - default_ear = 0, // Default Custom DF ear gain value - default_treble = 0, // Default Custom DF treble gain value - default_DF_name = "KEMAR DF", // Default RAW DF name - dfBaseline = false, // If true, DF is used as baseline when custom df tilt is on - tiltableTargets = ["KEMAR DF"]; // Targets that are allowed to be tilted + scale_smoothing = 0.2; // Smoothing factor for scale transitions // Specify which targets to display const targets = [ { type:"Neutral", files:["KEMAR DF", "IEF Neutral"] }, - { type:"Community", files:["Listener Tilt 711"] }, { type:"Preference", files:["Harman Combined", "Harman 2018 OE", "Harman 2015 OE", "Harman 2013 OE"] } ]; +// Haruto's Addons +const preference_bounds_name = "Bounds", // Preference bounds name + preference_bounds_dir = "assets/pref_bounds/", // Preference bounds directory + preference_bounds_startup = false, // If true, preference bounds are displayed on startup + allowSquigDownload = false, // If true, allows download of measurement data + // PHONE_BOOK = "phone_book_hp.json", // Path to phone book JSON file /* UNCOMMENT THIS IF YOU WANT TO MOVE PHONEBOOK OUTSIDE AGAIN */ + default_y_scale = "40db", // Default Y scale; values: ["20db", "30db", "40db", "50db", "crin"] + default_DF_name = "KEMAR DF", // Default RAW DF name + dfBaseline = true, // If true, DF is used as baseline when custom df tilt is on + default_bass_shelf = 8, // Default Custom DF bass shelf value + default_tilt = -0.8, // Default Custom DF tilt value + default_ear = 0, // Default Custom DF ear gain value + default_treble = 0, // Default Custom DF treble gain value + tiltableTargets = ["KEMAR DF"], // Targets that are allowed to be tilted + compTargets = ["KEMAR DF"], // Targets that are allowed to be used for compensation + allowCreatorSupport = true; // Allow the creator to have a button top right to support them +const harmanFilters = [ + { name: "Harman 2013 OE", tilt: 0, bass_shelf: 4.8, ear: 0, treble: -4.4 }, + { name: "Harman 2015 OE", tilt: 0, bass_shelf: 6.6, ear: 0, treble: -1.4 }, + { name: "Harman 2018 OE", tilt: 0, bass_shelf: 6.6, ear: -1.8, treble: -3 }, +] + // ************************************************************* // Functions to support config options set above; probably don't need to change these // ************************************************************* @@ -77,18 +92,22 @@ function watermark(svg) { .attrs({id:'wtext', x:0, y:80, "font-size":28, "text-anchor":"middle", "class":"graph-name"}) .text(watermark_text); } - - if ( preference_bounds ) { - wm.append("image") - .attrs({id:'bounds',x:-385, y:-365, width:770, height:770, "xlink:href":preference_bounds, "display":"none"}); + + if ( rig_description ) { + wm.append("text") + .attrs({x:380, y:-134, "font-size":8, "text-anchor":"end", "class":"rig-description", "style": "filter: var(--svg-filter);"}) + .text("Measured on: " + rig_description); } - - // Extra flair - svg.append("g") - .attr("opacity",0.2) - .append("text") - .attrs({x:765, y:314, "font-size":10, "text-anchor":"end", "class":"site_name"}) - .text("graphtool.harutohiroki.com"); + + let wmSq = svg.append("g") + .attr("opacity",0.2); + + wmSq.append("image") + .attrs({x:652, y:254, width:100, height:94, "class":"wm-squiglink-logo", "xlink:href":"assets/images/squiglink-giggle.svg"}); + + wmSq.append("text") + .attrs({x:641, y:314, "font-size":10, "transform":"translate(0,0)", "text-anchor":"end", "class":"wm-squiglink-address"}) + .text("squig.link/lab/harutohiroki"); } @@ -101,8 +120,6 @@ function tsvParse(fr) { .filter(t => !isNaN(t[0]) && !isNaN(t[1])); } - - // Apply stylesheet based layout options above function setLayout() { function applyStylesheet(styleSheet) { @@ -132,8 +149,8 @@ setLayout(); const // Short text, center-aligned, useful for a little side info, credits, links to measurement setup, etc. simpleAbout = ` -

This web software is based on the CrinGraph open source software project. Audio Spectrum definition source.

- `, +

This web software is based on a heavily modified version of the CrinGraph open source software project, with Audio Spectrum's definition source.

+ `; // Which of the above variables to actually insert into the page whichAccessoriesToUse = simpleAbout; @@ -161,8 +178,12 @@ const linkSets = [ url: "https://www.hypethesonics.com/iemdbc/" }, { - name: "In-Ear Fidelity", - url: "https://crinacle.com/graphs/iems/graphtool/" + name: "Hangout.Audio", + url: "https://graph.hangout.audio/" + }, + { + name: "HarutoHiroki", + url: "https://graphtool.harutohiroki.com/" }, { name: "Precogvision", @@ -193,6 +214,10 @@ const linkSets = [ name: "In-Ear Fidelity", url: "https://crinacle.com/graphs/headphones/graphtool/" }, + { + name: "Listener", + url: "https://listener800.github.io/" + }, { name: "Super* Review", url: "https://squig.link/hp.html" @@ -208,7 +233,7 @@ function setupGraphAnalytics() { if ( analyticsEnabled ) { const pageHead = document.querySelector("head"), graphAnalytics = document.createElement("script"), - graphAnalyticsSrc = "graphAnalytics.js"; + graphAnalyticsSrc = "assets/js/graphAnalytics.js"; graphAnalytics.setAttribute("src", graphAnalyticsSrc); pageHead.append(graphAnalytics); @@ -223,25 +248,14 @@ let headerLogoText = "HarutoHiroki", headerLogoImgUrl = "assets/images/haruto.svg", headerLinks = [ { - name: "Home", - url: "https://harutohiroki.com" - }, - { - name: "Ranking", - url: "https://docs.google.com/spreadsheets/d/1DZTac1BxCLdmS2J4DDQyvKSVUZGnNhz2r86qMGcs_Jo/edit?pli=1#gid=330037169" - }, - { - name: "Discord", - url: "https://discord.harutohiroki.com" + name: "Sample", + url: "https://sample.com" }, { - name: "Donate", - url: "https://www.paypal.me/harutohirokiUS" - }, -// { -// name: "GitHub", -// url: "https://github.com/HarutoHiroki" -// }, + name: "Sample External", + url: "https://sample.com", + external: true + } ]; // Source: https://www.teachmeaudio.com/mixing/techniques/audio-spectrum @@ -274,11 +288,16 @@ let tutorialDefinitions = [ { name: 'Presence', width: '5.9%', - description: 'The presence range is responsible for the clarity and definition of a sound. Over-boosting can cause an irritating, harsh sound. Cutting in this range makes the sound more distant and transparent.' + description: 'The Presence range is responsible for the clarity and definition of a sound. Over-boosting can cause an irritating, harsh sound. Cutting in this range makes the sound more distant and transparent.' }, { - name: 'Brilliance', + name: 'Treble', width: '17.4%', - description: 'The brilliance range is composed entirely of harmonics and is responsible for sparkle and air of a sound. Over boosting in this region can accentuate hiss and cause ear fatigue.' + description: 'The Treble range is composed entirely of harmonics and is responsible for sparkle and air of a sound. Over boosting in this region can accentuate hiss and cause ear fatigue.' } -] \ No newline at end of file +] + +// Configure paths to extraEQ plugins here +let extraEQplugins = [ + './devicePEQ/plugin.js' // Path to one or more "extraEQ" plugins +]; \ No newline at end of file diff --git a/assets/js/devicePEQ/fiioUsbHidHandler.js b/assets/js/devicePEQ/fiioUsbHidHandler.js new file mode 100644 index 00000000..c884516d --- /dev/null +++ b/assets/js/devicePEQ/fiioUsbHidHandler.js @@ -0,0 +1,422 @@ +// +// Copyright 2024 : Pragmatic Audio +// +// Define the shared logic for JadeAudio / SnowSky / FiiO devices - Each manufacturer will have slightly +// different code so best to each have a separate 'module' + +const PEQ_FILTER_COUNT = 0x18; // 24 in hex +const PEQ_GLOBAL_GAIN = 0x17; // 23 in hex +const PEQ_FILTER_PARAMS = 0x15; // 21 in hex +const PEQ_PRESET_SWITCH = 0x16; // 22 in hex +const PEQ_SAVE_TO_DEVICE = 0x19; // 25 in hex +const PEQ_RESET_DEVICE = 0x1B; // 27 in hex +const PEQ_RESET_ALL = 0x1C; // 28 in hex + +// Note these have different headers +const PEQ_FIRMWARE_VERSION = 0x0B; // 11 in hex +const PEQ_NAME_DEVICE = 0x30; // 48 in hex + +const SET_HEADER1 = 0xAA; +const SET_HEADER2 = 0x0A; +const GET_HEADER1 = 0xBB; +const GET_HEADER2 = 0x0B; +const END_HEADERS = 0xEE; + +export const fiioUsbHID = (function () { + + const getCurrentSlot = async (deviceDetails) => { + var device = deviceDetails.rawDevice; + var reportId = getFiioReportId(deviceDetails); + try { + let currentSlot = -99; + + device.oninputreport = async (event) => { + const data = new Uint8Array(event.data.buffer); + console.log(`USB Device PEQ: getCurrentSlot() onInputReport received data:`, data); + if (data[0] === GET_HEADER1 && data[1] === GET_HEADER2) { + switch (data[4]) { + case PEQ_PRESET_SWITCH: + currentSlot = handleEqPreset(data, deviceDetails); + break; + default: + console.log("USB Device PEQ: Unhandled data type:", data[4], data); + } + } + }; + + await getPresetPeq(device, reportId); + + // Wait at most 10 seconds for filters to be populated + const result = await waitForFilters(() => { + return currentSlot > -99 + }, device, 10000, (device) => ( + currentSlot + )); + + return result; + } catch (error) { + console.error("Failed to pull data from FiiO Device:", error); + throw error; + } + }; + + const pushToDevice = async (deviceDetails, phoneObj, slot, preamp_gain, filters) => { + try { + var device = deviceDetails.rawDevice; + var reportId = getFiioReportId(deviceDetails); + + // FiiO devices will automatically cut the max SPL by the maxGain (typically -12) + // So, we can safely apply a +12 gain - the larged preamp_gain needed + // .e.g. if we need to +5dB for a filter then we can still make the globalGain 7dB + await setGlobalGain(device, deviceDetails.modelConfig.maxGain + preamp_gain, reportId); + const maxFilters = deviceDetails.modelConfig.maxFilters; + const maxFiltersToUse = Math.min(filters.length, maxFilters); + await setPeqCounter(device, maxFiltersToUse, reportId); + await new Promise(resolve => setTimeout(resolve, 100)); // Added 100ms delay + + for (let filterIdx = 0; filterIdx < maxFiltersToUse; filterIdx++) { + const filter = filters[filterIdx]; + var gain = 0; // If disabled we still need to reset to 0 gain as previous gain value will + // still be active + if (!filter.disabled) { + gain = filter.gain; + } + await setPeqParams(device, filterIdx, filter.freq, gain, filter.q, convertFromFilterType(filter.type), reportId); + } + await new Promise(resolve => setTimeout(resolve, 100)); // Added 100ms delay + + saveToDevice(device, slot, reportId); + + console.log("PEQ filters pushed successfully."); + + if (deviceDetails.modelConfig.disconnectOnSave) { + return true; // Disconnect + } + return false; + + } catch (error) { + console.error("Failed to push data to FiiO Device:", error); + throw error; + } + }; + + const pullFromDevice = async (deviceDetails, slot) => { + try { + const filters = []; + let peqCount = 0; + let globalGain = 0; + let currentSlot = 0; + var device = deviceDetails.rawDevice; + var reportId = getFiioReportId(deviceDetails); + + device.oninputreport = async (event) => { + const data = new Uint8Array(event.data.buffer); + console.log(`USB Device PEQ: pullFromDevice() onInputReport received data:`, data); + if (data[0] === GET_HEADER1 && data[1] === GET_HEADER2) { + switch (data[4]) { + case PEQ_FILTER_COUNT: + peqCount = handlePeqCounter(data, device, reportId); + break; + case PEQ_FILTER_PARAMS: + handlePeqParams(data, device, filters); + break; + case PEQ_GLOBAL_GAIN: + globalGain = handleGain(data[6], data[7]); + console.log(`USB Device PEQ: Global gain received: ${globalGain}dB`); + break; + case PEQ_PRESET_SWITCH: + currentSlot = handleEqPreset(data, deviceDetails); + break; + case PEQ_SAVE_TO_DEVICE: + savedEQ(data, device); + break; + default: + console.log("USB Device PEQ: Unhandled data type:", data[4], data); + } + } + }; + + await getPresetPeq(device, reportId); + await getPeqCounter(device, reportId); + await getGlobalGain(device, reportId); + + // Wait at most 10 seconds for filters to be populated + const result = await waitForFilters(() => { + return filters.length == peqCount + }, device, 10000, (device) => ({ + filters: filters, + globalGain: globalGain + })); + + return result; + } catch (error) { + console.error("Failed to pull data from FiiO Device:", error); + throw error; + } + } + + const enablePEQ = async (deviceDetails, enable, slotId) => { + + var device = deviceDetails.rawDevice + var reportId = getFiioReportId(deviceDetails); + + if (enable) { // take the slotId we are given and switch to it + await setPresetPeq(device, slotId, reportId); + } else { + await setPresetPeq(device, deviceDetails.modelConfig.maxFilters, reportId); + } + } + return { + pushToDevice, + pullFromDevice, + getCurrentSlot, + enablePEQ + }; +})(); + + +// Private Helper Functions + +/** + * Gets the appropriate reportId for a FiiO device based on its product name or modelConfig. + * @param {Object} device - The device object. + * @param {Object} [deviceDetails] - Optional deviceDetails object containing modelConfig. + * @returns {number} - The reportId to use for the device. + */ +function getFiioReportId(deviceDetails) { + // If deviceDetails is provided and has a modelConfig with reportId, use that + if (deviceDetails && deviceDetails.modelConfig && deviceDetails.modelConfig.reportId !== undefined) { + console.log(`Using reportId ${deviceDetails.modelConfig.reportId} from modelConfig for ${deviceDetails.model || "unknown device"}`); + return deviceDetails.modelConfig.reportId; + } + + // Default reportId for FiiO devices is 7 + console.log(`Using default reportId 7 for ${deviceDetails.model || "unknown device"}`); + return 7; +} + +async function setPeqParams(device, filterIndex, fc, gain, q, filterType, reportId) { + const [frequencyLow, frequencyHigh] = splitUnsignedValue(fc); + const [gainLow, gainHigh] = fiioGainBytesFromValue(gain); + const qFactorValue = Math.round(q * 100); + const [qFactorLow, qFactorHigh] = splitUnsignedValue(qFactorValue); + + const packet = [ + SET_HEADER1, SET_HEADER2, 0, 0, PEQ_FILTER_PARAMS, 8, + filterIndex, gainLow, gainHigh, + frequencyLow, frequencyHigh, + qFactorLow, qFactorHigh, + filterType, 0, END_HEADERS + ]; + + const data = new Uint8Array(packet); + console.log(`USB Device PEQ: setPeqParams() sending filter ${filterIndex} - Freq: ${fc}Hz, Gain: ${gain}dB, Q: ${q}, Type: ${filterType}`, data); + await device.sendReport(reportId, data); +} + +async function setPresetPeq(device, presetId, reportId) { // Default to 0 if not specified + const packet = [ + SET_HEADER1, SET_HEADER2, 0, 0, PEQ_PRESET_SWITCH, 1, + presetId, 0, END_HEADERS + ]; + + const data = new Uint8Array(packet); + console.log(`USB Device PEQ: setPresetPeq() switching to preset ${presetId}`, data); + await device.sendReport(reportId, data); +} + +async function setGlobalGain(device, gain, reportId) { + const globalGain = Math.round(gain * 10); + const gainBytes = toBytePair(globalGain); + + const packet = [ + SET_HEADER1, SET_HEADER2, 0, 0, PEQ_GLOBAL_GAIN, 2, + gainBytes[1], gainBytes[0], 0, END_HEADERS + ]; + + const data = new Uint8Array(packet); + console.log(`USB Device PEQ: setGlobalGain() setting global gain to ${gain}dB`, data); + await device.sendReport(reportId, data); +} + +async function setPeqCounter(device, counter, reportId) { + const packet = [ + SET_HEADER1, SET_HEADER2, 0, 0, PEQ_FILTER_COUNT, 1, + counter, 0, END_HEADERS + ]; + + const data = new Uint8Array(packet); + console.log(`USB Device PEQ: setPeqCounter() setting filter count to ${counter}`, data); + await device.sendReport(reportId, data); +} + +function convertFromFilterType(filterType) { + const mapping = {"PK": 0, "LSQ": 1, "HSQ": 2}; + return mapping[filterType] !== undefined ? mapping[filterType] : 0; +} + +function convertToFilterType(datum) { + switch (datum) { + case 0: + return "PK"; + case 1: + return "LSQ"; + case 2: + return "HSQ"; + default: + return "PK"; + } +} + +function toBytePair(value) { + return [ + value & 0xFF, + (value & 0xFF00) >> 8 + ]; +} + +function splitSignedValue(value) { + const signedValue = value < 0 ? value + 65536 : value; + return [ + (signedValue >> 8) & 0xFF, + signedValue & 0xFF + ]; +} + +function splitUnsignedValue(value) { + return [ + (value >> 8) & 0xFF, + value & 0xFF + ]; +} + +function combineBytes(lowByte, highByte) { + return (lowByte << 8) | highByte; +} + +function getGlobalGain(device, reportId) { + const packet = [GET_HEADER1, GET_HEADER2, 0, 0, PEQ_GLOBAL_GAIN, 0, 0, END_HEADERS]; + const data = new Uint8Array(packet); + console.log("getGlobalGain() Send data:", data); + device.sendReport(reportId, data); +} + +function getPeqCounter(device, reportId) { + const packet = [GET_HEADER1, GET_HEADER2, 0, 0, PEQ_FILTER_COUNT, 0, 0, END_HEADERS]; + const data = new Uint8Array(packet); + console.log("getPeqCounter() Send data:", data); + device.sendReport(reportId, data); +} + +function getPeqParams(device, filterIndex, reportId) { + const packet = [GET_HEADER1, GET_HEADER2, 0, 0, PEQ_FILTER_PARAMS, 1, filterIndex, 0, END_HEADERS]; + const data = new Uint8Array(packet); + console.log("getPeqParams() Send data:", data); + device.sendReport(reportId, data); +} + +function getPresetPeq(device, reportId) { + const packet = [GET_HEADER1, GET_HEADER2, 0, 0, PEQ_PRESET_SWITCH, 0, 0, END_HEADERS]; + const data = new Uint8Array(packet); + console.log("getPresetPeq() Send data:", data); + device.sendReport(reportId, data); +} + +function saveToDevice(device, slotId, reportId) { + const packet = [SET_HEADER1, SET_HEADER2, 0, 0, PEQ_SAVE_TO_DEVICE, 1, slotId, 0, END_HEADERS]; + const data = new Uint8Array(packet); + console.log(`USB Device PEQ: saveToDevice() using reportId ${reportId} for slot ${slotId}`, data); + device.sendReport(reportId, data); +} + +function handlePeqCounter(data, device, reportId) { + let peqCount = data[6]; + console.log("***********oninputreport peq counter=", peqCount); + if (peqCount > 0) { + processPeqCount(device, peqCount, reportId); + } + return peqCount; +} + +function processPeqCount(device, peqCount, reportId) { + console.log("PEQ Counter:", peqCount); + + // Fetch individual PEQ settings based on count + for (let i = 0; i < peqCount; i++) { + getPeqParams(device, i, reportId); + } +} + +function handlePeqParams(data, device, filters) { + const filter = data[6]; + const gain = handleGain(data[7], data[8]); + const frequency = combineBytes(data[9], data[10]); + const qFactor = (combineBytes(data[11], data[12])) / 100 || 1; + const filterType = convertToFilterType(data[13]); + + console.log(`Filter ${filter}: Gain=${gain}, Frequency=${frequency}, Q=${qFactor}, Type=${filterType}`); + + filters[filter] = { + type: filterType, + freq: frequency, + q: qFactor, + gain: gain, + disabled: (gain || frequency || qFactor) ? false : true // Disable filter if 0 value found + }; +} + + +function handleGain(lowByte, highByte) { + let r = combineBytes(lowByte, highByte); + const gain = r & 32768 ? (r = (r ^ 65535) + 1, -r / 10) : r / 10; + return gain; +} + +function fiioGainBytesFromValue(e) { + let t = e * 10; + t < 0 && (t = (Math.abs(t) ^ 65535) + 1); + const r = t >> 8 & 255, + n = t & 255; + return [r, n] +} + +function handleEqPreset(data, deviceDetails) { + const presetId = data[6]; + console.log("EQ Preset ID:", presetId); + + if (presetId === deviceDetails.modelConfig.disabledPresetId) { + return -1; // with JA11 slot 4 == Off + } + // Handle preset switch if necessary + return presetId; +} + +function savedEQ(data, device) { + const slotId = data[6]; + console.log("EQ Slot ID:", slotId); + // Handle slot enablement if necessary +} + + +// Utility function to wait for a condition or timeout +function waitForFilters(condition, device, timeout, callback) { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + if (!condition()) { + console.warn("Timeout reached before data returned?"); + reject(callback(device)); + } else { + resolve(callback(device)); + } + }, timeout); + + // Check every 100 milliseconds if everything is ready based on condition method !! + const interval = setInterval(() => { + if (condition()) { + clearTimeout(timer); + clearInterval(interval); + resolve(callback(device)); + } + }, 100); + }); +} diff --git a/assets/js/devicePEQ/fiioUsbSerialHandler.js b/assets/js/devicePEQ/fiioUsbSerialHandler.js new file mode 100644 index 00000000..0ecb8983 --- /dev/null +++ b/assets/js/devicePEQ/fiioUsbSerialHandler.js @@ -0,0 +1,346 @@ +// fiioUsbSerialHandler.js +// Pragmatic Audio - Handler for FiiO USB Serial EQ Control + +// Header constants - matching fiioUsbHidHandler.js for compatibility +const SET_HEADER1 = 0xAA; +const SET_HEADER2 = 0x0A; +const GET_HEADER1 = 0xBB; +const GET_HEADER2 = 0x0B; +const END_HEADERS = 0xEE; + +// PEQ command constants - matching fiioUsbHidHandler.js for compatibility +const PEQ_FILTER_COUNT = 0x18; // 24 in hex +const PEQ_GLOBAL_GAIN = 0x17; // 23 in hex +const PEQ_FILTER_PARAMS = 0x15; // 21 in hex +const PEQ_PRESET_SWITCH = 0x16; // 22 in hex +const PEQ_SAVE_TO_DEVICE = 0x19; // 25 in hex +const PEQ_RESET_DEVICE = 0x1B; // 27 in hex +const PEQ_RESET_ALL = 0x1C; // 28 in hex + +// Note these have different headers +const PEQ_FIRMWARE_VERSION = 0x0B; // 11 in hex +const PEQ_NAME_DEVICE = 0x30; // 48 in hex + +class SerialDeviceError extends Error {} + +export const fiioUsbSerial = (function () { + + // Helper function to send data and listen for response using device streams + let __serialIsSending = false; + async function sendReportAndListen(device, data, endByte = END_HEADERS) { + if (__serialIsSending) throw new Error("Port is busy"); + __serialIsSending = true; + + const port = device.rawDevice; + if (!port || !port.readable || !port.writable) { + __serialIsSending = false; + throw new Error("Serial port not available"); + } + + let writer = null; + let reader = null; + const buffer = []; + const overallTimeoutMs = 5000; + const startedAt = Date.now(); + let timerId = null; + + // Track expected total frame length once we have the header and LEN byte + let expectedTotal = null; // bytes + + try { + // Acquire writer per call, write, then release (replicating reference write()) + writer = port.writable.getWriter(); + await writer.write(data); + try { writer.releaseLock(); } catch (_) {} + + // Acquire reader per call and read until done/terminator/timeout (replicating reference read()) + reader = port.readable.getReader(); + + await Promise.all([ + Promise.resolve(), + (async () => { + while (true) { + const elapsed = Date.now() - startedAt; + if (elapsed >= overallTimeoutMs) return; // stop reading on overall timeout + + const remaining = overallTimeoutMs - elapsed; + const race = await Promise.race([ + reader.read(), + new Promise((_, reject) => { + timerId = setTimeout(() => { + // cancel in-flight read to unblock + reader.cancel().catch(() => {}); + reject(new Error("Timeout")); + }, remaining); + }) + ]); + + const { value, done } = race; + if (done) break; + const chunk = Array.from(value || []); + if (chunk.length > 0) { + buffer.push(...chunk); + + // Determine expected total frame length once we have at least 6 bytes + if (expectedTotal == null && buffer.length >= 6) { + const len = buffer[5] || 0; // LEN field + // Frame layout: [H1,H2,0,0,CMD,LEN, (LEN data...), 0, END] + expectedTotal = 6 + len + 2; // bytes + } + + // If we already know how long the frame should be, only stop once all bytes are in + if (expectedTotal != null && buffer.length >= expectedTotal) { + // Only accept if the last byte is the terminator; otherwise keep reading + if (buffer[expectedTotal - 1] === endByte) { + // Trim any extra bytes beyond expectedTotal (shouldn't happen often) + buffer.splice(expectedTotal); + return; + } + } + } + clearTimeout(timerId); // clear per-iteration timer + timerId = null; + } + })() + ]); + + return buffer.length > 0 ? new Uint8Array(buffer) : new Uint8Array(0); + } catch (e) { + if (e && e.message === "Timeout") { + // On timeout, return empty buffer like original + return new Uint8Array(0); + } + throw e; + } finally { + if (timerId) clearTimeout(timerId); + try { if (reader) reader.releaseLock(); } catch (_) {} + __serialIsSending = false; + } + } + + + // Helper function to create command bytes + function createCommandPacket(header1, header2, command, data = []) { + const packet = [header1, header2, 0, 0, command]; + if (data.length > 0) { + packet.push(data.length); + packet.push(...data); + // Reserved byte before terminator in examples + packet.push(0); + } else { + packet.push(0, 0); + } + packet.push(END_HEADERS); // End header + return new Uint8Array(packet); + } + + // Helper function to convert string to byte array + function stringToByteArray(str) { + return Array.from(str, char => char.charCodeAt(0)); + } + + // Command functions for FiiO USB protocol - matching HID handler constants + const createGetEqCountCmd = () => createCommandPacket(GET_HEADER1, GET_HEADER2, PEQ_FILTER_COUNT); + const createSetEqBandWithNameCmd = (bandIndex, name) => createCommandPacket(SET_HEADER1, SET_HEADER2, PEQ_NAME_DEVICE, [bandIndex, ...stringToByteArray(name.padEnd(8, "\0").slice(0, 8))]); + const createGetEqBandCmd = bandIndex => createCommandPacket(GET_HEADER1, GET_HEADER2, PEQ_FILTER_PARAMS, [bandIndex]); + const createSetEqBandCmd = bandIndex => createCommandPacket(SET_HEADER1, SET_HEADER2, PEQ_FILTER_PARAMS, [bandIndex]); + const createGetEqPresetCmd = () => createCommandPacket(GET_HEADER1, GET_HEADER2, PEQ_PRESET_SWITCH); + const createGetGlobalGainCmd = () => createCommandPacket(GET_HEADER1, GET_HEADER2, PEQ_GLOBAL_GAIN); + const createSetGlobalGainCmd = gain => { + // Encode gain in tenths (0.1 dB) as two bytes (signed big-endian) + const value = Math.round(gain * 10); + const v16 = ((value % 0x10000) + 0x10000) % 0x10000; + const hi = (v16 >> 8) & 0xFF; + const lo = v16 & 0xFF; + return createCommandPacket(SET_HEADER1, SET_HEADER2, PEQ_GLOBAL_GAIN, [hi, lo]); + }; + const createSetEqPresetCmd = presetValue => createCommandPacket(SET_HEADER1, SET_HEADER2, PEQ_PRESET_SWITCH, [presetValue & 0xFF]); + const createGetEqStatusCmd = () => createCommandPacket(GET_HEADER1, GET_HEADER2, PEQ_FILTER_COUNT); + const createGetDeviceInfoCmd = () => createCommandPacket(GET_HEADER1, GET_HEADER2, PEQ_FIRMWARE_VERSION); + const createResetEqCmd = () => createCommandPacket(SET_HEADER1, SET_HEADER2, PEQ_RESET_DEVICE); + + // Helper functions for data parsing + function parseGain(byte1, byte2) { + // Signed 16-bit big-endian, tenths (0.1 dB units) + let v = ((byte1 << 8) | byte2) & 0xFFFF; + if (v & 0x8000) v = v - 0x10000; + return v / 10.0; + } + + function parseQValue(byte1, byte2) { + // Unsigned 16-bit big-endian, hundredths + const v = ((byte1 << 8) | byte2) & 0xFFFF; + return v / 100.0; + } + + // Encoding helpers + function encodeSignedHundredths(value) { + // For gain: device uses tenths (0.1 dB) + const v = Math.round(value * 10); + const v16 = ((v % 0x10000) + 0x10000) % 0x10000; + return [(v16 >> 8) & 0xFF, v16 & 0xFF]; + } + function encodeUnsignedHundredths(value) { + const v = Math.round(value * 100); + const v16 = v & 0xFFFF; + return [(v16 >> 8) & 0xFF, v16 & 0xFF]; + } + + // Full-band set command: [index, gain_hi, gain_lo, freq_hi, freq_lo, q_hi, q_lo, type] + function createSetEqBandCommand(bandIndex, frequency, gain, qValue, filterType) { + const [gHi, gLo] = encodeSignedHundredths(gain); + const freq = Math.round(frequency) & 0xFFFF; + const fHi = (freq >> 8) & 0xFF; + const fLo = freq & 0xFF; + const [qHi, qLo] = encodeUnsignedHundredths(qValue); + const data = [bandIndex & 0xFF, gHi, gLo, fHi, fLo, qHi, qLo, (filterType ?? 0) & 0xFF]; + return createCommandPacket(SET_HEADER1, SET_HEADER2, PEQ_FILTER_PARAMS, data); + } + + // EQ switch (on/off) + const createSetEqSwitchCommand = (enabled) => createCommandPacket(SET_HEADER1, SET_HEADER2, 0x1A, [enabled ? 1 : 0]); + + // Set preset (pre = 0x16) + const createSetEqPreCommand = (presetValue) => createCommandPacket(SET_HEADER1, SET_HEADER2, PEQ_PRESET_SWITCH, [presetValue & 0xFF]); + + // Main handler functions + async function getCurrentSlot(deviceDetails) { + try { + // Get current EQ preset + const cmd = createGetEqPresetCmd(); + try { console.debug('[FiiO Serial] SEND get preset:', Array.from(cmd)); } catch (_) {} + const response = await sendReportAndListen(deviceDetails, cmd); + try { console.debug('[FiiO Serial] RECV get preset:', Array.from(response)); } catch (_) {} + if (response.length > 6) { + return response[6]; // Assuming preset ID is at byte 6 + } + return 0; + } catch (error) { + console.error("Failed to get current slot:", error); + throw error; + } + } + + async function pullFromDevice(deviceDetails, slot) { + try { + // Get EQ count + const countResponse = await sendReportAndListen(deviceDetails, createGetEqCountCmd()); + let eqCount = 0; + if (countResponse.length > 6) { + eqCount = countResponse[6]; + if (eqCount === 0) { + throw new Error("No PEQ band found."); + } + } + + // Get global gain + const gainResponse = await sendReportAndListen(deviceDetails, createGetGlobalGainCmd()); + let eqGlobalGain = 0; + if (gainResponse.length > 7) { + eqGlobalGain = parseGain(gainResponse[6], gainResponse[7]); + } + + // Get EQ bands + const filters = []; + for (let i = 0; i < eqCount; i++) { + const bandResponse = await sendReportAndListen(deviceDetails, createGetEqBandCmd(i)); + if (bandResponse.length >= 14) { + // Data layout: [index, gain_hi, gain_lo, freq_hi, freq_lo, q_hi, q_lo, type] + const gain = parseGain(bandResponse[7], bandResponse[8]); + const frequency = (bandResponse[9] << 8) | bandResponse[10]; + const qValue = parseQValue(bandResponse[11], bandResponse[12]); + const filterType = bandResponse[13]; + + // Convert FiiO filter type to standard format + let type = "PK"; + switch (filterType) { + case 0: type = "PK"; break; + case 1: type = "LSQ"; break; + case 2: type = "HSQ"; break; + default: type = "PK"; break; + } + + filters.push({ + freq: frequency, + gain: gain, + q: qValue, + type: type + }); + } + } + + // Sort filters by frequency + filters.sort((a, b) => a.freq - b.freq); + + return { + filters: filters, + globalGain: eqGlobalGain + }; + + } catch (error) { + console.error("Failed to pull data from FiiO device:", error); + throw error; + } + } + + async function pushToDevice(deviceDetails, phoneObj, slot, globalGain, filters) { + try { + // Set global gain + await sendReportAndListen(deviceDetails, createSetGlobalGainCmd(globalGain)); + + // Set each EQ band + for (let i = 0; i < filters.length; i++) { + const filter = filters[i]; + + // Convert filter type to FiiO format + let filterType = 0; // Default to peaking (PK) + switch (filter.type) { + case "PK": filterType = 0; break; + case "LSQ": filterType = 1; break; + case "HSQ": filterType = 2; break; + } + + await sendReportAndListen(deviceDetails, + createSetEqBandCommand(i, filter.freq, filter.gain, filter.q, filterType) + ); + } + + console.log("FiiO settings applied successfully"); + // Return whether we should disconnect after saving, mirroring HID handler behavior + return !!(deviceDetails && deviceDetails.modelConfig && deviceDetails.modelConfig.disconnectOnSave); + + } catch (error) { + console.error("Failed to push data to FiiO device:", error); + throw error; + } + } + + async function enablePEQ(deviceDetails, enable, slotId) { + try { + if (enable) { + // Enable EQ and set to specified slot/preset + await sendReportAndListen(deviceDetails, createSetEqSwitchCommand(1)); + if (slotId !== undefined) { + await sendReportAndListen(deviceDetails, createSetEqPreCommand(slotId)); + } + } else { + // Disable EQ + await sendReportAndListen(deviceDetails, createSetEqSwitchCommand(0)); + } + + console.log(`FiiO EQ ${enable ? 'enabled' : 'disabled'}`); + + } catch (error) { + console.error("Failed to enable/disable FiiO EQ:", error); + throw error; + } + } + + // Return the handler interface + return { + getCurrentSlot, + pullFromDevice, + pushToDevice, + enablePEQ + }; +})(); diff --git a/assets/js/devicePEQ/jdsLabsUsbSerialHandler.js b/assets/js/devicePEQ/jdsLabsUsbSerialHandler.js new file mode 100644 index 00000000..25411de0 --- /dev/null +++ b/assets/js/devicePEQ/jdsLabsUsbSerialHandler.js @@ -0,0 +1,303 @@ +// jdsLabsUsbSerialHandler.js +// Pragmatic Audio - Handler for JDS Labs Element IV USB Serial EQ Control + +class SerialDeviceError extends Error {} + +export const jdsLabsUsbSerial = (function () { + const textEncoder = new TextEncoder(); + const textDecoder = new TextDecoder(); + const describeCommand = { Product: "JDS Labs Element IV", Action: "Describe" }; + + // Define 12-band filter order + const FILTER_12_BAND_ORDER = [ + "Lowshelf 1", + "Lowshelf 2", + "Peaking 1", + "Peaking 2", + "Peaking 3", + "Peaking 4", + "Peaking 5", + "Peaking 6", + "Peaking 7", + "Peaking 8", + "Highshelf 1", + "Highshelf 2", + ]; + + + async function sendJsonCommand(device, json) { + const writer = device.writable; + const jsonString = JSON.stringify(json); + const payload = textEncoder.encode(jsonString + "\0"); + console.log(`USB Device PEQ: JDS Labs sending command:`, jsonString); + await writer.write(payload); + } + + async function readJsonResponse(device) { + const reader = device.readable; + let buffer = ''; + while (true) { + const { value, done } = await reader.read(); + if (done || !value) break; + buffer += textDecoder.decode(value); + if (buffer.includes("\0")) { + const jsonStr = buffer.split("\0")[0]; + const response = JSON.parse(jsonStr); + console.log(`USB Device PEQ: JDS Labs received response:`, response); + return response; + } + } + console.log(`USB Device PEQ: JDS Labs received no response`); + return null; + } + + async function getCurrentSlot(deviceDetails) { + await sendJsonCommand(deviceDetails, describeCommand); + const response = await readJsonResponse(deviceDetails); + if (!response || !response.Configuration || !response.Configuration.General) { + throw new Error("Invalid Describe response for slot extraction"); + } + const currentInput = response.Configuration.General["Input Mode"]?.Current; + return currentInput === "USB" ? 0 : 1; // slot 0 for USB, slot 1 for SPDIF + } + + + // Helper function to get the filter order (always 12-band) + function getFilterOrder() { + return FILTER_12_BAND_ORDER; + } + + // Helper function to transform JDS Labs filter types to standard format + function transformFilterType(jdsLabsType) { + switch (jdsLabsType) { + case "LOWSHELF": + return "LSQ"; + case "HIGHSHELF": + return "HSQ"; + case "PEAKING": + return "PK"; + default: + return "PK"; // Default to peaking + } + } + + async function pullFromDevice(deviceDetails, slot) { + await sendJsonCommand(deviceDetails, describeCommand); + const response = await readJsonResponse(deviceDetails); + if (!response || !response.Configuration || !response.Configuration.DSP) { + throw new Error("Invalid Describe response for PEQ extraction"); + } + + console.log(`USB Device PEQ: JDS Labs device (12-band support only)`); + + const headphoneConfig = response.Configuration.DSP.Headphone; + const filters = []; + const filterNames = getFilterOrder(); + + // Count actual filters available from the device + let actualFilterCount = 0; + for (const name of filterNames) { + if (headphoneConfig[name]) { + actualFilterCount++; + } + } + + // Show toast notification if fewer than 12 filters are detected + if (actualFilterCount < 12) { + console.log(`USB Device PEQ: JDS Labs detected only ${actualFilterCount} filters, showing firmware update notification`); + if (typeof window !== 'undefined' && window.showToast) { + window.showToast( + `Only ${actualFilterCount} of 12 filters detected. Please update your JDS Labs Element IV firmware to the latest version for full 12-band EQ support.`, + 'warning', + 8000 + ); + } + } + + for (const name of filterNames) { + const filter = headphoneConfig[name]; + if (!filter) { + console.log(`USB Device PEQ: JDS Labs missing filter ${name}, using default values`); + // Add default values for missing filters + const defaultType = name.startsWith("Lowshelf") ? "LOWSHELF" : + name.startsWith("Highshelf") ? "HIGHSHELF" : "PEAKING"; + filters.push({ + freq: name.startsWith("Lowshelf") ? 80 : name.startsWith("Highshelf") ? 10000 : 1000, + gain: 0, + q: 0.707, + type: transformFilterType(defaultType) + }); + continue; + } + + // Use full type names for consistency + let filterType = "PEAKING"; // Default to PEAKING + if (filter.Type) { + filterType = filter.Type.Current || "PEAKING"; + } + + filters.push({ + freq: filter.Frequency.Current, + gain: filter.Gain.Current, + q: filter.Q.Current, + type: transformFilterType(filterType) + }); + } + + const preampGain = headphoneConfig.Preamp?.Gain?.Current || 0; + + return { filters, globalGain: preampGain }; + } + + // Helper function to group and validate filters for JDS Labs + function groupAndValidateFilters(filters) { + const JDS_LIMITS = { + LSQ: 2, // 2 Lowshelf filters + HSQ: 2, // 2 Highshelf filters + PK: 8 // 8 Peaking filters + }; + + // Group filters by type + const grouped = { + LSQ: filters.filter(f => f.type === 'LSQ'), + HSQ: filters.filter(f => f.type === 'HSQ'), + PK: filters.filter(f => f.type === 'PK') + }; + + const warnings = []; + const validatedFilters = { + LSQ: [], + HSQ: [], + PK: [] + }; + + // Validate and truncate each group + for (const [type, typeFilters] of Object.entries(grouped)) { + const limit = JDS_LIMITS[type]; + + if (typeFilters.length > limit) { + warnings.push(`Warning: JDS Labs only supports ${limit} ${type === 'LSQ' ? 'Low Shelf' : type === 'HSQ' ? 'High Shelf' : 'Peak'} filters, but ${typeFilters.length} were provided. Only the first ${limit} will be applied.`); + validatedFilters[type] = typeFilters.slice(0, limit); + } else { + validatedFilters[type] = typeFilters; + } + } + + // Show warnings if any + if (warnings.length > 0) { + warnings.forEach(warning => { + console.warn(`USB Device PEQ: JDS Labs - ${warning}`); + if (typeof window !== 'undefined' && window.showToast) { + window.showToast(warning, "warning", 8000); + } + }); + } + + // Create aligned filter array for JDS Labs 12-band structure + const alignedFilters = []; + + // Add Lowshelf filters (positions 0-1) + for (let i = 0; i < 2; i++) { + if (i < validatedFilters.LSQ.length) { + alignedFilters.push({...validatedFilters.LSQ[i], type: 'LOWSHELF'}); + } else { + // Add disabled/default lowshelf filter + alignedFilters.push({freq: 80, gain: 0, q: 0.707, type: 'LOWSHELF'}); + } + } + + // Add Peaking filters (positions 2-9) + for (let i = 0; i < 8; i++) { + if (i < validatedFilters.PK.length) { + alignedFilters.push({...validatedFilters.PK[i], type: 'PEAKING'}); + } else { + // Add disabled/default peaking filter + alignedFilters.push({freq: 1000, gain: 0, q: 0.707, type: 'PEAKING'}); + } + } + + // Add Highshelf filters (positions 10-11) + for (let i = 0; i < 2; i++) { + if (i < validatedFilters.HSQ.length) { + alignedFilters.push({...validatedFilters.HSQ[i], type: 'HIGHSHELF'}); + } else { + // Add disabled/default highshelf filter + alignedFilters.push({freq: 10000, gain: 0, q: 0.707, type: 'HIGHSHELF'}); + } + } + + return alignedFilters; + } + + async function pushToDevice(deviceDetails, phoneObj, slot, globalGain, filters) { + + console.log(`USB Device PEQ: JDS Labs building settings for 12-band device`); + + // Group and validate filters according to JDS Labs requirements + const alignedFilters = groupAndValidateFilters(filters); + + // Create filter object with Type field (always 12-band) + const makeFilterObj = (filter, defaultType = "PEAKING") => { + // Device expects full type names, not abbreviated forms + const currentType = filter.type || defaultType; + + return { + Gain: filter.gain, + Frequency: filter.freq, + Q: filter.q, + Type: currentType + }; + }; + + // Get the filter order (always 12-band) + const filterOrder = getFilterOrder(); + + // Create the headphone configuration object + const headphoneConfig = { + Preamp: { Gain: globalGain, Mode: "AUTO" } + }; + + // Add aligned filters to the configuration (alignedFilters already has correct types and positions) + filterOrder.forEach((name, index) => { + if (index < alignedFilters.length) { + headphoneConfig[name] = makeFilterObj(alignedFilters[index]); + } else { + // This shouldn't happen since alignedFilters should always be 12 elements + console.warn(`USB Device PEQ: JDS Labs missing filter at index ${index}`); + } + }); + + const payload = { + Product: "JDS Labs Element IV", + FormatOutput: true, + Action: "Update", + Configuration: { + DSP: { + Headphone: headphoneConfig + } + } + }; + + await sendJsonCommand(deviceDetails, payload); + const response = await readJsonResponse(deviceDetails); + if (response["Status"] === true) { + console.log("Settings Applied & Saved"); + return response; + } else { + throw new SerialDeviceError("Command error updating settings"); + } + } + + + return { + getCurrentSlot, + pullFromDevice, + pushToDevice, // Kept for backward compatibility + enablePEQ: async () => {} // Not applicable for JDSLabs + }; +})(); + +// CommonJS compatibility +if (typeof module !== 'undefined' && module.exports) { + module.exports = { jdsLabsUsbSerial }; +} diff --git a/assets/js/devicePEQ/ktmicroUsbHidHandler.js b/assets/js/devicePEQ/ktmicroUsbHidHandler.js new file mode 100644 index 00000000..4231a6a1 --- /dev/null +++ b/assets/js/devicePEQ/ktmicroUsbHidHandler.js @@ -0,0 +1,330 @@ +export const ktmicroUsbHidHandler = (function () { + const FILTER_COUNT = 10; + const REPORT_ID = 0x4b; + const COMMAND_READ = 0x52; + const COMMAND_WRITE = 0x57; + const COMMAND_COMMIT = 0x53; + const COMMAND_CLEAR = 0x43; + + function buildReadPacket(filterFieldToRequest) { + return new Uint8Array([filterFieldToRequest, 0x00, 0x00, 0x00, COMMAND_READ, 0x00, 0x00, 0x00, 0x00]); + } + + function buildReadGlobalPacket() { + return new Uint8Array([0x66, 0x00, 0x00, 0x00, COMMAND_READ, 0x00, 0x00, 0x00, 0x00]); + } + + function buildWriteGlobalPacket() { + return new Uint8Array([0x66, 0x00, 0x00, 0x00, COMMAND_WRITE, 0x00, 0x00, 0x00, 0x00]); + } + + function buildEnableEQPacket(slotId) { + return new Uint8Array([0x24, 0x00, 0x00, 0x00, COMMAND_WRITE, 0x00, slotId, 0x00, 0x00, 0x00]); + } + function buildReadEQPacket(enable) { + return new Uint8Array([0x24, 0x00, 0x00, 0x00, COMMAND_READ, 0x00, 0x03, 0x00, 0x00, 0x00]); + } + + function decodeGainFreqResponse(data,compensate2X) { + const gainRaw = data[6] | (data[7] << 8); + const gain = gainRaw > 0x7FFF ? gainRaw - 0x10000 : gainRaw; // signed 16-bit + var freq = data[8] + (data[9] << 8); + if (compensate2X) { + freq = freq * 2; + } + + return { gain: gain / 10.0, freq }; + } + + function decodeQResponse(data) { + const q = (data[6] + (data[7] << 8)) / 1000.0; + let type = "PK"; // Default to Peak filter + + // Read filter type from byte 8 + const filterTypeValue = data[8]; + if (filterTypeValue === 3) { + type = "LSQ"; // Low Shelf + } else if (filterTypeValue === 0) { + type = "PK"; // Peak + } else if (filterTypeValue === 4) { + type = "HSQ"; // High Shelf + } + + return { q, type }; + } + + async function getCurrentSlot (deviceDetails){ + var device = deviceDetails.rawDevice; + return new Promise(async (resolve, reject) => { + const request = buildReadEQPacket(); + + const timeout = setTimeout(() => { + device.removeEventListener("inputreport", onReport); + reject("Timeout reading slot"); + }, 1000); + + const onReport = (event) => { + const data = new Uint8Array(event.data.buffer); + console.log(`USB Device PEQ: KTMicro onInputReport received slot data:`, data); + + clearTimeout(timeout); + device.removeEventListener("inputreport", onReport); + + const slotId = data[6]; // + + console.log(`USB Device PEQ: KTMicro read slot value: ${slotId}`); + resolve(slotId); + }; + + device.addEventListener("inputreport", onReport); + console.log(`USB Device PEQ: Moondrop sending readPregain command:`, request); + await device.sendReport(REPORT_ID, request); + }); + } + + async function readFullFilter(device, filterIndex, compensate2X) { + const gainFreqId = 0x26 + filterIndex * 2; + const qId = gainFreqId + 1; + + const requestGainFreq = buildReadPacket(gainFreqId); + const requestQ = buildReadPacket(qId); + + return new Promise(async (resolve, reject) => { + const result = {}; + const timeout = setTimeout(() => { + device.removeEventListener('inputreport', onReport); + reject("Timeout reading filter"); + }, 1000); + + const onReport = (event) => { + const data = new Uint8Array(event.data.buffer); + console.log(`USB Device PEQ: KTMicro onInputReport received data:`, data); + if (data[4] !== COMMAND_READ) return; + + if (data[0] === gainFreqId) { + const gainFreqData = decodeGainFreqResponse(data, compensate2X); + console.log(`USB Device PEQ: KTMicro filter ${filterIndex} gain/freq decoded:`, gainFreqData); + Object.assign(result, gainFreqData); + } else if (data[0] === qId) { + const qData = decodeQResponse(data); + console.log(`USB Device PEQ: KTMicro filter ${filterIndex} Q decoded:`, qData); + Object.assign(result, qData); + } + + if ('gain' in result && 'freq' in result && 'q' in result && 'type' in result) { + clearTimeout(timeout); + device.removeEventListener('inputreport', onReport); + console.log(`USB Device PEQ: KTMicro filter ${filterIndex} complete:`, result); + resolve(result); + } + }; + + device.addEventListener('inputreport', onReport); + console.log(`USB Device PEQ: KTMicro sending gain/freq request for filter ${filterIndex}:`, requestGainFreq); + await device.sendReport(REPORT_ID, requestGainFreq); + console.log(`USB Device PEQ: KTMicro sendReport gain/freq for filter ${filterIndex} sent`); + + console.log(`USB Device PEQ: KTMicro sending Q request for filter ${filterIndex}:`, requestQ); + await device.sendReport(REPORT_ID, requestQ); + + console.log(`USB Device PEQ: KTMicro sendReport Q for filter ${filterIndex} sent`); + }); + } + + async function readPregain(device) { + return new Promise(async (resolve, reject) => { + const request = buildReadGlobalPacket(); + + const timeout = setTimeout(() => { + device.removeEventListener("inputreport", onReport); + reject("Timeout reading pregain"); + }, 1000); + + const onReport = (event) => { + const data = new Uint8Array(event.data.buffer); + console.log(`USB Device PEQ: KTMicro onInputReport received pregain data:`, data); + + clearTimeout(timeout); + device.removeEventListener("inputreport", onReport); + + const rawPregain = data[6]; // + var pregain = 0; + if (rawPregain > 127) { + pregain = rawPregain - 256; + } else { + pregain = rawPregain; + } + + console.log(`USB Device PEQ: KTMicro pregain value: ${pregain}`); + resolve(pregain); + }; + + device.addEventListener("inputreport", onReport); + console.log(`USB Device PEQ: Moondrop sending readPregain command:`, request); + await device.sendReport(REPORT_ID, request); + }); + } + + async function writePregain(device, value) { + const request = buildWriteGlobalPacket(); + + let processedGlobalGain = Math.round(value); // Ensure it's a whole number + if (processedGlobalGain < 0) { + processedGlobalGain = processedGlobalGain & 0xFF; + } + + request[6] = processedGlobalGain; + + console.log(`USB Device PEQ: Moondrop sending writePregain command:`, request); + await device.sendReport(REPORT_ID, request); + } + + async function pullFromDevice(deviceDetails) { + const device = deviceDetails.rawDevice; + const compensate2X = deviceDetails.modelConfig.compensate2X; + const filters = []; + for (let i = 0; i < deviceDetails.modelConfig.maxFilters; i++) { + const filter = await readFullFilter(device, i, compensate2X); + filters.push(filter); + } + + const pregain = readPregain(device); + + return { filters, globalGain: pregain }; + } + + function toLittleEndianBytes(value, scale = 1) { + const v = Math.round(value * scale); + return [v & 0xff, (v >> 8) & 0xff]; + } + + function toSignedLittleEndianBytes(value, scale = 1) { + let v = Math.round(value * scale); + if (v < 0) v += 0x10000; // Convert to unsigned 16-bit + return [v & 0xFF, (v >> 8) & 0xFF]; + } + + function buildWritePacket(filterId, freq, gain) { + const freqBytes = toLittleEndianBytes(freq); + const gainBytes = toSignedLittleEndianBytes(gain, 10); + return new Uint8Array([ + filterId, 0x00, 0x00, 0x00, COMMAND_WRITE, 0x00, gainBytes[0], gainBytes[1], freqBytes[0], freqBytes[1] + ]); + } + + function buildQPacket(filterId, q, type) { + const qBytes = toLittleEndianBytes(q, 1000); + var filterTypeValue = 0; + if (type === "LSQ") { + filterTypeValue = 3; // Low Shelf + } else if (type === "HSQ") { + filterTypeValue = 4; // High Shelf + } + + return new Uint8Array([ + filterId, 0x00, 0x00, 0x00, COMMAND_WRITE, 0x00, qBytes[0], qBytes[1], filterTypeValue, 0x00 + ]); + } + + function buildCommand(commandCode) { + return new Uint8Array([ + 0x00, 0x00, 0x00, 0x00, commandCode, 0x00, 0x00, 0x00, 0x00, 0x00 + ]); + } + + async function pushClearToDevice(device) { + // Send a clear first ( sort of like a reset ) + const clear = buildCommand(COMMAND_CLEAR); + console.log(`USB Device PEQ: KTMicro sending clear command:`, clear); + await device.sendReport(REPORT_ID, clear); + console.log(`USB Devic e PEQ: KTMicro sendReport clear sent`); + + await new Promise(resolve => setTimeout(resolve, 200)); // Added 100ms delay + } + + async function pushToDevice(deviceDetails, phoneObj, slot, globalGain, filters) { + const device = deviceDetails.rawDevice; + + // First check if we need to enable PEQ + const currentSlot = await getCurrentSlot(deviceDetails); + if (currentSlot === deviceDetails.modelConfig.disabledPresetId) { + // Use the first of the availableSlots to 'enable' that slot + slot = deviceDetails.modelConfig.availableSlots[0].id; + console.log(`USB Device PEQ: KTMicro device is disabled, enabling it first with slot ${slot}`); + await enablePEQ(deviceDetails, true, slot); + } + + try { + + // Now write the filters + for (let i = 0; i < filters.length; i++) { + if (i >= deviceDetails.modelConfig.maxFilters) break; + + const filterId = 0x26 + i * 2; + var freqToWrite = filters[i].freq; + if (deviceDetails.modelConfig.compensate2X) { // Most older KTMicro devices set the wrong frequency + freqToWrite = filters[i].freq / 2; // 100Hz seems to end up as 200Hz + } + var gain = filters[i].gain; + if (filters[i].disabled) { + gain = 0; + } + const writeGainFreq = buildWritePacket(filterId, freqToWrite, gain); + const writeQ = buildQPacket(filterId + 1, filters[i].q, filters[i].type); + + // We should verify it is saved correctly but for now lets assume once command is accepted it has worked + console.log(`USB Device PEQ: KTMicro sending gain/freq for filter ${i}:`, filters[i], writeGainFreq); + await device.sendReport(REPORT_ID, writeGainFreq); + console.log(`USB Device PEQ: KTMicro sendReport gain/freq for filter ${i} sent`); + + console.log(`USB Device PEQ: KTMicro sending Q for filter ${i}:`, filters[i].q, writeQ); + await device.sendReport(REPORT_ID, writeQ); + console.log(`USB Device PEQ: KTMicro sendReport Q for filter ${i} sent`); + } + } catch (e) { + console.log(`USB Device PEQ: KTMicro Error`, e); + throw e; + } + + if (deviceDetails.modelConfig.supportsPregain) { + writePregain(device, globalGain); + } + + const commit = buildCommand (COMMAND_COMMIT); + console.log(`USB Device PEQ: KTMicro sending commit command:`, commit); + await device.sendReport(REPORT_ID, commit); + console.log(`USB Device PEQ: KTMicro sendReport commit sent`); + + await new Promise(resolve => setTimeout(resolve, 1000)); // Added 100ms delay + + console.log(`USB Device PEQ: KTMicro successfully pushed ${filters.length} filters to device`); + if (deviceDetails.modelConfig.disconnectOnSave) { + return true; // Disconnect + } + return false; + } + + const enablePEQ = async (deviceDetails, enable, slotId) => { + + // KT micro - has issue if device is PEQ was disabled we try to enable it + var device = deviceDetails.rawDevice + + if (slotId === deviceDetails.modelConfig.disabledPresetId || enable === false) { + slotId = deviceDetails.modelConfig.disabledPresetId; // Disable + //await pushClearToDevice(device); + } + + const enableEQPacket = buildEnableEQPacket(slotId); + + console.log(`USB Device PEQ: KTMicro enable PEQ request`, enableEQPacket); + await device.sendReport(REPORT_ID, enableEQPacket); + + } + + return { + getCurrentSlot, + pushToDevice, + pullFromDevice, + enablePEQ, + }; +})(); diff --git a/assets/js/devicePEQ/luxsinNetworkHandler.js b/assets/js/devicePEQ/luxsinNetworkHandler.js new file mode 100644 index 00000000..4c9cd84d --- /dev/null +++ b/assets/js/devicePEQ/luxsinNetworkHandler.js @@ -0,0 +1,254 @@ +// +// Copyright 2024 : Pragmatic Audio +// +// Luxsin X9 Network Handler for PEQ over HTTP API +// Uses custom base64 alphabet encoding used by /dev/info.cgi +// + +export const luxsinNetworkHandler = (function () { + // Custom encoding/decoding alphabets from sample controller + const RC = "KLMPQRSTUVWXYZABCGHdefIJjkNOlmnopqrstuvwxyzabcghiDEF34501289+67/"; + const PC = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + + function encodeCustom(text) { + const encoder = new TextEncoder(); + const bytes = encoder.encode(text); + let binaryString = ''; + for (let i = 0; i < bytes.length; i++) binaryString += String.fromCharCode(bytes[i]); + const base64 = btoa(binaryString); + let encoded = ''; + for (let i = 0; i < base64.length; i++) { + const ch = base64.charAt(i); + const idx = PC.indexOf(ch); + encoded += idx !== -1 ? RC.charAt(idx) : ch; + } + return encoded; + } + + function decodeCustom(encoded) { + let base64 = ''; + for (let i = 0; i < encoded.length; i++) { + const ch = encoded.charAt(i); + const idx = RC.indexOf(ch); + base64 += idx !== -1 ? PC.charAt(idx) : ch; + } + const binaryString = atob(base64); + const bytes = new Uint8Array(binaryString.length); + for (let i = 0; i < binaryString.length; i++) bytes[i] = binaryString.charCodeAt(i); + return new TextDecoder('utf-8').decode(bytes); + } + + // Helpers to normalize filter types between app and Luxsin API + function toLuxsinType(type) { + if (typeof type === 'number') return type; // assume already Luxsin code + const map = { + 'LPF': 0, + 'Low-Pass': 0, + 'HPF': 1, + 'High-Pass': 1, + 'BPF': 2, + 'Band-Pass': 2, + 'Notch': 3, + 'Peak': 4, + 'PK': 4, + 'Low-Shelf': 5, + 'LSQ': 5, + 'High-Shelf': 6, + 'HSQ': 6, + 'AllPass': 7, + 'All-Pass': 7, + }; + return map[type] !== undefined ? map[type] : 4; // default to Peak + } + + function fromLuxsinType(code) { + // Map Luxsin numeric types back to the short codes used by the app UI + // so that pulled filters display correctly as PK / LSQ / HSQ, etc. + switch (Number(code)) { + case 0: return 'LPF'; // Low-pass + case 1: return 'HPF'; // High-pass + case 2: return 'BPF'; // Band-pass + case 3: return 'NOTCH'; // Notch (short code form) + case 4: return 'PK'; // Peaking + case 5: return 'LSQ'; // Low-shelf + case 6: return 'HSQ'; // High-shelf + case 7: return 'ALLPASS'; // All-pass + default: return 'PK'; // Default to Peaking + } + } + + async function httpGet(ip, pathAndQuery) { + const url = `http://${ip}${pathAndQuery}`; + const response = await fetch(url, { method: 'GET' }); + if (!response.ok) throw new Error(`HTTP ${response.status}: ${response.statusText}`); + return response.text(); + } + + async function httpPostJsonEncoded(ip, path, obj) { + const jsonStr = JSON.stringify(obj); + const encodedJson = encodeCustom(jsonStr); + const body = new URLSearchParams(); + body.append('json', encodedJson); + const url = `http://${ip}${path}`; + const response = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' }, + body + }); + if (!response.ok) throw new Error(`HTTP ${response.status}: ${response.statusText}`); + return true; + } + + // Pull PEQ settings and normalize for the app + async function pullFromDevice(device, slot) { + try { + const [syncDataText, syncPeqText] = await Promise.all([ + httpGet(device.ip, '/dev/info.cgi?action=syncData'), + httpGet(device.ip, '/dev/info.cgi?action=syncPeq').catch(() => '') + ]); + + const deviceData = JSON.parse(decodeCustom(syncDataText)); + if (syncPeqText) { + const peqData = JSON.parse(decodeCustom(syncPeqText)); + if (peqData.peq) deviceData.peq = peqData.peq; + if (peqData.peqSelect !== undefined) deviceData.peqSelect = peqData.peqSelect; + if (peqData.peqEnable !== undefined) deviceData.peqEnable = peqData.peqEnable; + } + + // Determine current profile and map filters + let filters = []; + let preamp = 0; + const currentIndex = deviceData.peqSelect ?? 0; + const currentProfile = Array.isArray(deviceData.peq) ? deviceData.peq[currentIndex] : null; + if (currentProfile) { + preamp = Number(currentProfile.preamp) || 0; + try { + const rawFilters = JSON.parse(currentProfile.filters || '[]'); + filters = rawFilters.map(f => ({ + type: fromLuxsinType(Number(f.type)), + freq: Number(f.fc), + q: Number(f.q), + gain: Number(f.gain) + })); + } catch (e) { + console.warn('Luxsin: failed to parse filters JSON', e); + } + } + + const deviceDetails = { + maxFilters: 10, + profiles: Array.isArray(deviceData.peq) ? deviceData.peq.map((p, idx) => ({ id: idx, name: p.name || `Profile ${idx}` })) : [] + }; + + // Append synthetic "New" preset option at the end + deviceDetails.profiles.push({ id: 'new', name: 'New' }); + + return { filters, globalGain: preamp, currentSlot: currentIndex, deviceDetails }; + } catch (err) { + console.error('Luxsin: error pulling from device', err); + throw err; + } + } + + // Push filters/preamp to current or specified slot + async function pushToDevice(device, phoneObj, slot, preamp, filters) { + try { + // Support device as IP string or object with ip + const deviceIp = typeof device === 'string' ? device : device.ip; + // Get current data to fetch profile metadata first + const syncDataText = await httpGet(deviceIp, '/dev/info.cgi?action=syncData'); + const deviceData = JSON.parse(decodeCustom(syncDataText)); + const slotId = (typeof slot === 'object' && slot !== null) ? (slot.id ?? slot.slot ?? slot.value) : slot; + const isNewPreset = String(slotId) === 'new'; + const currentIndex = (!isNewPreset && slotId !== undefined && slotId !== null) ? Number(slotId) : (deviceData.peqSelect ?? 0); + const profile = Array.isArray(deviceData.peq) ? deviceData.peq[currentIndex] : null; + + const luxFilters = (filters || []).map(f => ({ + type: toLuxsinType(f.type), + fc: Number(f.freq ?? f.fc), + gain: Number(f.gain), + q: Number(f.q) + })); + + let payload; + if (isNewPreset) { + // Create a brand new preset/profile using the observed payload shape with `peqChange` + // Important: filters must be sent as an array of objects (not stringified) + const newName = (phoneObj && phoneObj.fileName) ? String(phoneObj.fileName) : 'New Profile'; + payload = { + peqChange: { + name: newName, + filters: luxFilters, + preamp: Number(preamp ?? 0), + autoPre: 0, + canDel: 1 + } + }; + } else { + payload = { + peq: [{ + index: currentIndex, + name: profile?.name || phoneObj?.fileName || `Profile ${currentIndex}`, + canDel: profile?.canDel ?? 1, + preamp: Number(preamp ?? profile?.preamp ?? 0), + filters: JSON.stringify(luxFilters) + }] + }; + } + + await httpPostJsonEncoded(deviceIp, '/dev/info.cgi', payload); + console.log('Luxsin: PEQ updated successfully'); + return false; // no restart required + } catch (err) { + console.error('Luxsin: error pushing to device', err); + throw err; + } + } + + async function enablePEQ(device, enabled, slotId) { + try { + const payload = { peqEnable: enabled ? 1 : 0 }; + if (slotId !== undefined && slotId !== null) payload.peqSelect = Number(slotId); + await httpPostJsonEncoded(device.ip, '/dev/info.cgi', payload); + console.log(`Luxsin: PEQ ${enabled ? 'enabled' : 'disabled'}${slotId !== undefined ? ` on slot ${slotId}` : ''}`); + } catch (err) { + console.error('Luxsin: error toggling PEQ', err); + throw err; + } + } + + async function getCurrentSlot(device) { + try { + const text = await httpGet(device.ip, '/dev/info.cgi?action=syncPeq'); + const data = JSON.parse(decodeCustom(text)); + return data.peqSelect ?? 0; + } catch (err) { + console.warn('Luxsin: getCurrentSlot failed, defaulting to 0', err); + return 0; + } + } + + async function getAvailableSlots(device) { + try { + const text = await httpGet(device.ip, '/dev/info.cgi?action=syncPeq'); + const data = JSON.parse(decodeCustom(text)); + const peq = Array.isArray(data.peq) ? data.peq : []; + const list = peq.map((p, idx) => ({ id: idx, name: p.name || `Profile ${idx}` })); + // Append synthetic "New" preset option at the end + list.push({ id: 'new', name: 'New' }); + return list; + } catch (err) { + console.warn('Luxsin: getAvailableSlots failed, returning empty list', err); + // Even if failed, still expose the ability to create a new preset + return [{ id: 'new', name: 'New' }]; + } + } + + return { + getCurrentSlot, + getAvailableSlots, + pullFromDevice, + pushToDevice, + enablePEQ, + }; +})(); diff --git a/assets/js/devicePEQ/moondropOldFashionedUsbHidHandler.js b/assets/js/devicePEQ/moondropOldFashionedUsbHidHandler.js new file mode 100644 index 00000000..b9a2aca1 --- /dev/null +++ b/assets/js/devicePEQ/moondropOldFashionedUsbHidHandler.js @@ -0,0 +1,154 @@ +export const oldFashionedUsbHidHandler = (function () { + const REPORT_ID = 75; + const EQ_REG_BASE = 38; + const WRITE_REG = 87; // 'W' + const SAVE_REG = 83; // 'S' + const READ_REG = 82; // 'R' + + const PACKET_LEN = 10; + const SCALE_GAIN = 10; + const SCALE_Q = 1000; + const DELAY_MS = 100; + + const ADDR = 0; + const CMD = 4; + const DATA_SLOT_GAIN = 6; + const DATA_SLOT_Q = 6; + const DATA_SLOT_FREQUENCY = 8; + + function sleep(ms = DELAY_MS) { + return new Promise(resolve => setTimeout(resolve, ms)); + } + + function getFilterRegAddr(filterIndex) { + return EQ_REG_BASE + filterIndex * 2; + } + + function createPacket(builder) { + const buffer = new ArrayBuffer(PACKET_LEN); + const view = new DataView(buffer); + builder(view); + return new Uint8Array(buffer); + } + + async function readRegister(device, addr) { + const packet = createPacket(view => { + view.setUint8(ADDR, addr); + view.setUint8(CMD, READ_REG); + }); + + return new Promise(async (resolve, reject) => { + const timeout = setTimeout(() => { + device.removeEventListener("inputreport", onReport); + reject("Timeout reading register"); + }, 1000); + + const onReport = (event) => { + const data = new DataView(event.data.buffer); + clearTimeout(timeout); + device.removeEventListener("inputreport", onReport); + resolve(data); + }; + + device.addEventListener("inputreport", onReport); + await device.sendReport(REPORT_ID, packet); + }); + } + + async function writeRegister(device, addr, dataBuilder) { + const packet = createPacket(view => { + view.setUint8(ADDR, addr); + view.setUint8(CMD, WRITE_REG); + dataBuilder(view); + }); + await device.sendReport(REPORT_ID, packet); + } + + async function readSingleFilter(device, filterIndex) { + const regAddr = getFilterRegAddr(filterIndex); + + // Read frequency and gain from first register + const data1 = await readRegister(device, regAddr); + const freq = data1.getUint16(DATA_SLOT_FREQUENCY, true); + const gainRaw = data1.getInt8(DATA_SLOT_GAIN); + const gain = Math.max(-12.8, Math.min(12.7, gainRaw / SCALE_GAIN)); + + await sleep(); + + // Read Q from second register + const data2 = await readRegister(device, regAddr + 1); + const q = data2.getInt16(DATA_SLOT_Q, true) / SCALE_Q; + + return { freq, gain, q, type: "PK" }; + } + + async function pullFromDevice(deviceDetails) { + const device = deviceDetails.rawDevice; + const filters = []; + const filterCount = deviceDetails.modelConfig.maxFilters || 5; + + for (let i = 0; i < filterCount; i++) { + const filter = await readSingleFilter(device, i); + filters.push(filter); + await sleep(); + } + + return { filters, globalGain: 0 }; + } + + async function writeSingleFilter(device, filterIndex, filter) { + const regAddr = getFilterRegAddr(filterIndex); + const { freq, gain, q } = filter; + + // Write frequency and gain to first register + await writeRegister(device, regAddr, view => { + const gainVal = Math.round(gain * SCALE_GAIN); + const clampedGain = Math.max(-128, Math.min(127, gainVal)); + view.setInt8(DATA_SLOT_GAIN, clampedGain); + view.setUint16(DATA_SLOT_FREQUENCY, freq, true); + }); + + await sleep(); + + // Write Q to second register + await writeRegister(device, regAddr + 1, view => { + const qVal = Math.round(q * SCALE_Q); + view.setInt16(DATA_SLOT_Q, qVal, true); + }); + + await sleep(); + } + + async function saveToFlash(device) { + const packet = createPacket(view => { + view.setUint8(CMD, SAVE_REG); + }); + await device.sendReport(REPORT_ID, packet); + } + + async function pushToDevice(deviceDetails, phoneObj, slot, globalGain, filters) { + const device = deviceDetails.rawDevice; + const filterCount = deviceDetails.modelConfig.maxFilters || 5; + + for (let i = 0; i < filters.length && i < filterCount; i++) { + await writeSingleFilter(device, i, filters[i]); + } + + await saveToFlash(device); + console.log(`USB Device PEQ: Old Fashioned pushed ${filters.length} filters`); + return false; + } + + // Old-Fashioned devices do not expose multiple EQ preset slots (single active bank). + // Provide a trivial implementation so the UI can treat it as slot 0. + async function getCurrentSlot(deviceDetails) { + return 0; + } + + return { + pullFromDevice, + pushToDevice, + getCurrentSlot, + enablePEQ: async () => {}, + }; +})(); diff --git a/assets/js/devicePEQ/moondropUsbHidHandler.js b/assets/js/devicePEQ/moondropUsbHidHandler.js new file mode 100644 index 00000000..ff0a1b91 --- /dev/null +++ b/assets/js/devicePEQ/moondropUsbHidHandler.js @@ -0,0 +1,489 @@ +export const moondropUsbHidHandler = (function () { + const FILTER_COUNT = 8; + const REPORT_ID = 0x4b; + const COMMAND_WRITE = 1; + const COMMAND_READ = 128; + const COMMAND_UPDATE_EQ = 9; + const COMMAND_UPDATE_EQ_COEFF_TO_REG = 10; + const COMMAND_SAVE_EQ_TO_FLASH = 1; + const COMMAND_SAVE_OFFSET_TO_FLASH = 4; + const COMMAND_SET_DAC_OFFSET = 3; + const COMMAND_CLEAR_FLASH = 0x05; + const COMMAND_CHANNEL_BALANCE = 0x16; + const COMMAND_DAC_GAIN = 0x19; + const COMMAND_DAC_MODE = 0x1D; + const COMMAND_PRE_GAIN = 0x23; + const COMMAND_LED_SWITCH = 0x18; + const COMMAND_DAC_FILTER = 0x11; + const COMMAND_VER = 0x0C; + const COMMAND_RESET_EQ = 0x05; + const COMMAND_RESET_FLASH = 0x17; + const COMMAND_UPGRADE = 0xFF; + const COMMAND_ACTIVE_EQ = 15; + + function buildReadPacket(filterIndex) { + return new Uint8Array([COMMAND_READ, COMMAND_UPDATE_EQ, 0x18, 0x00, filterIndex, 0x00]); + } + + function decodeFilterResponse(data) { + const e = new Int8Array(data.buffer); + + const rawFreq = (e[27] & 0xff) | ((e[28] & 0xff) << 8); + const freq = rawFreq; + + const q = ((e[30] << 8) | (e[29] & 255)) / 256; // 16-bit fixed-point + const gain = ((e[32] << 8) | (e[31] & 255)) / 256; + const filterType = convertToFilterType(e[33]); + const valid = freq > 10 && freq < 24000 && !isNaN(gain) && !isNaN(q); + + return { + type: filterType, + freq: valid ? freq : 0, + q: valid ? q : 1.0, + gain: valid ? gain : 0.0, + disabled: !valid + }; + } + + function convertToFilterType(byte) { + switch (byte) { + case 1: return "LSQ"; // Low Shelf (if seen in future captures) + case 2: return "PK"; // Peaking + case 3: return "HSQ"; // High Shelf (future-proof) + default: return "PK"; + } + } + + async function getCurrentSlot(deviceDetails) { + const device = deviceDetails.rawDevice; + const request = new Uint8Array([0x80, 0x0F, 0x00]); // READ, SET_ACTIVE_EQ, bLength = 0 + + return new Promise(async (resolve, reject) => { + const timeout = setTimeout(() => { + device.removeEventListener("inputreport", onReport); + reject("Timeout reading current slot"); + }, 1000); + + const onReport = (event) => { + const data = new Uint8Array(event.data.buffer); + console.log(`USB Device PEQ: Moondrop onInputReport received slot data:`, data); + if (data[0] !== 0x80 || data[1] !== 0x0F) return; + + clearTimeout(timeout); + device.removeEventListener("inputreport", onReport); + console.log(`USB Device PEQ: Moondrop current slot: ${data[3]}`); + resolve(data[3]); // slot ID + }; + + device.addEventListener("inputreport", onReport); + console.log(`USB Device PEQ: Moondrop sending getCurrentSlot command:`, request); + await device.sendReport(0x4B, request); + }); + } + + async function readFullFilter(device, filterIndex) { + const packet = buildReadPacket(filterIndex); + + return new Promise(async (resolve, reject) => { + const timeout = setTimeout(() => { + device.removeEventListener("inputreport", onReport); + reject("Timeout reading filter"); + }, 1000); + + const onReport = (event) => { + const data = new Uint8Array(event.data.buffer); + console.log(`USB Device PEQ: Moondrop onInputReport received filter ${filterIndex} data:`, data); + if (data[0] !== COMMAND_READ || data[1] !== COMMAND_UPDATE_EQ) return; + + clearTimeout(timeout); + device.removeEventListener("inputreport", onReport); + const filter = decodeFilterResponse(data); + console.log(`USB Device PEQ: Moondrop filter ${filterIndex} decoded:`, filter); + resolve(filter); + }; + + device.addEventListener("inputreport", onReport); + console.log(`USB Device PEQ: Moondrop sending readFilter ${filterIndex} command:`, packet); + await device.sendReport(REPORT_ID, packet); + }); + } + + async function readPregain(device) { + return new Promise(async (resolve, reject) => { + const request = new Uint8Array([COMMAND_READ, COMMAND_SET_DAC_OFFSET]); + + const timeout = setTimeout(() => { + device.removeEventListener("inputreport", onReport); + reject("Timeout reading pregain"); + }, 1000); + + const onReport = (event) => { + const data = new Uint8Array(event.data.buffer); + console.log(`USB Device PEQ: Moondrop onInputReport received pregain data:`, data); + if (data[0] !== COMMAND_READ || data[1] !== COMMAND_SET_DAC_OFFSET) return; + + clearTimeout(timeout); + device.removeEventListener("inputreport", onReport); + + const r = new Int8Array(data.buffer); + const pregain = (r[4] << 8 | r[3] & 255) / 256; + + console.log(`USB Device PEQ: Moondrop pregain value: ${pregain}`); + resolve(pregain); + }; + + device.addEventListener("inputreport", onReport); + console.log(`USB Device PEQ: Moondrop sending readPregain command:`, request); + await device.sendReport(REPORT_ID, request); + }); + } + + async function writePregain(device, value) { + const val = Math.round(value * 256); + const request = new Uint8Array([COMMAND_WRITE, COMMAND_PRE_GAIN, 0, val & 255, (val >> 8) & 255]); + console.log(`USB Device PEQ: Moondrop sending writePregain command:`, request); + await device.sendReport(REPORT_ID, request); + } + + async function pullFromDevice(deviceDetails) { + const device = deviceDetails.rawDevice; + const filters = []; + + for (let i = 0; i < deviceDetails.modelConfig.maxFilters; i++) { + const filter = await readFullFilter(device, i); + filters.push(filter); + } + + const globalGain = await readPregain(device); + + return { + filters, + globalGain + }; + } + + function toLittleEndianBytes(value, scale = 1) { + const v = Math.round(value * scale); + return [v & 0xff, (v >> 8) & 0xff]; + } + + function toSignedLittleEndianBytes(value, scale = 1) { + let v = Math.round(value * scale); + if (v < 0) v += 0x10000; + return [v & 0xff, (v >> 8) & 0xff]; + } + + function encodeBiquad(freq, gain, q) { + const A = Math.pow(10, gain / 40); + const w0 = (2 * Math.PI * freq) / 96000; + const alpha = Math.sin(w0) / (2 * q); + const cosW0 = Math.cos(w0); + const norm = 1 + alpha / A; + + const b0 = (1 + alpha * A) / norm; + const b1 = (-2 * cosW0) / norm; + const b2 = (1 - alpha * A) / norm; + const a1 = -b1; + const a2 = (1 - alpha / A) / norm; + + return [b0, b1, b2, a1, -a2].map(c => Math.round(c * 1073741824)); + } + + function encodeToByteArray(coeffs) { + const arr = new Uint8Array(20); + for (let i = 0; i < coeffs.length; i++) { + const val = coeffs[i]; + arr[i * 4] = val & 0xff; + arr[i * 4 + 1] = (val >> 8) & 0xff; + arr[i * 4 + 2] = (val >> 16) & 0xff; + arr[i * 4 + 3] = (val >> 24) & 0xff; + } + return arr; + } + + function buildWritePacket(filterIndex, { freq, gain, q, type }) { + const packet = new Uint8Array(63); + packet[0] = COMMAND_WRITE; + packet[1] = COMMAND_UPDATE_EQ; + packet[2] = 0x18; // bLength + packet[3] = 0x00; + packet[4] = filterIndex; + packet[5] = 0x00; + packet[6] = 0x00; + + const coeffs = encodeToByteArray(encodeBiquad(freq, gain, q)); + packet.set(coeffs, 7); + + packet[27] = freq & 0xff; + packet[28] = (freq >> 8) & 0xff; + const qVal = Math.round(q * 256); + packet[29] = qVal & 255; + packet[30] = (qVal >> 8) & 255; + + const gainVal = Math.round(gain * 256); + packet[31] = gainVal & 255; + packet[32] = (gainVal >> 8) & 255; + packet[33] = convertFromFilterType(type); // 2 by default + packet[34] = 0; + packet[35] = 7; // peqIndex + + return packet; + } + + function convertFromFilterType(filterType) { + const mapping = {"PK": 2, "LSQ": 1, "HSQ": 3}; + return mapping[filterType] !== undefined ? mapping[filterType] : 2; + } + + function buildEnablePacket(filterIndex) { + const packet = new Uint8Array(63); + packet[0] = COMMAND_WRITE; + packet[1] = COMMAND_UPDATE_EQ_COEFF_TO_REG; + packet[2] = filterIndex; + packet[3] = 0; + packet[4] = 255; + packet[5] = 255; + packet[6] = 255; + return packet; + } + + function buildSavePacket() { + return new Uint8Array([COMMAND_WRITE, COMMAND_SAVE_EQ_TO_FLASH]); + } + + async function pushToDevice(deviceDetails, phoneObj, slot, globalGain, filters) { + const device = deviceDetails.rawDevice; + + for (let i = 0; i < filters.length && i < deviceDetails.modelConfig.maxFilters; i++) { + const writeFilter = buildWritePacket(i, filters[i]); + console.log(`USB Device PEQ: Moondrop sending filter ${i} data:`, filters[i], writeFilter); + await device.sendReport(REPORT_ID, writeFilter); + + const enable = buildEnablePacket(i); + console.log(`USB Device PEQ: Moondrop sending enable command for filter ${i}:`, enable); + await device.sendReport(REPORT_ID, enable); + } + + // Write the global gain (pregain) + await writePregain(device, globalGain); + console.log(`USB Device PEQ: Moondrop set pregain to ${globalGain}`); + + const save = buildSavePacket(); + console.log(`USB Device PEQ: Moondrop sending save command:`, save); + await device.sendReport(REPORT_ID, save); + + console.log(`USB Device PEQ: Moondrop successfully pushed ${filters.length} filters to device`); + return false; + } + + async function readVer(device) { + return new Promise(async (resolve, reject) => { + const request = new Uint8Array([COMMAND_READ, COMMAND_VER]); + + const timeout = setTimeout(() => { + device.removeEventListener("inputreport", onReport); + reject("Timeout reading version"); + }, 1000); + + const onReport = (event) => { + const data = new Uint8Array(event.data.buffer); + console.log(`USB Device PEQ: Moondrop onInputReport received version data:`, data); + if (data[0] !== COMMAND_READ || data[1] !== COMMAND_VER) return; + + clearTimeout(timeout); + device.removeEventListener("inputreport", onReport); + const version = `${data[3]}.${data[4]}.${data[5]}`; + console.log(`USB Device PEQ: Moondrop version: ${version}`); + resolve(version); + }; + + device.addEventListener("inputreport", onReport); + console.log(`USB Device PEQ: Moondrop sending readVer command:`, request); + await device.sendReport(REPORT_ID, request); + }); + } + + async function readChannelBalance(device, lr) { + return new Promise(async (resolve, reject) => { + const request = new Uint8Array([COMMAND_READ, COMMAND_CHANNEL_BALANCE, 0, lr]); + + const timeout = setTimeout(() => { + device.removeEventListener("inputreport", onReport); + reject("Timeout reading channel balance"); + }, 1000); + + const onReport = (event) => { + const data = new Uint8Array(event.data.buffer); + console.log(`USB Device PEQ: Moondrop onInputReport received channel balance data:`, data); + if (data[0] !== COMMAND_READ || data[1] !== COMMAND_CHANNEL_BALANCE) return; + + clearTimeout(timeout); + device.removeEventListener("inputreport", onReport); + const balance = data[5]; + console.log(`USB Device PEQ: Moondrop channel balance value: ${balance}`); + resolve(balance); + }; + + device.addEventListener("inputreport", onReport); + console.log(`USB Device PEQ: Moondrop sending readChannelBalance command:`, request); + await device.sendReport(REPORT_ID, request); + }); + } + + async function writeChannelBalance(device, lr, db) { + const request = new Uint8Array([COMMAND_WRITE, COMMAND_CHANNEL_BALANCE, 0, lr, 0, db, 0]); + console.log(`USB Device PEQ: Moondrop sending writeChannelBalance command:`, request); + await device.sendReport(REPORT_ID, request); + } + + async function readDACGain(device) { + return new Promise(async (resolve, reject) => { + const request = new Uint8Array([COMMAND_READ, COMMAND_DAC_GAIN, 0]); + + const timeout = setTimeout(() => { + device.removeEventListener("inputreport", onReport); + reject("Timeout reading DAC gain"); + }, 1000); + + const onReport = (event) => { + const data = new Uint8Array(event.data.buffer); + console.log(`USB Device PEQ: Moondrop onInputReport received DAC gain data:`, data); + if (data[0] !== COMMAND_READ || data[1] !== COMMAND_DAC_GAIN) return; + + clearTimeout(timeout); + device.removeEventListener("inputreport", onReport); + const gain = data[3]; + console.log(`USB Device PEQ: Moondrop DAC gain value: ${gain}`); + resolve(gain); + }; + + device.addEventListener("inputreport", onReport); + console.log(`USB Device PEQ: Moondrop sending readDACGain command:`, request); + await device.sendReport(REPORT_ID, request); + }); + } + + async function writeDACGain(device, vl) { + const request = new Uint8Array([COMMAND_WRITE, COMMAND_DAC_GAIN, 1, vl]); + console.log(`USB Device PEQ: Moondrop sending writeDACGain command:`, request); + await device.sendReport(REPORT_ID, request); + } + + async function readDACMode(device) { + return new Promise(async (resolve, reject) => { + const request = new Uint8Array([COMMAND_READ, COMMAND_DAC_MODE, 0]); + + const timeout = setTimeout(() => { + device.removeEventListener("inputreport", onReport); + reject("Timeout reading DAC mode"); + }, 1000); + + const onReport = (event) => { + const data = new Uint8Array(event.data.buffer); + console.log(`USB Device PEQ: Moondrop onInputReport received DAC mode data:`, data); + if (data[0] !== COMMAND_READ || data[1] !== COMMAND_DAC_MODE) return; + + clearTimeout(timeout); + device.removeEventListener("inputreport", onReport); + const mode = data[3]; + console.log(`USB Device PEQ: Moondrop DAC mode value: ${mode}`); + resolve(mode); + }; + + device.addEventListener("inputreport", onReport); + console.log(`USB Device PEQ: Moondrop sending readDACMode command:`, request); + await device.sendReport(REPORT_ID, request); + }); + } + + async function writeDACMode(device, vl) { + const request = new Uint8Array([COMMAND_WRITE, COMMAND_DAC_MODE, 1, vl]); + console.log(`USB Device PEQ: Moondrop sending writeDACMode command:`, request); + await device.sendReport(REPORT_ID, request); + } + + async function readLEDSwitch(device) { + return new Promise(async (resolve, reject) => { + const request = new Uint8Array([COMMAND_READ, COMMAND_LED_SWITCH, 0]); + + const timeout = setTimeout(() => { + device.removeEventListener("inputreport", onReport); + reject("Timeout reading LED switch"); + }, 1000); + + const onReport = (event) => { + const data = new Uint8Array(event.data.buffer); + console.log(`USB Device PEQ: Moondrop onInputReport received LED switch data:`, data); + if (data[0] !== COMMAND_READ || data[1] !== COMMAND_LED_SWITCH) return; + + clearTimeout(timeout); + device.removeEventListener("inputreport", onReport); + const ledSwitch = data[3]; + console.log(`USB Device PEQ: Moondrop LED switch value: ${ledSwitch}`); + resolve(ledSwitch); + }; + + device.addEventListener("inputreport", onReport); + console.log(`USB Device PEQ: Moondrop sending readLEDSwitch command:`, request); + await device.sendReport(REPORT_ID, request); + }); + } + + async function writeLEDSwitch(device, vl) { + const request = new Uint8Array([COMMAND_WRITE, COMMAND_LED_SWITCH, 1, vl]); + console.log(`USB Device PEQ: Moondrop sending writeLEDSwitch command:`, request); + await device.sendReport(REPORT_ID, request); + } + + async function readDACFilter(device) { + return new Promise(async (resolve, reject) => { + const request = new Uint8Array([COMMAND_READ, COMMAND_DAC_FILTER, 0]); + + const timeout = setTimeout(() => { + device.removeEventListener("inputreport", onReport); + reject("Timeout reading DAC filter"); + }, 1000); + + const onReport = (event) => { + const data = new Uint8Array(event.data.buffer); + console.log(`USB Device PEQ: Moondrop onInputReport received DAC filter data:`, data); + if (data[0] !== COMMAND_READ || data[1] !== COMMAND_DAC_FILTER) return; + + clearTimeout(timeout); + device.removeEventListener("inputreport", onReport); + const filter = data[3]; + console.log(`USB Device PEQ: Moondrop DAC filter value: ${filter}`); + resolve(filter); + }; + + device.addEventListener("inputreport", onReport); + console.log(`USB Device PEQ: Moondrop sending readDACFilter command:`, request); + await device.sendReport(REPORT_ID, request); + }); + } + + async function writeDACFilter(device, vl) { + const request = new Uint8Array([COMMAND_WRITE, COMMAND_DAC_FILTER, 1, vl]); + console.log(`USB Device PEQ: Moondrop sending writeDACFilter command:`, request); + await device.sendReport(REPORT_ID, request); + } + + async function resetEQ(device) { + const request = new Uint8Array([COMMAND_WRITE, COMMAND_RESET_EQ, 1, 4, 0]); + console.log(`USB Device PEQ: Moondrop sending resetEQ command:`, request); + await device.sendReport(REPORT_ID, request); + } + + async function resetFlash(device) { + const request = new Uint8Array([COMMAND_WRITE, COMMAND_RESET_FLASH, 0]); + console.log(`USB Device PEQ: Moondrop sending resetFlash command:`, request); + await device.sendReport(REPORT_ID, request); + } + + return { + getCurrentSlot, + pullFromDevice, + pushToDevice, + enablePEQ: async () => {} + }; +})(); diff --git a/assets/js/devicePEQ/networkDeviceConfig.js b/assets/js/devicePEQ/networkDeviceConfig.js new file mode 100644 index 00000000..2038cb00 --- /dev/null +++ b/assets/js/devicePEQ/networkDeviceConfig.js @@ -0,0 +1,42 @@ +// +// Copyright 2024 : Pragmatic Audio +// +// Network Device Model Configuration +// Provides a similar structure to usbDeviceConfig.js / usbSerialDeviceConfig.js +// for devices connected over the network (e.g., WiiM and Luxsin X9). +// + +export const networkDeviceHandlerConfig = { + // Defaults applied to all network devices unless overridden per model + defaultModelConfig: { + minGain: -12, + maxGain: 12, + maxFilters: 10, // Requirement: support up to 10 filters + firstWritableEQSlot: 0, + maxWritableEQSlots: 1, + disconnectOnSave: false, + disabledPresetId: -1, + experimental: false, + supportsLSHSFilters: true, // LHS/HSQ supported + supportsPregain: true, + supportedFilterTypes: ["PK", "LSQ", "HSQ"], // Requirement: PK, LSQ, HSQ + }, + + // Known network devices keyed by selection value used in the UI + devices: { + // WiiM devices accessed via Linkplay HTTP API over HTTPS + "WiiM": { + manufacturer: "WiiM", + model: "WiiM Network Device", + // modelConfig overrides if any in the future + modelConfig: {} + }, + + // Luxsin X9 device, plain HTTP with custom encoded payloads + "Luxsin": { + manufacturer: "Luxsin", + model: "Luxsin X9", + modelConfig: {} + } + } +}; diff --git a/assets/js/devicePEQ/networkDeviceConnector.js b/assets/js/devicePEQ/networkDeviceConnector.js new file mode 100644 index 00000000..c8eec62b --- /dev/null +++ b/assets/js/devicePEQ/networkDeviceConnector.js @@ -0,0 +1,117 @@ +// networkDeviceConnector.js +// Copyright 2024 : Pragmatic Audio + +const {wiimNetworkHandler} = await import('./wiimNetworkHandler.js'); +const {luxsinNetworkHandler} = await import('./luxsinNetworkHandler.js'); +const {networkDeviceHandlerConfig} = await import('./networkDeviceConfig.js'); + +export const NetworkDeviceConnector = (function () { + let currentDevice = null; + const deviceHandlers = { + "WiiM": wiimNetworkHandler, + "Luxsin": luxsinNetworkHandler, + }; + async function getDeviceConnected(deviceIP, deviceType) { + try { + if (!deviceIP) { + console.warn("No IP Address provided."); + return null; + } + + // Basic IP address validation (IPv4) + const ipPattern = /^(25[0-5]|2[0-4]\d|1?\d?\d)(\.(25[0-5]|2[0-4]\d|1?\d?\d)){3}$/; + if (!ipPattern.test(deviceIP)) { + console.warn("Invalid IP address format."); + return null; + } + + if (!deviceHandlers[deviceType]) { + console.warn("Unsupported Device Type."); + return null; + } + + // Build model information from config + const deviceConfig = networkDeviceHandlerConfig.devices?.[deviceType] || {}; + const defaultModelConfig = networkDeviceHandlerConfig.defaultModelConfig || {}; + const modelConfig = Object.assign({}, defaultModelConfig, deviceConfig.modelConfig || {}); + + currentDevice = { + ip: deviceIP, + type: deviceType, + handler: deviceHandlers[deviceType], + manufacturer: deviceConfig.manufacturer || deviceType, + model: deviceConfig.model || `${deviceType} Device`, + modelConfig: modelConfig, + }; + + console.log(`Connected to ${deviceType} at ${deviceIP}`); + return currentDevice; + } catch (error) { + console.error("Failed to connect to Network Device:", error); + return null; + } + } + + async function disconnectDevice() { + if (currentDevice) { + console.log(`Disconnected from ${currentDevice.type} at ${currentDevice.ip}`); + currentDevice = null; + } + } + + async function pushToDevice(device, phoneObj, slot, preamp, filters) { + if (!currentDevice) { + console.warn("No network device connected."); + return; + } + // Pass modelConfig so handlers can respect device-specific limits (e.g., maxFilters) + return await currentDevice.handler.pushToDevice( + currentDevice, + phoneObj, + slot, + preamp, + filters, + currentDevice.modelConfig + ); + } + + async function pullFromDevice(device, slot) { + if (!currentDevice) { + console.warn("No network device connected."); + return; + } + return await currentDevice.handler.pullFromDevice(currentDevice, slot); + } + async function getCurrentSlot(device) { + if (!deviceHandlers[device.type]) { + console.warn("Unsupported Device Type."); + return null; + } + return await deviceHandlers[device.type].getCurrentSlot(device); + } + async function getAvailableSlots(device) { + if (!deviceHandlers[device.type]) { + console.warn("Unsupported Device Type."); + return null; + } + return await deviceHandlers[device.type].getAvailableSlots(device); + } + + async function enablePEQ(device, enabled, slotId) { + if (!currentDevice) { + console.warn("No network device connected."); + return; + } + return await currentDevice.handler.enablePEQ(currentDevice, enabled, slotId); + } + + return { + getAvailableSlots, + getCurrentSlot, + getDeviceConnected, + disconnectDevice, + pushToDevice, + pullFromDevice, + enablePEQ, + }; +})(); diff --git a/assets/js/devicePEQ/nothingUsbSerialHandler.js b/assets/js/devicePEQ/nothingUsbSerialHandler.js new file mode 100644 index 00000000..1252184b --- /dev/null +++ b/assets/js/devicePEQ/nothingUsbSerialHandler.js @@ -0,0 +1,359 @@ +// nothingUsbSerialHandler.js +// Pragmatic Audio - Handler for Nothing Headphones USB Serial/Bluetooth SPP EQ Control + +export const nothingUsbSerial = (function () { + + // Nothing headphone protocol constants + const PROTOCOL_HEADER = [0x55, 0x60, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00]; + + // Command constants from bluetooth-spp-test.html + // READ_ commands - used to send() values to the SSP port + const READ_COMMANDS = { + READ_EQ_MODE: 49183, + READ_EQ_VALUES: 49229, + READ_FIRMWARE: 49218 + }; + + // WRITE_ commands - used to send() values to the SSP port + const WRITE_COMMANDS = { + SET_ADVANCE_CUSTOM_EQ_VALUE: 61520 + }; + + // RESPONSE_ commands - used to read the results of either READ_ or WRITE_ operations + const RESPONSE_COMMANDS = { + EQ_MODE: 16415, // Response for READ_EQ_MODE command + FIRMWARE: 16450, + EQ_VALUES: 16461 + }; + + + let operationID = 0; + let operationList = {}; + + function crc16(buffer) { + let crc = 0xFFFF; + for (let i = 0; i < buffer.length; i++) { + crc ^= buffer[i]; + for (let j = 0; j < 8; j++) { + crc = (crc & 1) ? ((crc >> 1) ^ 0xA001) : (crc >> 1); + } + } + return crc; + } + + async function sendCommand(device, command, payload = [], operation = "") { + let header = [...PROTOCOL_HEADER]; + operationID++; + header[7] = operationID; + + let commandBytes = new Uint8Array(new Uint16Array([command]).buffer); + header[3] = commandBytes[0]; + header[4] = commandBytes[1]; + + let payloadLength = payload.length; + header[5] = payloadLength; + header.push(...payload); + + let byteArray = new Uint8Array(header); + let crc = crc16(byteArray); + byteArray = [...byteArray, crc & 0xFF, crc >> 8]; + + if (operation !== "") { + operationList[operationID] = operation; + } + + console.log(`Nothing USB Serial: sending command ${command}:`, byteArray.map(byte => byte.toString(16).padStart(2, '0')).join('')); + + const writer = device.writable; + await writer.write(new Uint8Array(byteArray)); + } + + function getCommand(header) { + let commandBytes = new Uint8Array(header.slice(3, 5)); + let commandInt = new Uint16Array(commandBytes.buffer)[0]; + return commandInt; + } + + function bytesToFloat(byteArray) { + const buffer = new ArrayBuffer(4); + const view = new Uint8Array(buffer); + for (let i = 0; i < 4; i++) { + view[i] = byteArray[i]; + } + const dataView = new DataView(buffer); + return dataView.getFloat32(0, true); // true for little-endian + } + + function floatToBytes(value) { + const buffer = new ArrayBuffer(4); + const view = new DataView(buffer); + view.setFloat32(0, value, true); // true for little-endian + return new Uint8Array(buffer); + } + + function toByteArray(value, offset = 0, length = 1) { + const byteArray = new Uint8Array(length); + + if (length === 1) { + byteArray[0] = value & 0xFF; + } else if (length === 2) { + byteArray[0] = value & 0xFF; + byteArray[1] = (value >> 8) & 0xFF; + } else if (length === 4) { + byteArray[0] = value & 0xFF; + byteArray[1] = (value >> 8) & 0xFF; + byteArray[2] = (value >> 16) & 0xFF; + byteArray[3] = (value >> 24) & 0xFF; + } else { + byteArray[0] = value & 0xFF; + } + + return Array.from(byteArray); + } + + async function readResponse(device) { + const reader = device.readable; + const { value, done } = await reader.read(); + + if (done || !value) { + return null; + } + + let rawData = new Uint8Array(value.buffer); + if (rawData[0] !== 0x55 || rawData.length < 8) { + return null; + } + + // Use full 8-byte protocol header to align payload offset correctly + let header = rawData.slice(0, 8); + let command = getCommand(header); + + return { + command, + rawData, + hexString: rawData.reduce((acc, byte) => acc + byte.toString(16).padStart(2, '0'), '') + }; + } + + async function readEQMode(device) { + console.log("Nothing USB Serial: reading current EQ mode"); + await sendCommand(device, READ_COMMANDS.READ_EQ_MODE, [], "readEQMode"); + + const response = await readResponse(device); + if (!response || response.command !== RESPONSE_COMMANDS.EQ_MODE) { + throw new Error("Failed to read EQ mode from Nothing device"); + } + + // Parse EQ mode response + const hexArray = response.hexString.match(/.{2}/g).map(byte => parseInt(byte, 16)); + const eqModeValue = hexArray[8]; // EQ mode is typically at offset 8 + + return eqModeValue; + } + + async function getCurrentSlot(deviceDetails) { + try { + return await readEQMode(deviceDetails); + } catch (error) { + console.error("Nothing USB Serial: failed to read current EQ mode:", error); + return 0; // Default to Balanced profile + } + } + + function getProfileName(deviceDetails, profileId) { + const slots = deviceDetails?.modelConfig?.availableSlots; + if (Array.isArray(slots)) { + const match = slots.find(s => s.id === profileId); + if (match && match.name) return match.name; + } + // Removed hardcoded fallback; rely on config-provided names + return `Slot ${profileId}`; + } + + async function pullFromDevice(deviceDetails, slot) { + console.log(`Nothing USB Serial: pulling EQ from device slot ${slot}`); + + // First, read the current EQ mode to determine which profile is active + let currentProfile = slot; + try { + currentProfile = await readEQMode(deviceDetails); + console.log(`Nothing USB Serial: detected active profile ${currentProfile}`); + } catch (error) { + console.warn(`Nothing USB Serial: could not read EQ mode, using requested slot ${slot}`); + currentProfile = slot; + } + + // For profiles that are not the first writable EQ slot, we can only read basic EQ settings + const firstWritableSlot = deviceDetails?.modelConfig?.firstWritableEQSlot ?? 5; + // we can only read basic EQ settings - these don't have detailed parametric EQ data + if (currentProfile !== firstWritableSlot) { + const profileName = getProfileName(deviceDetails, currentProfile); + console.log(`Nothing USB Serial: reading basic EQ for profile ${currentProfile} (${profileName})`); + + return { + filters: [], // Basic profiles don't expose individual filters + globalGain: 0, + profileId: currentProfile, + profileName: profileName, + isBasicProfile: true + }; + } + + // For Custom profile (first writable slot), read detailed EQ values + const customProfileName = getProfileName(deviceDetails, firstWritableSlot); + console.log(`Nothing USB Serial: reading EQ values for ${customProfileName} profile ${currentProfile}`); + const payload = toByteArray(0, 0, 1); + await sendCommand(deviceDetails, READ_COMMANDS.READ_EQ_VALUES, payload, "readEQValues"); + + // Read response + const response = await readResponse(deviceDetails); + if (!response || response.command !== RESPONSE_COMMANDS.EQ_VALUES) { + throw new Error("Failed to read EQ values from Nothing device"); + } + + // Parse EQ values response - based on readEQValues() from HTML + const hexArray = response.hexString.match(/.{2}/g).map(byte => parseInt(byte, 16)); + + if (hexArray.length < 10) { + throw new Error("EQ Values response too short"); + } + + let offset = 8; // Skip 8-byte protocol header + + const profileIndex = hexArray[offset++]; + const numBands = hexArray[offset++]; + + // Total gain (4 bytes as float, little-endian) + const totalGainBytes = hexArray.slice(offset, offset + 4); + const totalGain = bytesToFloat(totalGainBytes); + offset += 4; + + const filters = []; + + // Parse each EQ band (13 bytes each) + for (let i = 0; i < numBands && offset + 12 < hexArray.length; i++) { + const filterType = hexArray[offset++]; + + const gainBytes = hexArray.slice(offset, offset + 4); + const gain = Math.round(bytesToFloat(gainBytes) * 100)/100; + offset += 4; + + const freqBytes = hexArray.slice(offset, offset + 4); + const frequency = bytesToFloat(freqBytes); + offset += 4; + + const qualityBytes = hexArray.slice(offset, offset + 4); + const quality = bytesToFloat(qualityBytes); + const qFactorValue = Math.round(quality * 100)/100; + offset += 4; + + filters.push({ + freq: frequency, + gain: gain, + q: qFactorValue, + type: filterType === 0 ? "LSQ" : filterType === 2 ? "HSQ" : "PK" + }); + } + + const profileName = getProfileName(deviceDetails, currentProfile); + console.log(`Nothing USB Serial: pulled ${filters.length} filters with global gain ${totalGain} for ${profileName}`); + return { + filters, + globalGain: totalGain, + profileId: currentProfile, + profileName: profileName, + isBasicProfile: false + }; + } + + function createEQDataPacket(profileIndex, eqBands, totalGain = 0.0) { + // Based on Java obtainDataPacket() method + const numBands = eqBands ? eqBands.length : 0; + const packetSize = 1 + 1 + 4 + (numBands * 13); // profileIndex + numBands + totalGain + (bands * 13 bytes each) + + const packet = new Uint8Array(packetSize); + let offset = 0; + + // Profile index (1 byte) + packet[offset++] = profileIndex; + + // Number of bands (1 byte) + packet[offset++] = numBands; + + // Total gain (4 bytes as float, little-endian) + const totalGainBytes = floatToBytes(totalGain); + packet.set(totalGainBytes, offset); + offset += 4; + + // EQ bands data + if (eqBands) { + for (const band of eqBands) { + // Filter type (1 byte) + packet[offset++] = band.filterType; // Default to PEAK + + // Gain (4 bytes as float) + const gainBytes = floatToBytes(band.gain || 0.0); + packet.set(gainBytes, offset); + offset += 4; + + // Frequency (4 bytes as float) + const freqBytes = floatToBytes(band.frequency || 1000.0); + packet.set(freqBytes, offset); + offset += 4; + + // Quality (4 bytes as float) + const qualityBytes = floatToBytes(band.quality || 0.707); + packet.set(qualityBytes, offset); + offset += 4; + } + } + + return packet; + } + + async function pushToDevice(deviceDetails, phoneObj, slot, globalGain, filters) { + console.log(`Nothing USB Serial: pushing ${filters.length} filters to device slot ${slot}`); + + // Only the first writable slot supports writing EQ values + const firstWritableSlot = deviceDetails?.modelConfig?.firstWritableEQSlot ?? 5; + if (slot !== firstWritableSlot) { + const name = getProfileName(deviceDetails, firstWritableSlot); + throw new Error(`EQ writing only supported for ${name} (slot ${firstWritableSlot}), requested slot: ${slot}`); + } + + // Convert filters to the format expected by createEQDataPacket + const eqBands = filters.map(filter => ({ + filterType: filter.type === "LSQ" ? 0 : filter.type === "HSQ" ? 2 : 1, // PEAKING = 1 + gain: filter.gain, + frequency: filter.freq, + quality: filter.q + })); + + // Create EQ data packet using the provided logic + const packet = createEQDataPacket(0, eqBands, globalGain); // profileIndex 0 for Custom + const payload = Array.from(packet); + + console.log(`Nothing USB Serial: writing Custom EQ with ${filters.length} filters and global gain ${globalGain}`); + await sendCommand(deviceDetails, WRITE_COMMANDS.SET_ADVANCE_CUSTOM_EQ_VALUE, payload, "writeEQValues"); + + // Wait for response to confirm write was successful + const response = await readResponse(deviceDetails); + if (!response) { + throw new Error("No response received after writing EQ values"); + } + + console.log(`Nothing USB Serial: EQ values written successfully to Custom profile`); + } + + async function enablePEQ(device, enabled, slotId) { + // Nothing headphones don't have a separate PEQ enable/disable command + console.log(`Nothing USB Serial: PEQ enable/disable not applicable`); + } + + return { + getCurrentSlot, + pullFromDevice, + pushToDevice, + enablePEQ + }; +})(); diff --git a/assets/js/devicePEQ/plugin.js b/assets/js/devicePEQ/plugin.js new file mode 100644 index 00000000..8e0268a2 --- /dev/null +++ b/assets/js/devicePEQ/plugin.js @@ -0,0 +1,1547 @@ +// Copyright 2024 : Pragmatic Audio + +/** + * Initialise the plugin - passing the content from the extraEQ section so we can both query + * and update that area and add our UI elements. + * + * @param context + * @returns {Promise} + */ +async function initializeDeviceEqPlugin(context) { + // Initialize console log history array if it doesn't exist + if (!window.consoleLogHistory) { + window.consoleLogHistory = []; + + // Store original console methods + const originalConsoleLog = console.log; + const originalConsoleError = console.error; + const originalConsoleWarn = console.warn; + + // Flag to control logging visibility + window.showDeviceLogs = false; + + // Override console.log to capture logs + console.log = function() { + // Convert arguments to string and add to history + const logString = Array.from(arguments).map(arg => + typeof arg === 'object' ? JSON.stringify(arg) : String(arg) + ).join(' '); + window.consoleLogHistory.push(`[LOG] ${logString}`); + + // Call original method only if showLogs is true or we have an experimental device + if (window.showDeviceLogs) { + originalConsoleLog.apply(console, arguments); + } + }; + + // Override console.error to capture errors + console.error = function() { + // Convert arguments to string and add to history + const logString = Array.from(arguments).map(arg => + typeof arg === 'object' ? JSON.stringify(arg) : String(arg) + ).join(' '); + window.consoleLogHistory.push(`[ERROR] ${logString}`); + + // Always show errors regardless of log settings + originalConsoleError.apply(console, arguments); + }; + + // Override console.warn to capture warnings + console.warn = function() { + // Convert arguments to string and add to history + const logString = Array.from(arguments).map(arg => + typeof arg === 'object' ? JSON.stringify(arg) : String(arg) + ).join(' '); + window.consoleLogHistory.push(`[WARN] ${logString}`); + + // Always show warnings regardless of log settings + originalConsoleWarn.apply(console, arguments); + }; + + // Limit history to last 500 entries + const MAX_LOG_HISTORY = 500; + setInterval(() => { + if (window.consoleLogHistory.length > MAX_LOG_HISTORY) { + window.consoleLogHistory = window.consoleLogHistory.slice(-MAX_LOG_HISTORY); + } + }, 10000); // Check every 10 seconds + } + + // Check if showLogs flag is passed in context + if (context && context.config && context.config.showLogs === true) { + window.showDeviceLogs = true; + console.log("Plugin initialized with showLogs enabled"); + } else { + console.log("Plugin initialized with context:", context); + } + + class DeviceEqUI { + constructor() { + this.deviceEqArea = document.getElementById('deviceEqArea'); + this.connectButton = this.deviceEqArea.querySelector('.connect-device'); + this.disconnectButton = this.deviceEqArea.querySelector('.disconnect-device'); + this.deviceNameElem = document.getElementById('deviceName'); + this.peqSlotArea = this.deviceEqArea.querySelector('.peq-slot-area'); + this.peqDropdown = document.getElementById('device-peq-slot-dropdown'); + this.pullButton = this.deviceEqArea.querySelector('.pull-filters-fromdevice'); + this.pushButton = this.deviceEqArea.querySelector('.push-filters-todevice'); + this.lastPushTime = 0; // Track when the push button was last clicked + + this.useNetwork = false; + this.currentDevice = null; + this.initializeUI(); + } + + initializeUI() { + this.disconnectButton.hidden = true; + this.pullButton.hidden = true; + this.pushButton.hidden = true; + this.peqDropdown.hidden = true; + this.peqSlotArea.hidden = true; + } + + showConnectedState(device, connectionType, availableSlots, currentSlot) { + this.connectButton.hidden = true; + this.currentDevice = device; + this.connectionType = connectionType; + this.disconnectButton.hidden = false; + this.deviceNameElem.textContent = device.model; + this.populatePeqDropdown(availableSlots, currentSlot); + this.pullButton.hidden = false; + this.pushButton.hidden = false; + this.peqDropdown.hidden = false; + this.peqSlotArea.hidden = false; + + // Check if the push button should still be disabled based on lastPushTime + const currentTime = Math.floor(Date.now() / 1000); + const cooldownTime = 0.2; // Cooldown time in seconds (200ms) + + if (currentTime < this.lastPushTime + cooldownTime) { + // Button is still in cooldown period + this.pushButton.disabled = true; + this.pushButton.style.opacity = "0.5"; + this.pushButton.style.cursor = "not-allowed"; + + // Set a new timeout for the remaining cooldown time + const remainingTime = (this.lastPushTime + cooldownTime) - currentTime; + setTimeout(() => { + this.pushButton.disabled = false; + this.pushButton.style.opacity = ""; + this.pushButton.style.cursor = ""; + console.log("Push button re-enabled after cooldown period"); + }, remainingTime * 1000); // Convert seconds to milliseconds + } + } + + showDisconnectedState() { + this.connectionType = "usb"; // Assume usb + this.currentDevice = null; + this.connectButton.hidden = false; + this.disconnectButton.hidden = true; + this.deviceNameElem.textContent = 'None'; + this.peqDropdown.innerHTML = ''; + this.peqDropdown.hidden = true; + this.pullButton.hidden = true; + this.pushButton.hidden = true; + this.peqSlotArea.hidden = true; + } + + populatePeqDropdown(slots, currentSlot) { + // Clear existing options and add the default "PEQ Disabled" option + this.peqDropdown.innerHTML = ''; + + // Populate the dropdown with available slots + slots.forEach(slot => { + const option = document.createElement('option'); + option.value = slot.id; + option.textContent = slot.name; + this.peqDropdown.appendChild(option); + }); + + // Set the selected option based on currentSlot + if (currentSlot === -1) { + // Select "PEQ Disabled" + this.peqDropdown.selectedIndex = 0; + } else { + // Attempt to select the option matching currentSlot + const matchingOption = Array.from(this.peqDropdown.options).find(option => option.value === String(currentSlot)); + if (matchingOption) { + this.peqDropdown.value = currentSlot; + } else { + // If no matching option, default to "PEQ Disabled" + this.peqDropdown.selectedIndex = 0; + } + } + } + } + + // Function to show toast messages + // Parameters: + // - message: The text message to display + // - type: The type of toast (success, error, warning) with default 'success' + // - timeout: The time in milliseconds before the toast disappears (default 5000ms) + // - requireClick: If true, adds a "Continue" button that must be clicked to dismiss the toast (ignores timeout) + // and returns a Promise that resolves when the button is clicked + // + // Example usage with await to block execution until user clicks Continue: + // async function someFunction() { + // // Show a toast and wait for user to click Continue + // await showToast("Please confirm to continue", "warning", 0, true); + // // Code here will only execute after the user clicks Continue + // console.log("User clicked Continue"); + // } + function showToast(message, type = 'success', timeout = 5000, requireClick = false) { + return new Promise((resolve) => { + // Create toast element + const toast = document.createElement('div'); + toast.id = `device-toast-${type}`; // Type-specific ID + + // Create message container + const messageContainer = document.createElement('div'); + messageContainer.textContent = message; + toast.appendChild(messageContainer); + + // Set style based on type + if (type === 'success') { + toast.style.backgroundColor = '#4CAF50'; // Green + toast.style.bottom = '80px'; // Bottom position for success + } else if (type === 'error') { + toast.style.backgroundColor = '#F44336'; // Red + toast.style.top = '30px'; // Top position for error + toast.style.bottom = 'auto'; // Override bottom + } else if (type === 'warning') { + toast.style.backgroundColor = '#FF9800'; // Orange + toast.style.bottom = '30px'; // Bottom position for warning + } + + // Common styles + toast.style.color = 'white'; + toast.style.padding = '16px'; + toast.style.borderRadius = '4px'; + toast.style.position = 'fixed'; + toast.style.zIndex = '10000'; + toast.style.left = '50%'; + toast.style.transform = 'translateX(-50%)'; + toast.style.minWidth = '250px'; + toast.style.textAlign = 'center'; + toast.style.boxShadow = '0 2px 5px rgba(0,0,0,0.3)'; + + // Check for existing toast of the same type + const existingToast = document.getElementById(`device-toast-${type}`); + if (existingToast) { + // Check if the existing toast has a continue button (requireClick=true) + const continueButton = existingToast.querySelector('button'); + if (continueButton) { + // If there's an existing toast with a continue button, return early + // to allow the user to interact with it + return resolve(); // Resolve immediately since we're not showing a new toast + } + document.body.removeChild(existingToast); + } + + // If requireClick is true, add a continue button + if (requireClick) { + // Add a continue button + const continueButton = document.createElement('button'); + continueButton.textContent = 'Click here to Continue'; + continueButton.style.marginTop = '10px'; + continueButton.style.padding = '5px 15px'; + continueButton.style.backgroundColor = 'white'; + continueButton.style.color = toast.style.backgroundColor; + continueButton.style.border = 'none'; + continueButton.style.borderRadius = '3px'; + continueButton.style.cursor = 'pointer'; + continueButton.style.fontWeight = 'bold'; + + // Add click event to remove the toast and resolve the promise + continueButton.addEventListener('click', () => { + if (document.body.contains(toast)) { + document.body.removeChild(toast); + } + resolve(); // Resolve the promise when the button is clicked + }); + + toast.appendChild(continueButton); + } else { + // Auto remove after xx seconds if requireClick is false + setTimeout(() => { + if (document.body.contains(toast)) { + document.body.removeChild(toast); + } + resolve(); // Resolve the promise when the toast is automatically removed + }, timeout); + } + + // Add to document + document.body.appendChild(toast); + }); + } + + // Make showToast globally accessible for handlers + window.showToast = showToast; + + function loadHtml() { + // Set default values for configuration + var headingTag = 'h4'; + + // Override with context config values if available + if (context && context.config) { + if (context.config.devicePEQHeadingTag) { + headingTag = context.config.devicePEQHeadingTag; + } + } + // Define the HTML to insert + const deviceEqHTML = ` +
+ + <${headingTag}>Device PEQ +
+ + + + +
+
+ +
+
+ + +
+
+ + + `; + // More flexible way to insert HTML into the DOM + var placement = 'afterend'; + var anchorDiv = '.extra-eq'; + if (context && context.config ) { + if (context.config.devicePEQPlacement) { + placement = context.config.devicePEQPlacement; + } + if (context.config.devicePEQAnchorDiv) { + anchorDiv = context.config.devicePEQAnchorDiv; + } + } + + // Find the
element + const extraEqElement = document.querySelector(anchorDiv); + + if (extraEqElement) { + // Insert the new HTML below the "extra-eq" div + extraEqElement.insertAdjacentHTML(placement, deviceEqHTML); + console.log('Device EQ UI added ' + placement + '
'); + } else { + console.error('Element
not found in the DOM.'); + } +// Open modal + document.getElementById('deviceInfoBtn').addEventListener('click', () => { + document.getElementById('deviceInfoModal').classList.remove('hidden'); + }); + +// Close modal via close button + document.getElementById('closeModalBtn').addEventListener('click', () => { + document.getElementById('deviceInfoModal').classList.add('hidden'); + }); + +// Optional: close modal when clicking outside content + document.getElementById('deviceInfoModal').addEventListener('click', (e) => { + if (e.target.id === 'deviceInfoModal') { + document.getElementById('deviceInfoModal').classList.add('hidden'); + } + }); + + document.querySelectorAll(".tab-button").forEach(btn => { + btn.addEventListener("click", () => { + // Toggle active tab button + document.querySelectorAll(".tab-button").forEach(b => b.classList.remove("active")); + btn.classList.add("active"); + + // Show correct tab content + const tabId = btn.getAttribute("data-tab"); + document.querySelectorAll(".tab-content").forEach(c => c.classList.remove("active")); + document.getElementById(tabId).classList.add("active"); + }); + }); + + document.querySelectorAll(".sub-tab-button").forEach(button => { + button.addEventListener("click", () => { + // Update button state + document.querySelectorAll(".sub-tab-button").forEach(b => b.classList.remove("active")); + button.classList.add("active"); + + // Show corresponding sub-tab + const tabId = button.getAttribute("data-subtab"); + document.querySelectorAll(".sub-tab-content").forEach(c => c.classList.remove("active")); + document.getElementById(tabId).classList.add("active"); + }); + }); + + // Function to collect recent console logs + function collectConsoleLogs() { + // Return the last 100 console logs that contain plugin-related keywords + if (!window.consoleLogHistory) { + return "No console logs available"; + } + + // Filter logs related to the plugin + const pluginLogs = window.consoleLogHistory.filter(log => + log.includes("Device") || + log.includes("PEQ") || + log.includes("USB") || + log.includes("plugin") || + log.includes("connector") + ); + + // Return the last 100 logs or all if less than 100 + return pluginLogs.slice(-100).join("\n"); + } + + // Set up feedback form submission + document.getElementById("modal-feedback-button").addEventListener("click", () => { + // Get values from form elements + const includeLogsCheckbox = document.getElementById("modal-include-logs-checkbox"); + const isWorkingCheckbox = document.getElementById("modal-is-working-checkbox"); + const deviceNameInput = document.getElementById("modal-device-name-input"); + const commentsInput = document.getElementById("modal-comments-input"); + const statusContainer = document.getElementById("modal-feedback-status"); + + // If console log is empty, capture it now + let logs = ""; + if (includeLogsCheckbox && includeLogsCheckbox.checked) { + logs = collectConsoleLogs(); + } + + // Show status message + statusContainer.style.display = "block"; + statusContainer.style.padding = "8px"; + statusContainer.style.borderRadius = "4px"; + statusContainer.style.textAlign = "center"; + statusContainer.style.backgroundColor = "#f8f9fa"; + statusContainer.style.color = "#333"; + statusContainer.textContent = "Submitting your feedback..."; + + // Submit to Google Form + submitFeedbackToGoogleForm( + deviceNameInput && deviceNameInput.value ? deviceNameInput.value : "Not specified", + commentsInput, + logs, + isWorkingCheckbox && isWorkingCheckbox.checked, + statusContainer + ); + }); + + async function submitFeedbackToGoogleForm(deviceName, comments, logs, isWorking, statusContainer) { + const formData = new URLSearchParams(); + formData.append('entry.1909598303', deviceName); + formData.append('entry.1928983035', comments && comments.value ? comments.value : "No comments provided"); + formData.append('entry.466843002', logs || "No logs available"); + formData.append('entry.1088832316', isWorking ? "Working" : "Not Working"); + + try { + const response = await fetch('https://docs.google.com/forms/d/e/1FAIpQLSfSaNpdpAvd39tOupDqzyUW_aFEVawywAz4xls4m1z2_T3BOQ/formResponse', { + method: 'POST', + mode: 'no-cors', // Google Forms requires no-cors mode + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: formData.toString() + }); + + // Note: With no-cors mode, we can't access the response details + // But we can assume it worked if no error was thrown + console.log("Google Form Submission Completed"); + + statusContainer.style.backgroundColor = "#d4edda"; + statusContainer.style.color = "#155724"; + statusContainer.textContent = "Thank you for your feedback!"; + + setTimeout(() => { + statusContainer.style.display = "none"; + }, 3000); + + } catch (error) { + console.error("Error submitting to Google Form:", error); + statusContainer.style.backgroundColor = "#f8d7da"; + statusContainer.style.color = "#721c24"; + statusContainer.textContent = "Failed to submit feedback."; + } + } + } + + try { + // Dynamically import USB and Network connectors + const UsbHIDConnectorAsync = await import('./usbHidConnector.js').then((module) => module.UsbHIDConnector); + const UsbHIDConnector = await UsbHIDConnectorAsync; + console.log('UsbHIDConnector loaded'); + + const UsbSerialConnectorAsync = await import('./usbSerialConnector.js').then((module) => module.UsbSerialConnector); + const UsbSerialConnector = await UsbSerialConnectorAsync; + console.log('UsbSerialConnector loaded'); + + const NetworkDeviceConnectorAsync = await import('./networkDeviceConnector.js').then((module) => module.NetworkDeviceConnector); + const NetworkDeviceConnector = await NetworkDeviceConnectorAsync; + console.log('NetworkDeviceConnector loaded'); + + if ('hid' in navigator) { // Only support browsers with HID support for now + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', () => initializeDeviceEQ()); + } else { + // DOM is already loaded + initializeDeviceEQ(); + } + + function initializeDeviceEQ() { + // Dynamically load the HTML we need in the right place + loadHtml(); + + const deviceEqUI = new DeviceEqUI(); + + // Show the Connect button if WebHID is supported + deviceEqUI.deviceEqArea.classList.remove('disabled'); + deviceEqUI.connectButton.hidden = false; + deviceEqUI.disconnectButton.hidden = true; + + // Connect Button Event Listener + deviceEqUI.connectButton.addEventListener('click', async () => { + try { + let selection = {connectionType: "usb"}; // Assume usb only by default + if (context.config.advanced) { + // Show a custom dialog to select Network or USB + selection = await showDeviceSelectionDialog(); + } + + if (selection.connectionType == "network") { + if (!selection.ipAddress) { + showToast("Please enter a valid IP address.", "error"); + return; + } + setCookie("networkDeviceIP", selection.ipAddress, 30); // Save IP for 30 days + setCookie("networkDeviceType", selection.deviceType, 30); // Store device type for 30 days + + // Connect via Network using the provided IP + const device = await NetworkDeviceConnector.getDeviceConnected(selection.ipAddress, selection.deviceType); + if (device?.handler == null) { + showToast("Sorry, this network device is not currently supported.", "error"); + await NetworkDeviceConnector.disconnectDevice(); + return; + } + if (device) { + deviceEqUI.showConnectedState( + device, + selection.connectionType, + await NetworkDeviceConnector.getAvailableSlots(device), + await NetworkDeviceConnector.getCurrentSlot(device) + ); + + // Check if device supports fewer filters than currently in context + const currentFilters = context.elemToFilters(true); + if (currentFilters.length > device.modelConfig.maxFilters) { + console.warn(`Device only supports ${device.modelConfig.maxFilters} PEQ filters but ${currentFilters.length} filters are currently loaded`); + if (window.showToast) { + await window.showToast(`Warning: This device only supports ${device.modelConfig.maxFilters} PEQ filters, but you currently have ${currentFilters.length} filters loaded. Only the first ${device.modelConfig.maxFilters} will be applied when pushed.`, "warning", 10000, true); + } + } + } + } else if (selection.connectionType == "usb") { + // Connect via USB and show the HID device picker + const device = await UsbHIDConnector.getDeviceConnected(); + // If the user cancelled the chooser, just exit silently + if (device?.cancelled) { + return; + } + // If device is explicitly marked unsupported or has no handler, show unsupported toast + if (device?.unsupported || device?.handler == null) { + showToast("Sorry, this USB device is not currently supported.", "error"); + await UsbHIDConnector.disconnectDevice(); + return; + } + if (device) { + // Check if the device is experimental + const isExperimental = device.modelConfig?.experimental === true; + + if (isExperimental) { + // Enable logs for experimental devices + showDeviceLogs = true; + console.log(`Enabling detailed logs for experimental device: ${device.model}`); + + // Show warning popup for experimental devices + const proceedWithConnection = await showExperimentalDeviceWarning(device.model); + if (!proceedWithConnection) { + await UsbHIDConnector.disconnectDevice(); + return; + } + } + + deviceEqUI.showConnectedState( + device, + selection.connectionType, + await UsbHIDConnector.getAvailableSlots(device), + await UsbHIDConnector.getCurrentSlot(device) + ); + + // Check if device supports fewer filters than currently in context + const currentFilters = context.elemToFilters(true); + if (currentFilters.length > device.modelConfig.maxFilters) { + console.warn(`Device only supports ${device.modelConfig.maxFilters} PEQ filters but ${currentFilters.length} filters are currently loaded`); + if (window.showToast) { + await window.showToast(`Warning: This device only supports ${device.modelConfig.maxFilters} PEQ filters, but you currently have ${currentFilters.length} filters loaded. Only the first ${device.modelConfig.maxFilters} will be applied when pushed.`, "warning", 10000, true); + } + } + + device.rawDevice.addEventListener('disconnect', () => { + console.log(`Device ${device.rawDevice.productName} disconnected.`); + deviceEqUI.showDisconnectedState(); + }); + } + } else if (selection.connectionType == "serial") { + // Connect via USB and show the Serial device picker + const device = await UsbSerialConnector.getDeviceConnected(); + // If the user cancelled the chooser, just exit silently + if (device?.cancelled) { + return; + } + if (device?.handler == null) { + showToast("Sorry, this USB Serial device is not currently supported.", "error"); + await UsbSerialConnector.disconnectDevice(); + return; + } + if (device) { + // Check if the device is experimental + const isExperimental = device.modelConfig?.experimental === true; + + if (isExperimental) { + // Enable logs for experimental devices + window.showDeviceLogs = true; + console.log(`Enabling detailed logs for experimental serial device: ${device.model}`); + + // Show warning popup for experimental devices + const proceedWithConnection = await showExperimentalDeviceWarning(device.model); + if (!proceedWithConnection) { + await UsbSerialConnector.disconnectDevice(); + return; + } + } + + deviceEqUI.showConnectedState( + device, + selection.connectionType, + await UsbSerialConnector.getAvailableSlots(device), + await UsbSerialConnector.getCurrentSlot(device) + ); + + // Check if device supports fewer filters than currently in context + const currentFilters = context.elemToFilters(true); + if (currentFilters.length > device.modelConfig.maxFilters) { + console.warn(`Device only supports ${device.modelConfig.maxFilters} PEQ filters but ${currentFilters.length} filters are currently loaded`); + if (window.showToast) { + await window.showToast(`Warning: This device only supports ${device.modelConfig.maxFilters} PEQ filters, but you currently have ${currentFilters.length} filters loaded. Only the first ${device.modelConfig.maxFilters} will be applied when pushed.`, "warning", 10000, true); + } + } + + device.rawDevice.addEventListener('disconnect', () => { + console.log(`Device ${device.rawDevice.productName} disconnected.`); + deviceEqUI.showDisconnectedState(); + }); + } + } + } catch (error) { + console.error("Error connecting to device:", error); + showToast("Failed to connect to the device.", "error"); + } + }); + + + // Cookie functions + function setCookie(name, value, days) { + let expires = ""; + if (days) { + const date = new Date(); + date.setTime(date.getTime() + days * 24 * 60 * 60 * 1000); + expires = "; expires=" + date.toUTCString(); + } + document.cookie = name + "=" + value + "; path=/" + expires; + } + + function getCookie(name) { + const nameEQ = name + "="; + const cookies = document.cookie.split(';'); + for (let i = 0; i < cookies.length; i++) { + let c = cookies[i]; + while (c.charAt(0) === ' ') c = c.substring(1, c.length); + if (c.indexOf(nameEQ) === 0) return c.substring(nameEQ.length, c.length); + } + return null; + } + + function deleteCookie(name) { + document.cookie = name + "=; path=/; expires=Thu, 01 Jan 1970 00:00:00 UTC"; + } + + // Function to show warning for experimental devices + function showExperimentalDeviceWarning(deviceName) { + return new Promise((resolve) => { + const dialogHTML = ` +
+

Experimental Device Warning

+

+ ${deviceName} is marked as an experimental device. + This means it hasn't been fully tested and while it may work perfectly, it may not work as expected. +

+

+ If the device is working for you please consider submiting feedback below, and we will mark it as not experimental in the next release. + If you noticed any issues, please disconnect the device and then come back here and submit feedback below. +

+

+ Would you like to proceed with the connection anyway? +

+ +
+ + +
+ +
+

+ Help us improve! If you proceed, please consider providing feedback: +

+
+ + +
+
+ + +
+
+ + +
+ +
+
+ `; + + // Force checkboxes + const styleFix = document.createElement("style"); + styleFix.innerHTML = ` + input[type="checkbox"] { + appearance: auto !important; + -webkit-appearance: auto !important; + width: 16px; + height: 16px; + vertical-align: middle; + } + `; + document.head.appendChild(styleFix); + + const dialogContainer = document.createElement("div"); + dialogContainer.innerHTML = dialogHTML; + document.body.appendChild(dialogContainer); + + // Proceed button + document.getElementById("proceed-button").addEventListener("click", () => { + document.body.removeChild(dialogContainer); + resolve(true); + }); + + // Cancel button + document.getElementById("cancel-button").addEventListener("click", () => { + document.body.removeChild(dialogContainer); + resolve(false); + }); + + // Function to collect recent console logs + function collectConsoleLogs() { + // Return the last 100 console logs that contain plugin-related keywords + if (!window.consoleLogHistory) { + return "No console logs available"; + } + + // Filter logs related to the plugin + const pluginLogs = window.consoleLogHistory.filter(log => + log.includes("Device") || + log.includes("PEQ") || + log.includes("USB") || + log.includes("plugin") || + log.includes("connector") + ); + + // Return the last 100 logs or all if less than 100 + return pluginLogs.slice(-100).join("\n"); + } + + // Feedback button + document.getElementById("feedback-button").addEventListener("click", () => { + // Get values from form elements + const includeLogsCheckbox = document.getElementById("include-logs-checkbox"); + const isWorkingCheckbox = document.getElementById("is-working-checkbox"); + const commentsInput = document.getElementById("comments-input"); + + // If console log is empty, capture it now + let logs = ""; + if (includeLogsCheckbox && includeLogsCheckbox.checked) { + logs = collectConsoleLogs(); + } + + // Show status message + const statusContainer = document.createElement("div"); + statusContainer.style.marginTop = "10px"; + statusContainer.style.padding = "8px"; + statusContainer.style.borderRadius = "4px"; + statusContainer.style.textAlign = "center"; + statusContainer.style.backgroundColor = "#f8f9fa"; + statusContainer.style.color = "#333"; + statusContainer.textContent = "Submitting your feedback..."; + + // Add status container after the feedback button + document.getElementById("feedback-button").insertAdjacentElement('afterend', statusContainer); + + // Submit to Google Form + submitToGoogleFormProxy(deviceName, commentsInput, logs, isWorkingCheckbox && isWorkingCheckbox.checked, statusContainer); + }); + + async function submitToGoogleFormProxy(deviceName, comments, logs, isWorking, statusContainer) { + const formData = new URLSearchParams(); + formData.append('entry.1909598303', deviceName); + formData.append('entry.1928983035', comments && comments.value ? comments.value : "No comments provided"); + formData.append('entry.466843002', logs || "No logs available"); + formData.append('entry.1088832316', isWorking ? "Working" : "Not Working"); + + try { + const response = await fetch('https://docs.google.com/forms/d/e/1FAIpQLSfSaNpdpAvd39tOupDqzyUW_aFEVawywAz4xls4m1z2_T3BOQ/formResponse', { + method: 'POST', + mode: 'no-cors', // Google Forms requires no-cors mode + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: formData.toString() + }); + + // Note: With no-cors mode, we can't access the response details + // But we can assume it worked if no error was thrown + console.log("Google Form Submission Completed"); + + statusContainer.style.backgroundColor = "#d4edda"; + statusContainer.style.color = "#155724"; + statusContainer.textContent = "Thank you for your feedback!"; + + setTimeout(() => { + if (statusContainer.parentNode) { + statusContainer.parentNode.removeChild(statusContainer); + } + }, 3000); + + } catch (error) { + console.error("Error submitting to Google Form Proxy:", error); + statusContainer.style.backgroundColor = "#f8d7da"; + statusContainer.style.color = "#721c24"; + statusContainer.textContent = "Failed to submit feedback."; + } + } + }); + } + + function showDeviceSelectionDialog() { + return new Promise((resolve) => { + const storedIP = getCookie("networkDeviceIP") || ""; + const storedDeviceType = getCookie("networkDeviceType") || "WiiM"; + + const dialogHTML = ` +
+

Select Connection Type

+

Choose how you want to connect to your device.

+ + +
+ + + +
+ + + + + + + + +
+ + +
+ `; + + const dialogContainer = document.createElement("div"); + dialogContainer.innerHTML = dialogHTML; + document.body.appendChild(dialogContainer); + + const ipInput = document.getElementById("ip-input"); + const networkOptions = document.getElementById("network-options"); + const submitButton = document.getElementById("submit-button"); + const testIpButton = document.getElementById("test-ip-button"); + const helpWiim = document.getElementById("help-wiim"); + const helpLuxsin = document.getElementById("help-luxsin"); + // Event: USB HID + document.getElementById("usb-hid-button").addEventListener("click", () => { + document.body.removeChild(dialogContainer); + resolve({ connectionType: "usb" }); + }); + + // Event: USB Serial + document.getElementById("usb-serial-button").addEventListener("click", () => { + document.body.removeChild(dialogContainer); + resolve({ connectionType: "serial" }); + }); + + // Event: Network + document.getElementById("network-button").addEventListener("click", () => { + ipInput.style.display = "block"; + networkOptions.style.display = "block"; + submitButton.style.display = "inline-block"; + // Initialize help visibility based on stored device type + const selectedDevice = document.querySelector('input[name="network-device"]:checked')?.value || "WiiM"; + helpWiim.style.display = selectedDevice === "WiiM" ? "block" : "none"; + helpLuxsin.style.display = selectedDevice === "Luxsin" ? "block" : "none"; + }); + + // Watch for IP input to show the Test IP button + ipInput.addEventListener("input", () => { + const ip = ipInput.value.trim(); + const isValid = /^(\d{1,3}\.){3}\d{1,3}$/.test(ip); // basic IPv4 validation + testIpButton.style.display = isValid ? "inline-block" : "none"; + submitButton.style.display = isValid ? "inline-block" : "none"; + }); + + // Switch help text and test button hint when selecting device type + const deviceRadios = document.querySelectorAll('input[name="network-device"]'); + // Defensive: Force radios to render even if host app CSS sets appearance:none + deviceRadios.forEach(r => { + try { + r.style.setProperty('appearance', 'auto', 'important'); + r.style.setProperty('-webkit-appearance', 'radio', 'important'); + r.style.setProperty('-moz-appearance', 'radio', 'important'); + r.style.setProperty('accent-color', '#007BFF'); + r.type = 'radio'; // ensure input remains radio + } catch (e) { /* ignore */ } + r.addEventListener('change', () => { + const val = document.querySelector('input[name="network-device"]:checked')?.value || 'WiiM'; + helpWiim.style.display = val === 'WiiM' ? 'block' : 'none'; + helpLuxsin.style.display = val === 'Luxsin' ? 'block' : 'none'; + // Update button label hint + if (testIpButton.style.display !== 'none') { + testIpButton.textContent = val === 'WiiM' ? 'Open WiiM Status (HTTPS)' : 'Open Luxsin Sync Data (HTTP)'; + } + }); + }); + + // Handle Test IP Button Click + testIpButton.addEventListener("click", () => { + const ip = ipInput.value.trim(); + if (!ip) return; + const selectedDevice = document.querySelector('input[name="network-device"]:checked')?.value || "WiiM"; + if (selectedDevice === 'WiiM') { + const confirmProceed = confirm(`This will open a new tab to https://${ip}.\nIf your browser shows technical info, you've already accepted the certificate. If you see a security warning (e.g., ERR_CERT_AUTHORITY_INVALID), accept the self-signed certificate (issued by Linkplay) via Advanced to proceed.`); + if (confirmProceed) { + window.open(`https://${ip}/httpapi.asp?command=getStatusEx`, "_blank", "noopener,noreferrer"); + } + } else if (selectedDevice === 'Luxsin') { + const confirmProceed = confirm(`This will open a new tab to http://${ip}/dev/info.cgi?action=syncData.\nIf the IP is correct, you should see encoded text/plain content returned by the device.`); + if (confirmProceed) { + window.open(`http://${ip}/dev/info.cgi?action=syncData`, "_blank", "noopener,noreferrer"); + } + } + }); + + // Submit Network + submitButton.addEventListener("click", () => { + const ip = ipInput.value.trim(); + if (!ip) { + showToast("Please enter a valid IP address.", "error"); + return; + } + + const selectedDevice = document.querySelector('input[name="network-device"]:checked')?.value || "WiiM"; + document.body.removeChild(dialogContainer); + resolve({ connectionType: "network", ipAddress: ip, deviceType: selectedDevice }); + }); + + // Cancel + document.getElementById("cancel-button").addEventListener("click", () => { + document.body.removeChild(dialogContainer); + resolve({connectionType: "none"}); + }); + }); + } + + + // Disconnect Button Event Listener + deviceEqUI.disconnectButton.addEventListener('click', async () => { + try { + if (deviceEqUI.connectionType == "network") { + await NetworkDeviceConnector.disconnectDevice(); + } else if (deviceEqUI.connectionType == "usb") { + await UsbHIDConnector.disconnectDevice(); + } else if (deviceEqUI.connectionType == "serial") { + await UsbSerialConnector.disconnectDevice(); + } + deviceEqUI.showDisconnectedState(); + } catch (error) { + console.error("Error disconnecting:", error); + showToast("Failed to disconnect.", "error"); + } + }); + + // Pull Button Event Listener + deviceEqUI.pullButton.addEventListener('click', async () => { + try { + const device = deviceEqUI.currentDevice; + const selectedSlot = deviceEqUI.peqDropdown.value; + if (!device || !selectedSlot) { + showToast("No device connected or PEQ slot selected.", "error"); + return; + } + var result = null; + if (deviceEqUI.connectionType == "network") { + result = await NetworkDeviceConnector.pullFromDevice(device, selectedSlot); + } else if (deviceEqUI.connectionType == "usb") { + result = await UsbHIDConnector.pullFromDevice(device, selectedSlot); + } else if (deviceEqUI.connectionType == "serial") { + result = await UsbSerialConnector.pullFromDevice(device, selectedSlot); + } + + // Check if we have a timeout but still received some filters + if (result.filters.length > 0) { + // Normal case - all filters received + context.filtersToElem(result.filters); + context.applyEQ(); + showToast("PEQ filters successfully pulled from device.", "success"); + } else { + showToast("No PEQ filters found on the device.", "warning"); + } + } catch (error) { + console.error("Error pulling PEQ filters:", error); + showToast("Failed to pull PEQ filters from device.", "error"); + + if (deviceEqUI.connectionType == "network") { + await NetworkDeviceConnector.disconnectDevice(); + } else if (deviceEqUI.connectionType == "usb") { + await UsbHIDConnector.disconnectDevice(); + } else if (deviceEqUI.connectionType == "serial") { + await UsbSerialConnector.disconnectDevice(); + } + deviceEqUI.showDisconnectedState(); + } + }); + + // Push Button Event Listener + deviceEqUI.pushButton.addEventListener('click', async () => { + try { + // Check if the button is in cooldown period + const currentTime = Math.floor(Date.now() / 1000); // Current time in seconds + const cooldownTime = 0.2; // Cooldown period in seconds (200ms) + + if (currentTime < deviceEqUI.lastPushTime + cooldownTime) { + const remainingTime = (deviceEqUI.lastPushTime + cooldownTime) - currentTime; + const remainingMinutes = Math.floor(remainingTime / 60); + const remainingSeconds = remainingTime % 60; + return; + } + + const device = deviceEqUI.currentDevice; + var selectedSlot = deviceEqUI.peqDropdown.value; + if (!device || !selectedSlot) { + showToast("No device connected or PEQ slot selected.", "error"); + return; + } + if (typeof selectedSlot === 'string' && !isNaN(parseInt(selectedSlot, 10))) { + selectedSlot = parseInt(selectedSlot, 10); + } + + + // ✅ Use context to get filters instead of undefined elemToFilters() + const filters = context.elemToFilters(true); + if (!filters.length) { + showToast("Please add at least one filter before pushing.", "error"); + return; + } + // Make sure that the phoneObj is set + if (typeof context.applyEQ === 'function') { + context.applyEQ(); + } + const preamp_gain = context.calcEqDevPreamp(filters); + let disconnect = false; + // Optional: pass phoneObj (e.g., contains fileName) down to connectors/handlers + const phoneTargetDetails = (typeof context.getCurrentPhoneTargetNormalisation === 'function') + ? (await context.getCurrentPhoneTargetNormalisation()) + : null; + const phoneObj = phoneTargetDetails?.phoneObj; + if (deviceEqUI.connectionType == "network") { + disconnect = await NetworkDeviceConnector.pushToDevice(device, phoneObj, selectedSlot, preamp_gain, filters); + } else if (deviceEqUI.connectionType == "usb") { + disconnect = await UsbHIDConnector.pushToDevice(device, phoneObj, selectedSlot, preamp_gain, filters); + } else if (deviceEqUI.connectionType == "serial") { + disconnect = await UsbSerialConnector.pushToDevice(device, phoneObj, selectedSlot, preamp_gain, filters); + } + + if (disconnect) { + if (deviceEqUI.connectionType == "network") { + await NetworkDeviceConnector.disconnectDevice(); + } else if (deviceEqUI.connectionType == "usb") { + await UsbHIDConnector.disconnectDevice(); + } else if (deviceEqUI.connectionType == "serial") { + await UsbSerialConnector.disconnectDevice(); + } + deviceEqUI.showDisconnectedState(); + showToast("PEQ Saved - Restarting", "success"); + } else { + showToast("PEQ Successfully pushed to device", "success"); + } + + // Set the last push time to current time and disable the button + deviceEqUI.lastPushTime = Math.floor(Date.now() / 1000); + deviceEqUI.pushButton.disabled = true; + deviceEqUI.pushButton.style.opacity = "0.5"; + deviceEqUI.pushButton.style.cursor = "not-allowed"; + + // Set a timeout to re-enable the button after the cooldown period + setTimeout(() => { + deviceEqUI.pushButton.disabled = false; + deviceEqUI.pushButton.style.opacity = ""; + deviceEqUI.pushButton.style.cursor = ""; + console.log("Push button re-enabled after cooldown period"); + }, 200); // 200ms timeout as requested + } catch (error) { + console.error("Error pushing PEQ filters:", error); + showToast("Failed to push PEQ filters to device.", "error"); + + if (deviceEqUI.connectionType == "network") { + await NetworkDeviceConnector.disconnectDevice(); + } else if (deviceEqUI.connectionType == "usb") { + await UsbHIDConnector.disconnectDevice(); + } else if (deviceEqUI.connectionType == "serial") { + await UsbSerialConnector.disconnectDevice(); + } + deviceEqUI.showDisconnectedState(); + } + }); + + // PEQ Dropdown Change Event Listener + deviceEqUI.peqDropdown.addEventListener('change', async (event) => { + const selectedValue = event.target.value; + console.log(`PEQ Slot selected: ${selectedValue}`); + + try { + if (selectedValue === "-1") { + if (deviceEqUI.connectionType == "network") { + await NetworkDeviceConnector.enablePEQ(deviceEqUI.currentDevice, false, -1); + } else if (deviceEqUI.connectionType == "usb") { + await UsbHIDConnector.enablePEQ(deviceEqUI.currentDevice, false, -1); + } else if (deviceEqUI.connectionType == "serial") { + await UsbSerialConnector.enablePEQ(deviceEqUI.currentDevice, false, -1); + } + console.log("PEQ Disabled."); + } else { + const slotId = parseInt(selectedValue, 10); + + if (deviceEqUI.connectionType == "network") { + await NetworkDeviceConnector.enablePEQ(deviceEqUI.currentDevice, true, slotId); + } else if (deviceEqUI.connectionType == "usb") { + await UsbHIDConnector.enablePEQ(deviceEqUI.currentDevice, true, slotId); + } else if (deviceEqUI.connectionType == "serial") { + await UsbSerialConnector.enablePEQ(deviceEqUI.currentDevice, true, slotId); + } + + console.log(`PEQ Enabled for slot ID: ${slotId}`); + } + } catch (error) { + console.error("Error updating PEQ slot:", error); + showToast("Failed to update PEQ slot.", "error"); + } + }); + + } + } + } catch (error) { + console. error("Error initializing Device EQ Plugin:", error.message); + } +} + +// Export for CommonJS & ES Modules +if (typeof module !== "undefined" && module.exports) { + module.exports = initializeDeviceEqPlugin; +} + +// Export for ES Modules +export default initializeDeviceEqPlugin; diff --git a/assets/js/devicePEQ/qudelixUsbHidHandler.js b/assets/js/devicePEQ/qudelixUsbHidHandler.js new file mode 100644 index 00000000..70d982aa --- /dev/null +++ b/assets/js/devicePEQ/qudelixUsbHidHandler.js @@ -0,0 +1,546 @@ +// qudelixUsbHidHandler.js +// Pragmatic Audio - Handler for Qudelix 5K USB HID EQ Control + +export const qudelixUsbHidHandler = (function () { + // HID Report IDs from Qudelix protocol + const HID_REPORT_ID = { + DATA_TRANSFER: 1, + RESPONSE: 2, + COMMAND: 3, + CONTROL: 4, + UPGRADE_DATA_TRANSFER: 5, + UPGRADE_RESPONSE: 6, + QX_OUT: 7, + QX_HOST_TO_DEVICE: 8, + QX_DEVICE_TO_HOST: 9 + }; + + // Qudelix EQ filter types + const FILTER_TYPES = { + BYPASS: 0, + LPF: 7, // 2nd order LPF + HPF: 8, // 2nd order HPF + PEQ: 13, // Parametric EQ + LS: 10, // 2nd order Low Shelf + HS: 11 // 2nd order High Shelf + }; + + // HID communication state + let hidReportInfo = []; + let sendReportId = 0; + let sendReportSize = 0; + + // App command definitions from qxApp_proto.ts + const APP_CMD = { + // Basic commands + ReqInitData: 0x0001, + + // Request commands + ReqDevConfig: 0x0003, + ReqEqPreset: 0x0004, + ReqEqPresetName: 0x0005, + + // Set commands + SetEqEnable: 0x0102, + SetEqType: 0x0103, + SetEqHeadroom: 0x0104, + SetEqPreGain: 0x0105, + SetEqGain: 0x0106, + SetEqFilter: 0x0107, + SetEqFreq: 0x0108, + SetEqQ: 0x0109, + SetEqBandParam: 0x010A, + SetEqPreset: 0x010B, + SetEqPresetName: 0x010E, + + // Additional commands + SaveEqPreset: 0x0202 + }; + + // Notification types from Qudelix app + const NOTIFY_EQ = { + Enable: 0x01, + Type: 0x02, + Headroom: 0x03, + PreGain: 0x04, + Gain: 0x05, + Q: 0x06, + Filter: 0x07, + Freq: 0x08, + Preset: 0x09, + PresetName: 0x0A, + Mode: 0x0B, + ReceiverInfo: 0x0C, + Band: 0x0D + }; + + // Utility functions + const utils = { + // Convert to signed 16-bit integer + toInt16: function(value) { + return (value << 16) >> 16; + }, + + // Extract 16-bit value from array at offset + d16: function(array, offset) { + return (array[offset] << 8) | array[offset + 1]; + }, + + // Get MSB of value + msb8: function(value) { + return (value >> 8) & 0xFF; + }, + + // Get LSB of value + lsb8: function(value) { + return value & 0xFF; + }, + + // Convert value to little-endian bytes + toLittleEndianBytes: function(value) { + return [this.msb8(value), this.lsb8(value)]; + }, + + // Convert to signed little-endian bytes with scaling + toSignedLittleEndianBytes: function(value, scale = 1) { + let v = Math.round(value * scale); + if (v < 0) v += 0x10000; // Convert to unsigned 16-bit + return [this.msb8(v), this.lsb8(v)]; + } + }; + + // Initialize HID report information (similar to AppUsbHid.init_reportInfo) + function initHidReports(device) { + hidReportInfo = []; + const collections = device.collections; + + console.log('Qudelix HID: Initializing reports from collections:', collections); + console.log('Qudelix HID: Total collections found:', collections?.length); + + // Debug all collections first + if (collections?.length) { + collections.forEach((info, collectionIndex) => { + console.log(`Collection ${collectionIndex}:`); + console.log(` usagePage: 0x${info.usagePage?.toString(16)}`); + console.log(` usage: 0x${info.usage?.toString(16)}`); + console.log(` featureReports: ${info.featureReports?.length || 0}`); + console.log(` inputReports: ${info.inputReports?.length || 0}`); + console.log(` outputReports: ${info.outputReports?.length || 0}`); + }); + } + + if (collections?.length) { + collections.forEach((info, collectionIndex) => { + console.log(`Processing collection ${collectionIndex}: usagePage=0x${info.usagePage?.toString(16)}`); + + // Only process vendor-defined collections (0xFF00) + if (info.usagePage !== 0xFF00) { + console.log(`Skipping collection ${collectionIndex} - not vendor-defined (0xFF00)`); + return; + } + // Process feature reports + info.featureReports?.forEach((report) => { + const reportId = report.reportId; + const reportSize = report.items?.[0]?.reportCount || 64; // Default to 64 if not specified + hidReportInfo.push({ type: 'feature', id: reportId, size: reportSize }); + console.log(`Found feature report: ID=${reportId}, size=${reportSize}`); + }); + + // Process input reports + info.inputReports?.forEach((report) => { + const reportId = report.reportId; + const reportSize = report.items?.[0]?.reportCount || 64; + hidReportInfo.push({ type: 'in', id: reportId, size: reportSize }); + console.log(`Found input report: ID=${reportId}, size=${reportSize}`); + }); + + // Process output reports + info.outputReports?.forEach((report) => { + const reportId = report.reportId; + const reportSize = report.items?.[0]?.reportCount || 64; + hidReportInfo.push({ type: 'out', id: reportId, size: reportSize }); + console.log(`Found output report: ID=${reportId}, size=${reportSize}`); + }); + }); + } + + console.log('Qudelix HID: All found reports:', hidReportInfo); + + // Find the best report ID for sending (try qx_hostToDevice first, fallback to qx_out) + sendReportId = HID_REPORT_ID.QX_HOST_TO_DEVICE; + sendReportSize = getReportSize(sendReportId); + + if (sendReportSize === 0) { + sendReportId = HID_REPORT_ID.QX_OUT; + sendReportSize = getReportSize(sendReportId); + } + + // If still no size found, use the first available output report + if (sendReportSize === 0) { + const firstOutputReport = hidReportInfo.find(r => r.type === 'out'); + if (firstOutputReport) { + sendReportId = firstOutputReport.id; + sendReportSize = firstOutputReport.size; + console.log(`Qudelix HID: Using first available output report: ID=${sendReportId}, size=${sendReportSize}`); + } else { + // Last resort: use a reasonable default + sendReportId = 7; + sendReportSize = 64; + console.log(`Qudelix HID: No reports found, using defaults: ID=${sendReportId}, size=${sendReportSize}`); + } + } + + console.log(`Qudelix HID: Using report ID ${sendReportId}, size ${sendReportSize}`); + } + + // Get report size for a given ID + function getReportSize(reportId) { + const report = hidReportInfo.find(r => r.id === reportId); + return report?.size || 0; + } + + // Map filter type from our PEQ format to Qudelix format + function mapFilterTypeToQudelix(filterType) { + switch (filterType) { + case "PK": return FILTER_TYPES.PEQ; + case "LSQ": return FILTER_TYPES.LS; + case "HSQ": return FILTER_TYPES.HS; + case "LPF": return FILTER_TYPES.LPF; + case "HPF": return FILTER_TYPES.HPF; + default: return FILTER_TYPES.PEQ; + } + } + + // Map Qudelix filter type to our PEQ format + function mapQudelixToFilterType(filterValue) { + switch (filterValue) { + case FILTER_TYPES.PEQ: return "PK"; + case FILTER_TYPES.LS: return "LSQ"; + case FILTER_TYPES.HS: return "HSQ"; + case FILTER_TYPES.LPF: return "LPF"; + case FILTER_TYPES.HPF: return "HPF"; + default: return "PK"; + } + } + + // Get current EQ slot + async function getCurrentSlot(deviceDetails) { + try { + // For Qudelix 5K, usually slot 101 is the main custom slot + return 101; + } catch (error) { + console.error("Error getting current Qudelix EQ slot:", error); + return 101; // Return default slot on error + } + } + + // Send command using Qudelix protocol (matches Qudelix.command.send) + async function sendCommand(device, cmdType, payload = []) { + // Create command packet: [cmdMSB, cmdLSB, ...payload] + const cmdPayload = new Uint8Array(2 + payload.length); + cmdPayload[0] = utils.msb8(cmdType); + cmdPayload[1] = utils.lsb8(cmdType); + + for (let i = 0; i < payload.length; i++) { + cmdPayload[i + 2] = payload[i]; + } + + console.log(`Qudelix USB: Sending command 0x${cmdType.toString(16).padStart(4, '0')}:`, [...cmdPayload].map(b => b.toString(16).padStart(2, '0')).join(' ')); + + // Send via the HID send_cmd method (this will add the HID packet wrapper) + await sendHidCommand(device, cmdPayload); + + // Add a small delay to avoid overwhelming the device + await new Promise(resolve => setTimeout(resolve, 20)); + } + + // Send HID command with proper packet wrapping (matches AppUsbHid.send_cmd) + async function sendHidCommand(device, payload) { + // Create HID packet: length + 0x80 + payload + const packet = new Uint8Array(sendReportSize); + packet.fill(0); + + // The length field should be: command (1 byte) + payload length + packet[0] = payload.length + 1; // 0x80 command + payload + packet[1] = 0x80; // HID command identifier + packet.set(payload, 2); // Copy payload starting at index 2 + + console.log(`Qudelix HID: Sending packet (len=${packet[0]}, cmd=0x${packet[1].toString(16)}):`, [...packet.slice(0, packet[0] + 1)].map(b => b.toString(16).padStart(2, '0')).join(' ')); + + await device.sendReport(sendReportId, packet); + } + + // Pull EQ settings from the device + async function pullFromDevice(deviceDetails, slot) { + const device = deviceDetails.rawDevice; + const maxBands = deviceDetails.modelConfig.maxFilters || 10; + const filters = []; + + try { + // Debug: Show device info + console.log('Qudelix USB: Device info:', { + productName: device.productName, + vendorId: '0x' + device.vendorId.toString(16), + productId: '0x' + device.productId.toString(16), + collectionsCount: device.collections?.length + }); + + // Initialize HID reports if not done already + if (hidReportInfo.length === 0) { + initHidReports(device); + } + + // If we don't have the vendor-defined interface, this is the wrong device interface + if (hidReportInfo.length === 0) { + console.error('Qudelix USB: WRONG INTERFACE! This appears to be the consumer control interface.'); + console.error('Qudelix USB: You need to select the vendor-defined HID interface when connecting.'); + console.error('Qudelix USB: Look for the interface with usagePage=0xFF00 in the browser device picker.'); + + return { + filters: [], + globalGain: 0, + error: 'Wrong HID interface selected. Please reconnect and choose the vendor-defined interface.' + }; + } + + // First, let's just listen for any data the device might be sending + console.log('Qudelix USB: Setting up listeners to detect any device activity...'); + + // Try a very simple approach - just listen for any input reports for 2 seconds + return new Promise((resolve, reject) => { + let timeout = null; + let anyDataReceived = false; + + const universalHandler = function(event) { + anyDataReceived = true; + const reportId = event.reportId; + const data = new Uint8Array(event.data.buffer); + + console.log(`Qudelix USB: DETECTED DATA! Report ID ${reportId}, length ${data.length}, data:`, [...data].map(b => b.toString(16).padStart(2, '0')).join(' ')); + + // Try to parse any data we receive + if (data.length > 2) { + const cmd = (data[0] << 8) | data[1]; + console.log(`Qudelix USB: Possible command response: 0x${cmd.toString(16).padStart(4, '0')}`); + } + }; + + device.addEventListener('inputreport', universalHandler); + + // Set a shorter timeout to see if device sends anything spontaneously + timeout = setTimeout(() => { + device.removeEventListener('inputreport', universalHandler); + + if (anyDataReceived) { + console.log('Qudelix USB: Device is sending data! Check logs above.'); + resolve({ filters: [], globalGain: 0, message: 'Device communicating but need to decode protocol' }); + } else { + console.log('Qudelix USB: No data received. Trying to send initialization commands...'); + // If no spontaneous data, try sending commands + tryInitialization(device, resolve, reject); + } + }, 2000); // Wait 2 seconds for any spontaneous data + }); + + } catch (error) { + console.error("Error pulling EQ from Qudelix:", error); + return { filters: [], globalGain: 0 }; + } + } + + // Try initialization commands if no spontaneous data + async function tryInitialization(device, resolve, reject) { + console.log('Qudelix USB: Trying initialization sequence...'); + + try { + let timeout = null; + let receivedData = false; + const filters = []; + let preGain = 0; + + const responseHandler = function(event) { + receivedData = true; + const reportId = event.reportId; + const data = new Uint8Array(event.data.buffer); + + console.log(`Qudelix USB: Response received! Report ID ${reportId}, length ${data.length}, data:`, [...data].map(b => b.toString(16).padStart(2, '0')).join(' ')); + + // Try to parse as HID packet format + if (data.length >= 3) { + const len = data[0]; + const cmd = (data[1] << 8) | data[2]; + console.log(`Qudelix USB: HID packet - len=${len}, cmd=0x${cmd.toString(16).padStart(4, '0')}`); + } + + // For now, just return success if we get any response + if (timeout) clearTimeout(timeout); + device.removeEventListener('inputreport', responseHandler); + resolve({ + filters, + globalGain: preGain, + message: `Got response on report ID ${reportId}` + }); + }; + + device.addEventListener('inputreport', responseHandler); + + // Try different communication methods + await testCommunication(device); + + // Set timeout + timeout = setTimeout(() => { + device.removeEventListener('inputreport', responseHandler); + if (receivedData) { + resolve({ filters, globalGain: preGain }); + } else { + reject(new Error("No response from device after initialization")); + } + }, 3000); + + } catch (error) { + reject(error); + } + } + + // Test different communication approaches + async function testCommunication(device) { + console.log('Qudelix USB: Testing different packet formats...'); + + // Test 1: Try direct ReqDevConfig with HID wrapper + console.log('Qudelix USB: Test 1 - ReqDevConfig with HID wrapper'); + await sendCommand(device, APP_CMD.ReqDevConfig, []); + await new Promise(resolve => setTimeout(resolve, 100)); + + // Test 2: Try raw packet without 0x80 wrapper + console.log('Qudelix USB: Test 2 - Raw ReqDevConfig packet'); + const rawPacket = new Uint8Array(sendReportSize); + rawPacket.fill(0); + rawPacket[0] = 0x00; // MSB of ReqDevConfig (0x0003) + rawPacket[1] = 0x03; // LSB of ReqDevConfig + console.log(`Qudelix USB: Sending raw packet:`, [...rawPacket.slice(0, 8)].map(b => b.toString(16).padStart(2, '0')).join(' ')); + await device.sendReport(sendReportId, rawPacket); + await new Promise(resolve => setTimeout(resolve, 100)); + + // Test 3: Try different report ID (7) + if (sendReportId !== 7) { + console.log('Qudelix USB: Test 3 - Trying report ID 7'); + const packet7 = new Uint8Array(64); // Assume size 64 for report 7 + packet7.fill(0); + packet7[0] = 0x00; + packet7[1] = 0x03; + console.log(`Qudelix USB: Sending on report ID 7:`, [...packet7.slice(0, 8)].map(b => b.toString(16).padStart(2, '0')).join(' ')); + await device.sendReport(7, packet7); + await new Promise(resolve => setTimeout(resolve, 100)); + } + + // Test 4: Try feature reports (might be needed for initialization) + console.log('Qudelix USB: Test 4 - Trying feature report ID 4'); + try { + const featurePacket = new Uint8Array(3); // Feature report ID 4, size 3 + featurePacket[0] = 0x00; + featurePacket[1] = 0x03; + featurePacket[2] = 0x00; + console.log(`Qudelix USB: Sending feature report:`, [...featurePacket].map(b => b.toString(16).padStart(2, '0')).join(' ')); + await device.sendFeatureReport(4, featurePacket); + await new Promise(resolve => setTimeout(resolve, 100)); + } catch (error) { + console.log('Qudelix USB: Feature report failed:', error.message); + } + + // Test 5: Try a simple "ping" or status request + console.log('Qudelix USB: Test 5 - Simple status request on report ID 1'); + const statusPacket = new Uint8Array(65); // Report ID 1, size 65 + statusPacket.fill(0); + statusPacket[0] = 0x00; // Simple status request + statusPacket[1] = 0x01; + console.log(`Qudelix USB: Sending status request:`, [...statusPacket.slice(0, 8)].map(b => b.toString(16).padStart(2, '0')).join(' ')); + await device.sendReport(1, statusPacket); + } + + // Push EQ settings to the device + async function pushToDevice(deviceDetails, phoneObj, slot, preamp, filters) { + const device = deviceDetails.rawDevice; + + try { + // Initialize HID reports if not done already + if (hidReportInfo.length === 0) { + initHidReports(device); + } + + // Step 1: Enable EQ + await sendCommand(device, APP_CMD.SetEqEnable, [1]); + + // Step 2: Set PreGain (global gain) + const preGainScaled = Math.round(preamp * 10); // Scale by 10 + const preGainBytes = utils.toSignedLittleEndianBytes(preGainScaled); + + // Set the same value for both channels + await sendCommand(device, APP_CMD.SetEqPreGain, [ + preGainBytes[0], preGainBytes[1], // Left channel + preGainBytes[0], preGainBytes[1] // Right channel (same value) + ]); + + // Step 3: Set each filter band + for (let i = 0; i < filters.length; i++) { + const filter = filters[i]; + if (i >= deviceDetails.modelConfig.maxFilters) break; + + if (filter.disabled) continue; + + const filterType = mapFilterTypeToQudelix(filter.type); + const freqScaled = Math.round(filter.freq); + const gainScaled = Math.round(filter.gain * 10); + const qScaled = Math.round(filter.q * 100); + + const freqBytes = utils.toLittleEndianBytes(freqScaled); + const gainBytes = utils.toSignedLittleEndianBytes(gainScaled); + const qBytes = utils.toLittleEndianBytes(qScaled); + + // Set filter parameters one by one + await sendCommand(device, APP_CMD.SetEqFilter, [i, filterType]); + await sendCommand(device, APP_CMD.SetEqFreq, [i, freqBytes[0], freqBytes[1]]); + await sendCommand(device, APP_CMD.SetEqGain, [i, gainBytes[0], gainBytes[1]]); + await sendCommand(device, APP_CMD.SetEqQ, [i, qBytes[0], qBytes[1]]); + } + + // Step 4: Save to preset + if (slot > 0) { + await sendCommand(device, APP_CMD.SaveEqPreset, [slot]); + } + + return false; // Generally no need to disconnect for Qudelix + } catch (error) { + console.error("Error pushing EQ to Qudelix:", error); + throw error; + } + } + + // Enable/disable EQ + async function enablePEQ(deviceDetails, enabled, slotId) { + try { + const device = deviceDetails.rawDevice; + + // Initialize HID reports if not done already + if (hidReportInfo.length === 0) { + initHidReports(device); + } + + // Enable/disable EQ + await sendCommand(device, APP_CMD.SetEqEnable, [enabled ? 1 : 0]); + + // If enabled and a valid slot ID is provided, switch to that preset + if (enabled && slotId > 0) { + await sendCommand(device, APP_CMD.SetEqPreset, [slotId]); + } + } catch (error) { + console.error("Error setting Qudelix EQ state:", error); + } + } + + return { + getCurrentSlot, + pullFromDevice, + pushToDevice, + enablePEQ + }; +})(); diff --git a/assets/js/devicePEQ/toppingUsbHidHandler.js b/assets/js/devicePEQ/toppingUsbHidHandler.js new file mode 100644 index 00000000..43784506 --- /dev/null +++ b/assets/js/devicePEQ/toppingUsbHidHandler.js @@ -0,0 +1,271 @@ +export const toppingUsbHidHandler = (function () { + // ===== Known scheme from logs ===== + // Band page: base = 0x90 + bandIndex (0-based; band 0 => 0x90, band 1 => 0x91, band 2 => 0x92, ...) + // Write ops (per band): + // enable: base+0x06 (data: 0/1) + // freq: base+0x07 (data: Hz, integer) + // gain: base+0x08 (data: dB*2, half-dB steps, signed) + // q: base+0x09 (data: Q*10000, integer) + // apply: base+0x0A (data: 1) + // + // NOTE: Report format on your device appears to be [cmd, data] in the HID payload; we keep REPORT_ID=1. + + const REPORT_ID = 0x01; + + // Helpers ------------------------------------------------------- + const bandBase = (filterIndex) => (0x90 + filterIndex) & 0xFF; + + // Clamp & encoders (defensive) + const encFreq = (hz) => Math.max(1, Math.round(hz)); + const encGainSteps = (db) => { + // half-dB steps, signed 16-bit safe + const v = Math.round(db * 2); + // device accepted small positives; keep as 16-bit signed range + return ((v << 16) >> 16); // ensure JS -> signed 16 + }; + const encQ = (q) => Math.max(1, Math.round(q * 10000)); + + // Send a single 2-byte command (cmd + data as 16/32?). Your logs show small integers. + // WebHID sendReport takes (reportId, data: BufferSource) + // We'll serialize as little-endian Uint32 for data to be safe; adjust if your device expects 16-bit. + function makePacket(cmd, data) { + // [cmd (1B), data (4B LE)] + const buf = new ArrayBuffer(5); + const view = new DataView(buf); + view.setUint8(0, cmd & 0xFF); + view.setUint32(1, data >>> 0, true); + return new Uint8Array(buf); + } + + async function sendCmd(device, cmd, data) { + const pkt = makePacket(cmd, data); + await device.sendReport(REPORT_ID, pkt); + } + + // Public API stubs that we can safely implement now ------------------------ + + async function getCurrentSlot(_deviceDetails) { + // Unknown in logs; keep placeholder. + console.log("USB Device PEQ: Topping getCurrentSlot called - not implemented (default 0)."); + return 0; + } + + +// Small helper: wait for echoed state packets and resolve what we need. + function collectEchoes(device, wantedCmds, ms = 120) { + return new Promise((resolve) => { + const found = new Map(); + const onReport = (e) => { + // Expect reportId === REPORT_ID; ignore others defensively + if (e.reportId !== REPORT_ID) return; + const dv = e.data; + if (dv.byteLength < 5) return; + const cmd = dv.getUint8(0); + if (!wantedCmds.includes(cmd)) return; + const val = dv.getUint32(1, true); + found.set(cmd, val); + }; + device.addEventListener("inputreport", onReport); + const t = setTimeout(() => { + device.removeEventListener("inputreport", onReport); + resolve(found); + }, ms); + // If you want a manual cancel, return t, but not needed here. + }); + } + +// Translate a single echoed field (cmd,value) into our filter fields + function decodeFilterResponse(cmd, value) { + // Identify band index from cmd high nibble: 0x90..0x99 + const page = cmd & 0xF0; // 0x90 for bands, 0x9C for pregain page + const low = cmd & 0x0F; // field within the page + const out = {}; + + if (page >= 0x90 && page <= 0x99) { + // Per-band fields we've seen: + // base+0x06 enable (0/1) + // base+0x07 freq (Hz) + // base+0x08 gainSteps (dB * 2) + // base+0x09 qScaled (Q * 10000) + if (low === 0x06) out.disabled = (value === 0); + else if (low === 0x07) out.freq = value; + else if (low === 0x08) out.gain = value / 2.0; + else if (low === 0x09) out.q = value / 10000.0; + } + return out; + } + +// Best-effort per-band read: poke APPLY then harvest echoes for freq/gain/q/enabled + async function readFullFilter(device, filterIndex) { + const base = (0x90 + filterIndex) & 0xFF; + + // 1) Trigger an "echo" of current band state without changing fields: + // send APPLY (base+0x0A, data=1). Your logs show the device then echoes 07/08/09 and often 06. + await sendCmd(device, (base + 0x0A) & 0xFF, 1); + + // 2) Collect echoes for a short window + const want = [(base + 0x06) & 0xFF, (base + 0x07) & 0xFF, (base + 0x08) & 0xFF, (base + 0x09) & 0xFF]; + const echoes = await collectEchoes(device, want, 150); // ~150ms harvest window + + // 3) Fold them into a filter object with safe defaults + let freq = 1000; + let gain = 0; + let q = 1.0; + let disabled = false; + + for (const [cmd, val] of echoes.entries()) { + const partial = decodeFilterResponse(cmd, val); + if (partial.freq != null) freq = partial.freq; + if (partial.gain != null) gain = partial.gain; + if (partial.q != null) q = partial.q; + if (partial.disabled != null) disabled = partial.disabled; + } + + return { type: "PK", freq, q, gain, disabled }; + } + +// ---------- Pregain (best-guess 16.16 fixed) ---------- + + const PREG_PAGE = 0x9C; + const PREG_SET_A = 0x9C01; // value + const PREG_TRIG_A = 0x9C02; // 1 + const PREG_SET_B = 0x9C03; // value (repeat/mirror) + const PREG_TRIG_B = 0x9C04; // 1 + +// Encode/Decode pregain as signed 16.16 fixed (best guess; tweak if your echoes disagree) + function encPregainFixed(dB) { + // clamp to a sensible range to avoid overflow (e.g. -60..+20 dB) + const clamped = Math.max(-60, Math.min(20, dB)); + // Convert to signed 32-bit + let fixed = Math.round(clamped * 65536); + // Bring to unsigned 32 for packing + return fixed >>> 0; + } + function decPregainFixed(val) { + // Interpret as signed 32 + const signed = (val & 0x80000000) ? (val - 0x100000000) : val; + return signed / 65536.0; + } + + async function readPregain(device) { + // We don't know a "request" opcode; mimic the band trick: + // send the "trigger" and collect any 0x9C01/0x9C03 echoes briefly. + // If nothing arrives, return 0 dB. + await sendCmd(device, PREG_TRIG_A & 0xFF, 1); + const want = [PREG_SET_A & 0xFF, PREG_SET_B & 0xFF]; + const echoes = await collectEchoes(device, want, 150); + + // Prefer the most-recent SET value we saw (B over A) + const vB = echoes.get(PREG_SET_B & 0xFF); + const vA = echoes.get(PREG_SET_A & 0xFF); + if (vB != null) return decPregainFixed(vB); + if (vA != null) return decPregainFixed(vA); + + // Fallback: no echo seen + return 0; + } + + async function writePregain(device, dB) { + const fixed = encPregainFixed(dB); + // Mirror sequence seen in logs (value → trigger → value → trigger) + await sendCmd(device, PREG_SET_A & 0xFF, fixed); + await sendCmd(device, PREG_TRIG_A & 0xFF, 1); + await sendCmd(device, PREG_SET_B & 0xFF, fixed); + await sendCmd(device, PREG_TRIG_B & 0xFF, 1); + } + + async function pullFromDevice(deviceDetails) { + console.log("USB Device PEQ: Topping pullFromDevice (reads mostly placeholders)."); + const device = deviceDetails.rawDevice; + const filters = []; + for (let i = 0; i < deviceDetails.modelConfig.maxFilters; i++) { + filters.push(await readFullFilter(device, i)); + } + const globalGain = await readPregain(device); + return { filters, globalGain }; + } + + // NEW: encode + write one filter using the discovered scheme + async function writeFilter(device, filterIndex, filter) { + const base = bandBase(filterIndex); + const enabled = filter.disabled ? 0 : 1; + + // Enable/disable (base+0x06) + await sendCmd(device, (base + 0x06) & 0xFF, enabled); + + // Frequency (base+0x07) – integer Hz + if (Number.isFinite(filter.freq)) { + await sendCmd(device, (base + 0x07) & 0xFF, encFreq(filter.freq)); + } + + // Gain (base+0x08) – half-dB steps + if (Number.isFinite(filter.gain)) { + await sendCmd(device, (base + 0x08) & 0xFF, encGainSteps(filter.gain)); + } + + // Q (base+0x09) – Q * 10000 + if (Number.isFinite(filter.q)) { + await sendCmd(device, (base + 0x09) & 0xFF, encQ(filter.q)); + } + + // Apply/commit (base+0x0A) + await sendCmd(device, (base + 0x0A) & 0xFF, 1); + } + + // Build packet function kept for API completeness (we now stream field-wise) + function buildWritePacket(_filterIndex, _f) { + // Sending happens per-field; there isn't a single combined packet in this protocol. + return new Uint8Array([0x00]); + } + + function buildSavePacket() { + // Unknown "save-to-flash" opcode; your logs show per-band APPLY (0x..0A). Keeping a no-op. + return new Uint8Array([0x00]); + } + + async function pushToDevice(deviceDetails, phoneObj, _slot, globalGain, filters) { + console.log("USB Device PEQ: Topping pushToDevice (using discovered per-band scheme)."); + const device = deviceDetails.rawDevice; + const max = Math.min(filters.length, deviceDetails.modelConfig.maxFilters || filters.length); + + for (let i = 0; i < max; i++) { + const f = filters[i]; + // default to peaking if unspecified + await writeFilter(device, i, { + type: f.type ?? "PK", + freq: f.freq, + gain: f.gain, + q: f.q, + disabled: !!f.disabled, + }); + } + + // Global pregain (left as a no-op until we finalize 0x9Cxx mapping) + if (Number.isFinite(globalGain)) { + await writePregain(device, globalGain); + } + + // Optional save/commit-to-flash is unknown; per-band commit already sent. + return false; // don't force disconnect + } + + async function enablePEQ(_device) { + // Not observed yet as a single global switch; bands have their own enable flags + per-band apply. + console.log("USB Device PEQ: Topping enablePEQ - no separate global opcode observed; enabling bands instead."); + } + + async function readVersion(_device) { + console.log("USB Device PEQ: Topping readVersion - not yet implemented."); + return "unknown"; + } + + return { + getCurrentSlot, + pullFromDevice, + pushToDevice, + enablePEQ, + readVersion, + // optionally expose these for advanced use / tests + _internal: { bandBase, encFreq, encGainSteps, encQ, writeFilter }, + }; +})(); diff --git a/assets/js/devicePEQ/usbDeviceConfig.js b/assets/js/devicePEQ/usbDeviceConfig.js new file mode 100644 index 00000000..837aaf79 --- /dev/null +++ b/assets/js/devicePEQ/usbDeviceConfig.js @@ -0,0 +1,837 @@ +// Dynamically import manufacturer specific handlers for their unique devices +const {fiioUsbHID} = await import('./fiioUsbHidHandler.js'); +const {walkplayUsbHID} = await import('./walkplayHidHandler.js'); +const {moondropUsbHidHandler} = await import('./moondropUsbHidHandler.js'); +const {moondropOldFashionedUsbHID} = await import('./moondropOldFashionedUsbHidHandler.js'); +const {ktmicroUsbHidHandler} = await import('./ktmicroUsbHidHandler.js'); +const {qudelixUsbHidHandler} = await import('./qudelixUsbHidHandler.js'); +const {toppingUsbHidHandler} = await import('./toppingUsbHidHandler.js'); + +// Main list of HID devices - each vendor has one or more vendorId, and a list of devices associated, +// each device has a model of how the slots are configured and a handler to handle reading / writing +// the raw USBHID reports to the device +export const usbHidDeviceHandlerConfig = ([ + { + vendorIds: [0x2972,0x0A12], + manufacturer: "FiiO", + handler: fiioUsbHID, + defaultModelConfig: { // Fallback if we haven't got specific details yet + minGain: -12, + maxGain: 12, + maxFilters: 5, + firstWritableEQSlot: -1, + maxWritableEQSlots: 0, + disconnectOnSave: true, + disabledPresetId: -1, + experimental: false, + supportsLSHSFilters: true, + supportsPregain: true, + defaultResetFiltersValues:[{gain:0, freq: 100, q:1, filterType: "PK"}], + reportId: 7, + availableSlots: [ + {id: 0, name: "Jazz"}, + {id: 1, name: "Pop"}, + {id: 2, name: "Rock"}, + {id: 3, name: "Dance"}, + {id: 4, name: "R&B"}, + {id: 5, name: "Classic"}, + {id: 6, name: "Hip-hop"}, + {id: 7, name: "Monitor"}, + {id: 160, name: "USER1"}, + {id: 161, name: "USER2"}, + {id: 162, name: "USER3"}, + {id: 163, name: "USER4"}, + {id: 164, name: "USER5"}, + {id: 165, name: "USER6"}, + {id: 166, name: "USER7"}, + {id: 167, name: "USER8"}, + {id: 168, name: "USER9"}, + {id: 169, name: "USER10"} + ] + }, + devices: { + "FIIO QX13": { + modelConfig: { + maxFilters: 10, + disconnectOnSave: false, + // Provided device presets mapping + disabledPresetId: 240, + firstWritableEQSlot: 160, + maxWritableEQSlots: 10, + availableSlots: [ + {id: 0, name: "Jazz"}, + {id: 1, name: "Pop"}, + {id: 2, name: "Rock"}, + {id: 3, name: "Dance"}, + {id: 4, name: "R&B"}, + {id: 5, name: "Classic"}, + {id: 6, name: "Hip-hop"}, + {id: 8, name: "Retro"}, + {id: 160, name: "USER1"}, + {id: 161, name: "USER2"}, + {id: 162, name: "USER3"}, + {id: 163, name: "USER4"}, + {id: 164, name: "USER5"}, + {id: 165, name: "USER6"}, + {id: 166, name: "USER7"}, + {id: 167, name: "USER8"}, + {id: 168, name: "USER9"}, + {id: 169, name: "USER10"}, + {id: 240, name: "BYPASS"} + ] + } + }, + "SNOWSKY Melody": { + manufacturer: "FiiO", + handler: fiioUsbHID, + modelConfig: { + minGain: -12, + maxGain: 12, + maxFilters: 10, + firstWritableEQSlot: -1, + disabledPresetId: 240, + maxWritableEQSlots: 0, + availableSlots: [{id: 0, name: "Jazz"}, {id: 1, name: "Pop"}, {id: 2, name: "Rock"}, { + id: 3, + name: "Dance" + }, { + id: 5, + name: "R&B" + }, {id: 6, name: "Classic"}, {id: 7, name: "Hip-hop"}, {id: 160, name: "USER1"}, {id: 161, name: "USER2"}, { + id: 162, + name: "USER3" + }] + + } + }, + "JadeAudio JIEZI": { + manufacturer: "FiiO", + handler: fiioUsbHID, + modelConfig: { + minGain: -12, + maxGain: 12, + maxFilters: 5, + firstWritableEQSlot: 160, + maxWritableEQSlots: 3, + disconnectOnSave: true, + disabledPresetId: 240, + reportId: 2, + availableSlots: [ + {id: 0, name: "Jazz"}, + {id: 1, name: "Pop"}, + {id: 2, name: "Rock"}, + {id: 3, name: "Dance"}, + {id: 4, name: "R&B"}, + {id: 5, name: "Classic"}, + {id: 6, name: "Hip-hop"}, + {id: 160, name: "USER1"}, + {id: 161, name: "USER2"}, + {id: 162, name: "USER3"}, + {id: 240, name: "Close EQ"} + ] + } + }, + "JadeAudio JA11": { + modelConfig: { + minGain: -12, + maxGain: 12, + maxFilters: 5, + firstWritableEQSlot: 3, + maxWritableEQSlots: 1, + disconnectOnSave: true, + disabledPresetId: 4, + reportId: 2, + availableSlots: [{id: 0, name: "Vocal"}, {id: 1, name: "Classic"}, {id: 2, name: "Bass"}, { + id: 3, + name: "USER1" + }] + } + }, + "FIIO KA17": { + modelConfig: { + minGain: -12, + maxGain: 12, + maxFilters: 10, + firstWritableEQSlot: 7, + maxWritableEQSlots: 3, + disconnectOnSave: false, + disabledPresetId: 11, + reportId: 1, + availableSlots: [{id: 0, name: "Jazz"}, {id: 1, name: "Pop"}, {id: 2, name: "Rock"}, { + id: 3, + name: "Dance" + }, { + id: 5, + name: "R&B" + }, {id: 6, name: "Classic"}, {id: 7, name: "Hip-hop"}, {id: 4, name: "USER1"}, {id: 8, name: "USER2"}, { + id: 9, + name: "USER3" + }] + } + }, + "FIIO Q7": { + modelConfig: { + minGain: -12, + maxGain: 12, + maxFilters: 10, + firstWritableEQSlot: 7, + maxWritableEQSlots: 3, + disconnectOnSave: false, + disabledPresetId: 11, + reportId: 1, + availableSlots: [{id: 0, name: "Jazz"}, {id: 1, name: "Pop"}, {id: 2, name: "Rock"}, { + id: 3, + name: "Dance" + }, { + id: 5, + name: "R&B" + }, {id: 6, name: "Classic"}, {id: 7, name: "Hip-hop"}, {id: 4, name: "USER1"}, {id: 8, name: "USER2"}, { + id: 9, + name: "USER3" + }] + } + }, + "FIIO KA17 (MQA HID)": { + modelConfig: { + minGain: -12, + maxGain: 12, + maxFilters: 10, + firstWritableEQSlot: 7, + maxWritableEQSlots: 3, + disconnectOnSave: false, + disabledPresetId: 11, + reportId: 1, + availableSlots: [{id: 0, name: "Jazz"}, {id: 1, name: "Pop"}, {id: 2, name: "Rock"}, { + id: 3, + name: "Dance" + }, { + id: 5, + name: "R&B" + }, {id: 6, name: "Classic"}, {id: 7, name: "Hip-hop"}, {id: 4, name: "USER1"}, {id: 8, name: "USER2"}, { + id: 9, + name: "USER3" + }] + } + }, + "FIIO BT11 (UAC1.0)": { + modelConfig: { + minGain: -12, + maxGain: 12, + maxFilters: 10, + firstWritableEQSlot: 7, + maxWritableEQSlots: 3, + disconnectOnSave: false, + disabledPresetId: 11, + reportId: 1, + availableSlots: [{id: 0, name: "Jazz"}, {id: 1, name: "Pop"}, {id: 2, name: "Rock"}, { + id: 3, + name: "Dance" + }, { + id: 5, + name: "R&B" + }, {id: 6, name: "Classic"}, {id: 7, name: "Hip-hop"}, {id: 4, name: "USER1"}, {id: 8, name: "USER2"}, { + id: 9, + name: "USER3" + }] + } + }, + "FIIO Air Link": { + modelConfig: { + minGain: -12, + maxGain: 12, + maxFilters: 10, + firstWritableEQSlot: 7, + maxWritableEQSlots: 3, + disconnectOnSave: false, + disabledPresetId: 11, + reportId: 1, + availableSlots: [{id: 0, name: "Jazz"}, {id: 1, name: "Pop"}, {id: 2, name: "Rock"}, { + id: 3, + name: "Dance" + }, { + id: 5, + name: "R&B" + }, {id: 6, name: "Classic"}, {id: 7, name: "Hip-hop"}, {id: 4, name: "USER1"}, {id: 8, name: "USER2"}, { + id: 9, + name: "USER3" + }] + } + }, + "FIIO BTR13": { + modelConfig: { + minGain: -12, + maxGain: 12, + maxFilters: 10, + firstWritableEQSlot: 7, + maxWritableEQSlots: 3, + disconnectOnSave: false, + disabledPresetId: 12, + availableSlots: [{id: 0, name: "Jazz"}, {id: 1, name: "Pop"}, {id: 2, name: "Rock"}, { + id: 3, + name: "Dance" + }, { + id: 4, + name: "R&B" + }, {id: 5, name: "Classic"}, {id: 6, name: "Hip-hop"}, {id: 7, name: "USER1"}, {id: 8, name: "USER2"}, { + id: 9, + name: "USER3" + }] + } + }, + "BTR17": { + modelConfig: { + minGain: -12, + maxGain: 12, + maxFilters: 10, + firstWritableEQSlot: 7, + maxWritableEQSlots: 3, + disconnectOnSave: false, + disabledPresetId: 11, + } + }, + "FIIO KA15": { + modelConfig: { + minGain: -12, + maxGain: 12, + maxFilters: 10, + firstWritableEQSlot: 7, + maxWritableEQSlots: 3, + disconnectOnSave: false, + disabledPresetId: 11, + availableSlots: [{id: 0, name: "Jazz"}, {id: 1, name: "Pop"}, {id: 2, name: "Rock"}, { + id: 3, + name: "Dance" + }, { + id: 4, + name: "R&B" + }, {id: 5, name: "Classic"}, {id: 6, name: "Hip-hop"}, {id: 7, name: "USER1"}, {id: 8, name: "USER2"}, { + id: 9, + name: "USER3" + }] + } + }, + "FIIO K13 R2R": { + modelConfig: { + minGain: -12, + maxGain: 12, + maxFilters: 10, + firstWritableEQSlot: 160, + maxWritableEQSlots: 10, + disconnectOnSave: false, + disabledPresetId: 240, + reportId: 1, + availableSlots: [ + {id: 240, name: "BYPASS"}, + {id: 0, name: "Jazz"}, + {id: 1, name: "Pop"}, + {id: 2, name: "Rock"}, + {id: 3, name: "Dance"}, + {id: 4, name: "R&B"}, + {id: 5, name: "Classic"}, + {id: 6, name: "Hip-hop"}, + {id: 8, name: "Retro"}, + {id: 9, name: "sDamp-1"}, + {id: 10, name: "sDamp-2"}, + {id: 160, name: "USER1"}, + {id: 161, name: "USER2"}, + {id: 162, name: "USER3"}, + {id: 163, name: "USER4"}, + {id: 164, name: "USER5"}, + {id: 165, name: "USER6"}, + {id: 166, name: "USER7"}, + {id: 167, name: "USER8"}, + {id: 168, name: "USER9"}, + {id: 169, name: "USER10"} + ] + } + }, + "FIIO BR15 R2R": { + modelConfig: { + minGain: -12, + maxGain: 12, + maxFilters: 10, + firstWritableEQSlot: 160, + maxWritableEQSlots: 10, + disconnectOnSave: false, + disabledPresetId: 240, + availableSlots: [ + {id: 240, name: "BYPASS"}, + {id: 0, name: "Jazz"}, + {id: 1, name: "Pop"}, + {id: 2, name: "Rock"}, + {id: 3, name: "Dance"}, + {id: 4, name: "R&B"}, + {id: 5, name: "Classic"}, + {id: 6, name: "Hip-hop"}, + {id: 8, name: "Retro"}, + {id: 9, name: "sDamp-1"}, + {id: 10, name: "sDamp-2"}, + {id: 160, name: "USER1"}, + {id: 161, name: "USER2"}, + {id: 162, name: "USER3"}, + {id: 163, name: "USER4"}, + {id: 164, name: "USER5"}, + {id: 165, name: "USER6"}, + {id: 166, name: "USER7"}, + {id: 167, name: "USER8"}, + {id: 168, name: "USER9"}, + {id: 169, name: "USER10"} + ] + } + }, + "FIIO FP3": { + modelConfig: { + minGain: -12, + maxGain: 12, + maxFilters: 10, + firstWritableEQSlot: 160, + maxWritableEQSlots: 1, + disconnectOnSave: false, + availableSlots: [ + {id: 0, name: "Jazz"}, + {id: 1, name: "Pop"}, + {id: 2, name: "Rock"}, + {id: 3, name: "Dance"}, + {id: 4, name: "R&B"}, + {id: 5, name: "Classic"}, + {id: 6, name: "Hip-hop"}, + {id: 160, name: "USER1"} + ] + } + }, + "SNOWSKY TINY A": { + manufacturer: "FiiO", + handler: fiioUsbHID, + modelConfig: { + minGain: -12, + maxGain: 12, + maxFilters: 5, + firstWritableEQSlot: 160, + maxWritableEQSlots: 3, + disconnectOnSave: true, + disabledPresetId: 240, + availableSlots: [ + {id: 0, name: "Jazz"}, + {id: 1, name: "Pop"}, + {id: 2, name: "Rock"}, + {id: 3, name: "Dance"}, + {id: 4, name: "R&B"}, + {id: 5, name: "Classic"}, + {id: 6, name: "Hip-hop"}, + {id: 160, name: "USER1"}, + {id: 161, name: "USER2"}, + {id: 162, name: "USER3"}, + {id: 240, name: "Close EQ"} + ] + } + }, + + "SNOWSKY TINY B": { + manufacturer: "FiiO", + handler: fiioUsbHID, + modelConfig: { + minGain: -12, + maxGain: 12, + maxFilters: 5, + firstWritableEQSlot: 160, + maxWritableEQSlots: 3, + disconnectOnSave: true, + disabledPresetId: 240, + availableSlots: [ + {id: 0, name: "Jazz"}, + {id: 1, name: "Pop"}, + {id: 2, name: "Rock"}, + {id: 3, name: "Dance"}, + {id: 4, name: "R&B"}, + {id: 5, name: "Classic"}, + {id: 6, name: "Hip-hop"}, + {id: 160, name: "USER1"}, + {id: 161, name: "USER2"}, + {id: 162, name: "USER3"}, + {id: 240, name: "Close EQ"} + ] + } + }, + + "FIIO FG3": { + modelConfig: { + minGain: -12, + maxGain: 12, + maxFilters: 10, + firstWritableEQSlot: 160, + maxWritableEQSlots: 10, + disconnectOnSave: false, + availableSlots: [ + {id: 0, name: "Jazz"}, + {id: 1, name: "Pop"}, + {id: 2, name: "Rock"}, + {id: 3, name: "Dance"}, + {id: 4, name: "R&B"}, + {id: 5, name: "Classic"}, + {id: 6, name: "Hip-hop"}, + {id: 12, name: "Cinema"}, + {id: 13, name: "FPS"}, + {id: 14, name: "MOBA"}, + {id: 15, name: "ACT"}, + {id: 16, name: "MUG"}, + {id: 160, name: "USER1"}, + {id: 161, name: "USER2"}, + {id: 162, name: "USER3"}, + {id: 163, name: "USER4"}, + {id: 164, name: "USER5"}, + {id: 165, name: "USER6"}, + {id: 166, name: "USER7"}, + {id: 167, name: "USER8"}, + {id: 168, name: "USER9"}, + {id: 169, name: "USER10"} + ] + } + }, + "FIIO LS-TC2": { + modelConfig: { + minGain: -12, + maxGain: 12, + maxFilters: 5, + firstWritableEQSlot: 160, + maxWritableEQSlots: 1, + disconnectOnSave: true, + experimental: true, + availableSlots: [ + {id: 0, name: "Jazz"}, + {id: 1, name: "Pop"}, + {id: 2, name: "Rock"}, + {id: 3, name: "Dance"}, + {id: 4, name: "R&B"}, + {id: 5, name: "Classic"}, + {id: 6, name: "Hip-hop"}, + {id: 160, name: "USER1"} + ] + } + } + } + }, + { + vendorIds: [0x3302, 0x0762, 0x35D8, 0x2FC6, 0x0104, 0xB445, 0x0661, 0x0666, 0x0D8C], // multiple Walkplay vendorIds + manufacturer: "WalkPlay", + handler: walkplayUsbHID, + defaultModelConfig: { + minGain: -12, + maxGain: 6, + maxFilters: 8, + schemeNo: 10, + firstWritableEQSlot: -1, + maxWritableEQSlots: 0, + disconnectOnSave: false, + disabledPresetId: -1, + supportsPregain: true, + defaultResetFiltersValues:[{gain:0, freq: 100, q:1, filterType: "PK"}], + supportsLSHSFilters: false, + autoGlobalGain: false, + experimental: false, + availableSlots: [{id: 101, name: "Custom"}] + }, + deviceGroups: { + "SchemeNo11": { + productIds: [0x13D4,0x98C0,0x98C0,0x93D1,0x13D7,0x12C0,0x1264,0x43D1,0x1266,0x51C0,0x13C1,0x13D3,0x1251,0x1262,0x1261,0x12C1,0x98D5], + modelConfig: { + supportsLSHSFilters: false, + supportsPregain: true + } + }, + "SchemeNo16": { + productIds: [0x4380, 0x43B6,0x43E1,0x43D7,0x43D8,0x43E4,0x98D4,0x43C0,0x43E8,0xF808,0xEE10,0x4352,0xEE20,0x43C5,0x43E6,0x4351,0x43DE,0x4358,0x4359,0x43DB,0x435A,0x4355,0x435C,0x435D,0x435E,0x43EF,0x43EC,0x4361,0x4363,0x4366,0x4364,0x4360,0x4382,0x4383,0x4386,0x43C6,0x43C7,0x011D,0x43C8,0x43DA,0x43C9,0x43CA,0x43CC,0x43CD,0x43CF,0x43B1,0x43C2,0x43B7,0x43B8,0x39C3], + modelConfig: { + schemeNo: 16, + maxFilters: 10, + minGain: -10, + maxGain: 10, + autoGlobalGain: false, + supportsLSHSFilters: true, + supportsPregain: true + } + } + }, + devices: { + "Old Fashioned": { + manufacturer: "Moondrop", + handler: moondropOldFashionedUsbHID, + modelConfig: { + minGain: -12, + maxGain: 3, // Limited range: -12.8 to +12.7 technically, but app shows -12 to +3 + maxFilters: 5, + firstWritableEQSlot: -1, + maxWritableEQSlots: 0, + disconnectOnSave: false, + disabledPresetId: -1, + experimental: false, + supportsLSHSFilters: false, // Only peaking filters supported + supportsPregain: false, + defaultResetFiltersValues: [{gain: 0, freq: 100, q: 1, filterType: "PK"}], + availableSlots: [{id: 0, name: "Custom"}] + } + }, + "FIIO FX17": { + manufacturer: "FiiO", + handler: fiioUsbHID, + modelConfig: { + minGain: -12, + maxGain: 12, + maxFilters: 10, + firstWritableEQSlot: 160, + maxWritableEQSlots: 1, + disconnectOnSave: false, + disabledPresetId: -1, + experimental: false, + availableSlots: [ + {id: 0, name: "Jazz"}, + {id: 1, name: "Pop"}, + {id: 2, name: "Rock"}, + {id: 3, name: "Dance"}, + {id: 4, name: "R&B"}, + {id: 5, name: "Classic"}, + {id: 6, name: "Hip-hop"}, + {id: 160, name: "USER1"} + ] + } + }, + "Rays": { + manufacturer: "Moondrop", + handler: moondropUsbHidHandler, + modelConfig: { + supportsLSHSFilters: true, + supportsPregain: true, + } + }, + "Marigold": { + manufacturer: "Moondrop", + handler: moondropUsbHidHandler, + modelConfig: { + supportsLSHSFilters: false, + supportsPregain: true, + } + }, + "FreeDSP Pro": { + manufacturer: "Moondrop", + handler: moondropUsbHidHandler, + modelConfig: { + supportsLSHSFilters: true, + supportsPregain: true, + } + }, + "MOONRIVER 3": { + manufacturer: "Moondrop", + handler: moondropUsbHidHandler, + modelConfig: { + supportsLSHSFilters: true, + supportsPregain: false, // Version dependent - needs firmware check + } + }, + "FreeDSP Mini": { + manufacturer: "Moondrop", + handler: moondropUsbHidHandler, + modelConfig: { + supportsLSHSFilters: true, + supportsPregain: true, + } + }, + "ddHiFi DSP IEM - Memory": { + manufacturer: "Moondrop", + handler: moondropUsbHidHandler + }, + "Protocol Max": { + manufacturer: "CrinEar", + modelConfig: { + schemeNo: 16, + maxFilters: 10, + minGain: -10, + maxGain: 10, + autoGlobalGain: true, + supportsLSHSFilters: true, + supportsPregain: true + } + }, + "Truthear KEYX": { + manufacturer: "Truthear", + handler: walkplayUsbHID, + modelConfig: { + minGain: -12, + maxGain: 6, + maxFilters: 8, + firstWritableEQSlot: -1, + maxWritableEQSlots: 0, + disconnectOnSave: false, + disabledPresetId: -1, + supportsPregain: true, + supportsLSHSFilters: false, + experimental: false, + defaultIndex: 0x17, + availableSlots: [{id: 101, name: "Custom"}] + } + }, + "BGVP MX1": { + modelConfig: { + schemeNo: 15, + experimental: true + } + }, + "DT04": { + manufacturer: "LETSHUOER", + modelConfig: { + schemeNo: 15, + experimental: true + } + }, + "MD-QT-042": { + manufacturer: "Moondrop", + modelConfig: { + schemeNo: 15, + experimental: true + } + }, + "MOONDROP HiFi with PD": { + manufacturer: "Moondrop", + modelConfig: { + schemeNo: 15, + experimental: true + } + }, + "DAWN PRO 2": { + manufacturer: "Moondrop", + modelConfig: { + schemeNo: 15, + experimental: false + } + }, + "CS431XX": { + modelConfig: { + schemeNo: 15, + experimental: true + } + }, + "ES9039 ": { + modelConfig: { + schemeNo: 15, + experimental: true + } + }, + "TANCHJIM-STARGATE II": { + manufacturer: "Tanchim", + modelConfig: { + schemeNo: 15, + supportsLSHSFilters: false + } + }, + "didiHiFi DSP Cable - Memory": { + manufacturer: "ddHifi", + modelConfig: { + schemeNo: 15 + } + }, + "Dual CS43198": { + modelConfig: { + schemeNo: 15, + experimental: true + } + }, + "ES9039 HiFi DSP Audio": { + modelConfig: { + schemeNo: 15, + experimental: true + } + } + } + }, + { + vendorIds: [0x31B2], + manufacturer: "KT Micro", + handler: ktmicroUsbHidHandler, + defaultModelConfig: { + minGain: -12, + maxGain: 12, + maxFilters: 5, + firstWritableEQSlot: -1, + maxWritableEQSlots: 0, + compensate2X: true, // Lets compenstate by default + disconnectOnSave: true, + disabledPresetId: 0x02, + experimental: false, + supportsPregain: false, + supportsLSHSFilters: true, + defaultResetFiltersValues:[{gain:0, freq: 100, q:1, filterType: "PK"}], + availableSlots: [{id: 0x03, name: "Custom"}] + }, + devices: { + "Kiwi Ears-Allegro PRO": { + manufacturer: "Kiwi Ears", + modelConfig: { + supportsLSHSFilters: false, + disconnectOnSave: true, + } + }, + "KT02H20 HIFI Audio": { + manufacturer: "JCally", + modelConfig: { + supportsLSHSFilters: false, + } + }, + "TANCHJIM BUNNY DSP": { + manufacturer: "TANCHJIM", + modelConfig: { + compensate2X: false, + supportsPregain: true, + } + }, + "TANCHJIM FISSION": { + manufacturer: "TANCHJIM", + modelConfig: { + compensate2X: false, + supportsPregain: true, + } + }, + "CDSP": { + manufacturer: "Moondrop", + modelConfig: { + compensate2X: false + } + }, + "Chu2 DSP": { + manufacturer: "Moondrop", + modelConfig: { + compensate2X: false + } + } + } + }, + { + vendorIds: [0x152A], // 5418 in decimal = 0x152A in hex + manufacturer: "Topping", + handler: toppingUsbHidHandler, + defaultModelConfig: { + minGain: -12, + maxGain: 12, + maxFilters: 10, + firstWritableEQSlot: 0, + maxWritableEQSlots: 3, + disconnectOnSave: false, + disabledPresetId: -1, + experimental: true, + supportsPregain: true, + supportsLSHSFilters: true, + defaultResetFiltersValues:[{gain:0, freq: 100, q:1, filterType: "PK"}], + availableSlots: [ + {id: 0, name: "Custom 1"}, + {id: 1, name: "Custom 2"}, + {id: 2, name: "Custom 3"} + ] + }, + devices: { + "DX5 II": { + productId: 0x8740, // 34640 in decimal = 0x8740 in hex + modelConfig: { + maxFilters: 10, + experimental: true + } + } + } + } +]) diff --git a/assets/js/devicePEQ/usbHidConnector.js b/assets/js/devicePEQ/usbHidConnector.js new file mode 100644 index 00000000..dd6ce8c2 --- /dev/null +++ b/assets/js/devicePEQ/usbHidConnector.js @@ -0,0 +1,260 @@ +// +// Copyright 2024 : Pragmatic Audio +// +// Declare UsbHIDConnector and attach it to the global window object + +export const UsbHIDConnector = ( async function () { + let currentDevice = null; + + const {usbHidDeviceHandlerConfig} = await import('./usbDeviceConfig.js'); + + const getDeviceConnected = async () => { + try { + const vendorToManufacturer = usbHidDeviceHandlerConfig.flatMap(entry => + entry.vendorIds.map(vendorId => ({ + vendorId, + name: entry.name + })) + ); + // Request devices matching the filters + const selectedDevices = await navigator.hid.requestDevice({ filters: vendorToManufacturer }); + + if (selectedDevices.length > 0) { + const rawDevice = selectedDevices[0]; + // Find the vendor configuration matching the selected device + const vendorConfig = usbHidDeviceHandlerConfig.find(entry => + entry.vendorIds.includes(rawDevice.vendorId) + ); + + if (!vendorConfig) { + console.error("No configuration found for vendor:", rawDevice.vendorId); + return { unsupported: true }; + } + + const model = rawDevice.productName; + + // Look up the model-specific configuration from the vendor config. + // Try three matching strategies in order of preference: + // 1. Match by productName in devices + // 2. Match by productId in deviceGroups + // 3. Fall back to defaultModelConfig + let deviceDetails = vendorConfig.devices?.[model]; + + // If no productName match, try matching by productId in deviceGroups + if (!deviceDetails && vendorConfig.deviceGroups) { + for (const [groupName, groupConfig] of Object.entries(vendorConfig.deviceGroups)) { + // Check if this group has a productIds array matching our device + if (Array.isArray(groupConfig.productIds) && + groupConfig.productIds.includes(rawDevice.productId)) { + deviceDetails = groupConfig; + console.log(`Matched device by productId in group: ${groupName} (0x${rawDevice.productId.toString(16)})`); + break; + } + } + } + + // Fall back to empty object if still no match + deviceDetails = deviceDetails || {}; + + let modelConfig = Object.assign( + {}, + vendorConfig.defaultModelConfig || {}, + deviceDetails.modelConfig || {} + ); + + const manufacturer = deviceDetails.manufacturer || vendorConfig.manufacturer; + let handler = deviceDetails.handler || vendorConfig.handler; + + // Check if already connected + if (currentDevice != null) { + return currentDevice; + } + + // Open the device if not already open + if (!rawDevice.opened) { + await rawDevice.open(); + } + currentDevice = { + rawDevice: rawDevice, + manufacturer: manufacturer, + model: model, + modelConfig: modelConfig, + handler: handler + }; + + return currentDevice; + } else { + // User cancelled the chooser dialog + console.log("USB HID chooser cancelled by user."); + return { cancelled: true }; + } + } catch (error) { + console.error("Failed to connect to HID device:", error); + return null; + } + }; + + const disconnectDevice = async () => { + if (currentDevice && currentDevice.rawDevice) { + try { + await currentDevice.rawDevice.close(); + console.log("Device disconnected:", currentDevice.model); + currentDevice = null; + } catch (error) { + console.error("Failed to disconnect device:", error); + } + } + }; + const checkDeviceConnected = async (device) => { + var rawDevice = device.rawDevice; + const rawDevices = await navigator.hid.getDevices(); + var matchingRawDevice = rawDevices.find(d => d.vendorId === rawDevice.vendorId && d.productId == rawDevice.productId); + if (typeof matchingRawDevice == 'undefined' || matchingRawDevice == null ) { + console.error("Device disconnected?"); + alert('Device disconnected?'); + return false; + } + // But lets check if we are still open otherwise we need to open the device again + if (!matchingRawDevice.opened) { + await matchingRawDevice.open(); + device.rawDevice = matchingRawDevice; // Swap the device over + } + return true; + }; + + const pushToDevice = async (device, phoneObj, slot, preamp, filters) => { + if (!await checkDeviceConnected(device)) { + throw Error("Device Disconnected"); + } + if (device && device.handler) { + + // Create a copy of the filters array to avoid modifying the original + const filtersToWrite = [...filters]; + + // Ensure array is at most the maxFilters + if (filtersToWrite.length > device.modelConfig.maxFilters) { + console.warn(`USB Device PEQ: Truncating ${filtersToWrite.length} filters to ${device.modelConfig.maxFilters} (device limit)`); + if (window.showToast) { + await window.showToast(`This device only supports ${device.modelConfig.maxFilters} PEQ filters - only first ${device.modelConfig.maxFilters} will be applied.`, "warning", 10000, true); + } + + filtersToWrite.splice(device.modelConfig.maxFilters); + } + + // And do an upfront sanity check on the values + for (let i = 0 ; i < filtersToWrite.length; i++) { + // A quick sanity check on the filters + if (filtersToWrite[i].freq < 20 || filtersToWrite[i].freq > 20000) { + filtersToWrite[i].freq = 100; + } + if (filtersToWrite[i].q < 0.01 || filtersToWrite[i].q > 100) { + filtersToWrite[i].q = 1; + } + } + + // Next, determine if we have LS/HS filters with non-zero gain + const hasLSHSFilters = filtersToWrite.some(filter => + (filter.type === "LSQ" || filter.type === "HSQ") && filter.gain !== 0); + + // Second, determine if we need pregain (only if globalGain is positive) + const needsPreGain = preamp < 0; + + // Handle LS/HS filters if device doesn't support them + if (hasLSHSFilters && device.modelConfig.supportsLSHSFilters === false) { + // Convert LS/HS filters with non-zero gain to PK with gain=0 + for (let i = 0; i < filtersToWrite.length; i++) { + if ((filtersToWrite[i].type === "LSQ" || filtersToWrite[i].type === "HSQ") && filtersToWrite[i].gain !== 0) { + console.log(`USB Device PEQ: converting ${filtersToWrite[i].type} filter to PK with gain=0`); + filtersToWrite[i].type = "PK"; + filtersToWrite[i].gain = 0; + } + } + } + + // Handle warnings based on device capabilities and filter requirements + if (hasLSHSFilters && device.modelConfig.supportsLSHSFilters === false && + needsPreGain && device.modelConfig.supportsPregain === false) { + // Case 1: Device doesn't support both LSHS filters and pregain + console.warn("Device doesn't support LS/HS filters and auto pregain - both will be ignored"); + if (window.showToast) { + window.showToast("Device doesn't support LS/HS filters and auto pregain - both will be ignored", "warning"); + } + } else if (hasLSHSFilters && device.modelConfig.supportsLSHSFilters === false) { + // Case 2: Device only doesn't support LSHS filters + console.warn("Device only supports Peak filters - ignoring LS/HS filters"); + if (window.showToast) { + window.showToast("Device only supports Peak filters - ignoring LS/HS filters", "warning"); + } + } else if (needsPreGain && device.modelConfig.supportsPregain === false) { + // Case 3: Device only doesn't support pregain + console.warn("Device does not support auto calculated pregain"); + if (window.showToast) { + window.showToast("Device does not support auto calculated pregain", "warning"); + } + } + + // If we have fewer filters than maxFilters, fill the rest with defaultResetFiltersValues + if (filtersToWrite.length < device.modelConfig.maxFilters && device.modelConfig.defaultResetFiltersValues) { + const defaultFilter = device.modelConfig.defaultResetFiltersValues[0]; + console.log(`USB Device PEQ: filling missing filters with defaults:`, defaultFilter); + + for (let i = filtersToWrite.length; i < device.modelConfig.maxFilters; i++) { + + filtersToWrite.push({...defaultFilter}); + } + } + + return await device.handler.pushToDevice(device, phoneObj, slot, preamp, filtersToWrite); + } else { + console.error("No device handler available for pushing."); + } + return true; // Disconnect anyway + }; + + // Helper Function to Get Available 'Custom' Slots Based on the Device that we can write too + const getAvailableSlots = async (device) => { + return device.modelConfig.availableSlots; + }; + + const getCurrentSlot = async (device) => { + if (device && device.handler) { + return await device.handler.getCurrentSlot(device) + }{ + console.error("No device handler available for querying"); + return -2; + } + }; + + const pullFromDevice = async (device, slot) => { + if (!await checkDeviceConnected(device)) { + throw Error("Device Disconnected"); + } + if (device && device.handler) { + return await device.handler.pullFromDevice(device, slot); + } else { + console.error("No device handler available for pulling."); + return { filters: [] }; // Empty filters + } + }; + + const enablePEQ = async (device, enabled, slotId) => { + if (device && device.handler) { + return await device.handler.enablePEQ(device, enabled, slotId); + } else { + console.error("No device handler available for enabling."); + } + }; + + const getCurrentDevice = () => currentDevice; + + return { + getDeviceConnected, + getAvailableSlots, + disconnectDevice, + pushToDevice, + pullFromDevice, + getCurrentDevice, + getCurrentSlot, + enablePEQ, + }; +})(); diff --git a/assets/js/devicePEQ/usbSerialConnector.js b/assets/js/devicePEQ/usbSerialConnector.js new file mode 100644 index 00000000..41d24f6b --- /dev/null +++ b/assets/js/devicePEQ/usbSerialConnector.js @@ -0,0 +1,239 @@ +// Copyright 2024 : Pragmatic Audio +// Declare UsbSerialConnector and attach it to the global window object + +export const UsbSerialConnector = (async function () { + let devices = []; + let currentDevice = null; + + const { usbSerialDeviceHandlerConfig } = await import('./usbSerialDeviceConfig.js'); + + const getDeviceConnected = async () => { + try { + // Build filters for device selection - support both USB and Bluetooth SPP + const filters = []; + + // Add USB vendor ID filters for traditional USB devices + for (const entry of usbSerialDeviceHandlerConfig) { + if (entry.vendorId) { + filters.push({ usbVendorId: entry.vendorId }); + } + // Add Bluetooth SPP filters for enhanced filtering + if (entry.filters && entry.filters.allowedBluetoothServiceClassIds) { + for (const serviceId of entry.filters.allowedBluetoothServiceClassIds) { + filters.push({ bluetoothServiceClassId: serviceId }); + } + } + } + + const requestOptions = {}; + if (filters.length > 0) { + requestOptions.filters = filters; + } + + // Also add allowedBluetoothServiceClassIds for Nothing devices + const bluetoothServiceIds = []; + for (const entry of usbSerialDeviceHandlerConfig) { + if (entry.filters && entry.filters.allowedBluetoothServiceClassIds) { + bluetoothServiceIds.push(...entry.filters.allowedBluetoothServiceClassIds); + } + } + if (bluetoothServiceIds.length > 0) { + requestOptions.allowedBluetoothServiceClassIds = bluetoothServiceIds; + } + + const rawDevice = await navigator.serial.requestPort(requestOptions); + const info = rawDevice.getInfo(); + const productId = info.usbProductId; + const bluetoothServiceClassId = info.bluetoothServiceClassId; + + let vendorConfig = null; + let modelName = null; + var modelConfig = {}; + var handler = null; + + // Enhanced device matching - support both USB and Bluetooth SPP + for (const entry of usbSerialDeviceHandlerConfig) { + let deviceMatched = false; + + // Check USB vendor ID match (traditional method) + if (entry.vendorId && entry.vendorId === info.usbVendorId) { + for (const [name, model] of Object.entries(entry.devices)) { + if (model.usbProductId === productId) { + vendorConfig = entry; + modelName = name; + modelConfig = model.modelConfig || {}; + handler = entry.handler; + deviceMatched = true; + break; + } + } + } + + // Check Bluetooth SPP UUID match (enhanced filtering) + if (!deviceMatched && entry.filters) { + const svc = (bluetoothServiceClassId || '').toLowerCase(); + const cfgSingle = (entry.filters.bluetoothServiceClassId || '').toLowerCase(); + const cfgList = Array.isArray(entry.filters.allowedBluetoothServiceClassIds) + ? entry.filters.allowedBluetoothServiceClassIds.map(x => String(x).toLowerCase()) + : []; + const matchesSingle = svc && cfgSingle && svc === cfgSingle; + const matchesAny = svc && cfgList.includes(svc); + if (matchesSingle || matchesAny) { + // For Bluetooth devices, use the first (and typically only) device entry + const deviceEntries = Object.entries(entry.devices); + if (deviceEntries.length > 0) { + const [name, model] = deviceEntries[0]; + vendorConfig = entry; + modelName = name; + modelConfig = model.modelConfig || {}; + handler = entry.handler; + deviceMatched = true; + } + } + } + + if (deviceMatched) break; + } + + if (!vendorConfig) { + const deviceId = productId ? `0x${productId.toString(16)}` : bluetoothServiceClassId || 'Unknown'; + document.getElementById('status').innerText = + `Status: Unsupported Device (${deviceId})`; + return; + } + + // Open device with appropriate baud rate + // - Bluetooth SPP typically uses 9600 + // - Otherwise default to 115200 unless overridden by modelConfig.baudRate + const defaultBaud = bluetoothServiceClassId ? 9600 : 115200; + const baudRate = (modelConfig && modelConfig.baudRate && !bluetoothServiceClassId) + ? modelConfig.baudRate + : defaultBaud; + await rawDevice.open({ baudRate }); + + // Set up readable and writable shim helpers for handlers expecting simple read()/write() + // Important: do NOT hold reader/writer locks persistently to avoid blocking other handlers (e.g., FiiO) + let readable = null; + let writable = null; + try { + if (rawDevice.readable && typeof rawDevice.readable.getReader === 'function') { + readable = { + async read() { + const r = rawDevice.readable.getReader(); + try { + const res = await r.read(); + return res; + } finally { + try { r.releaseLock(); } catch (_) {} + } + } + }; + } + if (rawDevice.writable && typeof rawDevice.writable.getWriter === 'function') { + writable = { + async write(data) { + const w = rawDevice.writable.getWriter(); + try { + await w.write(data); + } finally { + try { w.releaseLock(); } catch (_) {} + } + } + }; + } + } catch (e) { + console.warn('UsbSerialConnector: Failed to set up read/write shims:', e); + } + + const model = vendorConfig.model || modelName || "Unknown Serial Device"; + + currentDevice = { + rawDevice: rawDevice, + info, + manufacturer: vendorConfig.manufacturer, + model, + handler, + modelConfig, + // Backward-compatibility for handlers (e.g., Nothing) that call device.readable.read() / device.writable.write() + readable, + writable + }; + + devices.push(currentDevice); + return currentDevice; + } catch (error) { + // When the user cancels the port chooser, browsers typically throw NotFoundError + if (error && (error.name === 'NotFoundError' || error.code === 8)) { + console.log('Serial port chooser cancelled by user.'); + return { cancelled: true }; + } + console.error("Failed to connect to Serial device:", error); + return null; + } + }; + + const disconnectDevice = async () => { + if (currentDevice && currentDevice.rawDevice) { + try { + // Release reader/writer if we created them + try { + if (currentDevice.readable && typeof currentDevice.readable.releaseLock === 'function') { + currentDevice.readable.releaseLock(); + } + } catch (e) { + console.warn('UsbSerialConnector: releasing readable lock failed', e); + } + try { + if (currentDevice.writable && typeof currentDevice.writable.releaseLock === 'function') { + currentDevice.writable.releaseLock(); + } + } catch (e) { + console.warn('UsbSerialConnector: releasing writable lock failed', e); + } + + await currentDevice.rawDevice.close(); + devices = devices.filter(d => d !== currentDevice); + currentDevice = null; + console.log("Serial device disconnected."); + } catch (error) { + console.error("Failed to disconnect serial device:", error); + } + } + }; + + const pushToDevice = async (device, phoneObj, slot, preamp, filters) => { + if (!device || !device.handler) return; + return await device.handler.pushToDevice(device, phoneObj, slot, preamp, filters); + }; + + const pullFromDevice = async (device, slot) => { + if (!device || !device.handler) return { filters: [] }; + return await device.handler.pullFromDevice(device, slot); + }; + + const getAvailableSlots = async (device) => { + return device.modelConfig.availableSlots; + }; + + const getCurrentSlot = async (device) => { + if (device && device.handler) return await device.handler.getCurrentSlot(device); + return -2; + }; + + const enablePEQ = async (device, enabled, slotId) => { + if (device && device.handler) return await device.handler.enablePEQ(device, enabled, slotId); + }; + + const getCurrentDevice = () => currentDevice; + + return { + getDeviceConnected, + getAvailableSlots, + disconnectDevice, + pushToDevice, + pullFromDevice, + getCurrentDevice, + getCurrentSlot, + enablePEQ, + }; +})(); diff --git a/assets/js/devicePEQ/usbSerialDeviceConfig.js b/assets/js/devicePEQ/usbSerialDeviceConfig.js new file mode 100644 index 00000000..33a461bf --- /dev/null +++ b/assets/js/devicePEQ/usbSerialDeviceConfig.js @@ -0,0 +1,111 @@ +// Dynamically import the USB Serial handlers +const { jdsLabsUsbSerial } = await import('./jdsLabsUsbSerialHandler.js'); +const { nothingUsbSerial } = await import('./nothingUsbSerialHandler.js'); +const { fiioUsbSerial } = await import('./fiioUsbSerialHandler.js'); + +export const usbSerialDeviceHandlerConfig = [ + { + vendorId: 0x152a, // JDS Labs USB Vendor ID (common for JDS Labs / Teensy based boards) + manufacturer: "JDS Labs", + handler: jdsLabsUsbSerial, + devices: { + "Element IV": { + usbProductId: 35066, + modelConfig: { + minGain: -12, + maxGain: 12, + maxFilters: 10, + firstWritableEQSlot: 0, + maxWritableEQSlots: 1, + disconnectOnSave: false, + disabledPresetId: -1, + experimental: false, + availableSlots: [{ id: 0, name: "Headphones" },{ id: 1, name: "RCA" }] + } + } + } + }, + { + // Nothing headphones support both USB Serial and Bluetooth SPP + manufacturer: "Nothing", + handler: nothingUsbSerial, + // Enhanced filtering - support both USB vendor ID and Bluetooth SPP UUID + filters: { + // USB Serial filtering (if connected via USB) + usbVendorId: null, // Nothing doesn't have a specific USB vendor ID for headphones + // Bluetooth SPP filtering (primary connection method) + allowedBluetoothServiceClassIds: ["aeac4a03-dff5-498f-843a-34487cf133eb"], + bluetoothServiceClassId: "aeac4a03-dff5-498f-843a-34487cf133eb" + }, + devices: { + "Nothing Headphones": { + // No specific USB product ID since these are primarily Bluetooth devices + modelConfig: { + minGain: -12, + maxGain: 12, + maxFilters: 8, // Based on the EQ values parsing in the HTML + firstWritableEQSlot: 5, + maxWritableEQSlots: 1, // Only the Custom profile is writable + disconnectOnSave: false, + disabledPresetId: -1, + experimental: false, + readOnly: false, // Enable writing for Custom profile + availableSlots: [ + { id: 0, name: "Balanced" }, + { id: 1, name: "Voice" }, + { id: 2, name: "More Treble" }, + { id: 3, name: "More Bass" }, + { id: 5, name: "Custom" } + ] + } + } + } + }, + { + vendorId: 6790, // FiiO USB Vendor ID + manufacturer: "FiiO", + handler: fiioUsbSerial, + devices: { + "FiiO Audio DSP": { + usbProductId: 21971, + modelConfig: { + // Serial configuration + baudRate: 57600, + + // Model capabilities + minGain: -12, + maxGain: 12, + maxFilters: 10, // Typical FiiO EQ band count + firstWritableEQSlot: 0, + maxWritableEQSlots: 21, // Support for all FiiO presets + disconnectOnSave: false, + disabledPresetId: 11, // Based on FiiO code showing preset 11 for disabled EQ + experimental: false, + availableSlots: [ + { id: 240, name: "BYPASS" }, + { id: 0, name: "Jazz" }, + { id: 1, name: "Pop" }, + { id: 2, name: "Rock" }, + { id: 3, name: "Dance" }, + { id: 4, name: "R&B" }, + { id: 5, name: "Classic" }, + { id: 6, name: "Hip Hop" }, + { id: 8, name: "Retro" }, + { id: 9, name: "De-essing-1" }, + { id: 10, name: "De-essing-2" }, + { id: 160, name: "USER1" }, + { id: 161, name: "USER2" }, + { id: 162, name: "USER3" }, + { id: 163, name: "USER4" }, + { id: 164, name: "USER5" }, + { id: 165, name: "USER6" }, + { id: 166, name: "USER7" }, + { id: 167, name: "USER8" }, + { id: 168, name: "USER9" }, + { id: 169, name: "USER10" } + ] + } + } + } + } +]; diff --git a/assets/js/devicePEQ/walkplayHidHandler.js b/assets/js/devicePEQ/walkplayHidHandler.js new file mode 100644 index 00000000..b6c9ad22 --- /dev/null +++ b/assets/js/devicePEQ/walkplayHidHandler.js @@ -0,0 +1,364 @@ +// +// Copyright 2024 : Pragmatic Audio +// +// Define the shared logic for Walkplay devices +// +// Many thanks to ma0shu for providing a dump + +export const walkplayUsbHID = (function () { + const REPORT_ID = 0x4B; + const ALT_REPORT_ID = 0x3C; + const READ = 0x80; + const WRITE = 0x01; + const END = 0x00; + const CMD = { + PEQ_VALUES: 0x09, + VERSION: 0x0C, + TEMP_WRITE: 0x0A, + FLASH_EQ: 0x01, + GET_SLOT: 0x0F, + GLOBAL_GAIN: 0x03, + }; + + const DEFAULT_FILTER_COUNT = 8; + + const getCurrentSlot = async (deviceDetails) => { + const device = deviceDetails.rawDevice; + if (!device) throw new Error("Device not connected."); + + // Get the version number first + await sendReport(device, REPORT_ID, [READ, CMD.VERSION, END]); + var response = await waitForResponse(device); + const versionBytes = response.slice(3, 6); + const version = String.fromCharCode(...versionBytes); + + console.log("USB Device PEQ: Walkplay firmware version:", version); + const versionNumber = parseFloat(version); + + if (isNaN(versionNumber)) { + console.warn("Could not parse firmware version:", versionNumber); + deviceDetails.version = null; + } + + // Save version number to deviceDetails + deviceDetails.version = versionNumber; + + console.log("Fetching current EQ slot..."); + + await sendReport(device, REPORT_ID, [READ, CMD.PEQ_VALUES, END]); + response = await waitForResponse(device); + const slot = response ? response[35] : -1; + + console.log("Walkplay current EQ slot:", slot); + return slot; + }; + + // Push PEQ settings to Walkplay device + const pushToDevice = async (deviceDetails, phoneObj, slot, globalGain, filtersToWrite) => { + const device = deviceDetails.rawDevice; + if (!device) throw new Error("Device not connected."); + console.log("Pushing PEQ settings..."); + if (typeof slot === "string" ) // Convert from string + slot = parseInt(slot, 10); + + const useAltReport = false; + + for (let i = 0; i < filtersToWrite.length; i++) { + const filter = filtersToWrite[i]; + const bArr = computeIIRFilter(i, filter.freq, filter.gain, filter.q); + + const packet = [ + WRITE, CMD.PEQ_VALUES, 0x18, 0x00, i, 0x00, 0x00, + ...bArr, + ...convertToByteArray(filter.freq, 2), + ...convertToByteArray(Math.round(filter.q * 256), 2), + ...convertToByteArray(Math.round(filter.gain * 256), 2), + convertFromFilterType(filter.type), + 0x00, + (deviceDetails.modelConfig && typeof deviceDetails.modelConfig.defaultIndex !== 'undefined') ? deviceDetails.modelConfig.defaultIndex : slot, + END + ]; + + await sendReport(device, useAltReport ? ALT_REPORT_ID : REPORT_ID, packet); + } + + if (deviceDetails.modelConfig && typeof deviceDetails.modelConfig.autoGlobalGain !== 'undefined') { + // If the walkplay device auto calculates global gain we can leave the global gain as it was + if (!deviceDetails.modelConfig.autoGlobalGain) { + // Write the global gain + await writeGlobalGain(device, globalGain); + console.log(`USB Device PEQ: Walkplay set global gain to ${globalGain}`); + } + } + + await sendReport(device, REPORT_ID, [WRITE, CMD.TEMP_WRITE, 0x04, 0x00, 0x00, 0xFF, 0xFF, END]); + await sendReport(device, REPORT_ID, [WRITE, CMD.FLASH_EQ, 0x01, END]); + + console.log("PEQ filters successfully pushed to Walkplay device."); + }; + + function convertFromFilterType(filterType) { + const mapping = {"PK": 2, "LSQ": 1, "HSQ": 3}; + return mapping[filterType] !== undefined ? mapping[filterType] : 2; + } + + const pullFromDevice = async (deviceDetails, slot = -1) => { + const device = deviceDetails.rawDevice; + if (!device) throw new Error("Device not connected."); + + const filters = []; + let currentSlot = -1; + + device.oninputreport = async (event) => { + const data = new Uint8Array(event.data.buffer); + console.log(`USB Device PEQ: Walkplay pullFromDevice onInputReport received data:`, data); + + if (data.length >= 32) { + const filter = parseFilterPacket(data); + console.log(`USB Device PEQ: Walkplay parsed filter ${filter.filterIndex}:`, filter); + filters[filter.filterIndex] = filter; + } + + if (data.length >= 37) { + currentSlot = data[35]; + console.log(`USB Device PEQ: Walkplay parsed current slot: ${currentSlot}`); + } + }; + + // Send requests for each filter with increased delay + for (let i = 0; i < deviceDetails.modelConfig.maxFilters; i++) { + await sendReport(device, REPORT_ID, [READ, CMD.PEQ_VALUES, 0x00, 0x00, i, END]); + await delay(50); // Increased delay between requests + } + + // Check for missing filters after initial requests + await delay(100); // Wait a bit after sending all requests + + // Wait for filters with increased timeout + const result = await waitForFilters(() => { + return filters.filter(f => f !== undefined).length === deviceDetails.modelConfig.maxFilters; + }, device, 10000, () => ({ // Increased timeout to 15 seconds + filters, + globalGain: 0, // Will be updated after waiting for filters + currentSlot, + deviceDetails: deviceDetails.modelConfig, + })); + + device.oninputreport = null; // Stop listening on this callback for now + + + // Read global gain after waiting for filters + let globalGain = 0; + try { + globalGain = await readGlobalGain(device); + console.log(`USB Device PEQ: Walkplay read global gain: ${globalGain}dB`); + // Update the result with the global gain + result.globalGain = globalGain; + } catch (error) { + console.warn(`USB Device PEQ: Walkplay failed to read global gain: ${error}`); + } + + console.log("Pulled PEQ filters from Walkplay:", result); + return result; + }; + + function parseFilterPacket(packet) { + if (packet.length < 32) { + throw new Error("Packet too short to contain filter data."); + } + + const filterIndex = packet[4]; + + // Frequency (little-endian 16-bit) + const freq = packet[27] | (packet[28] << 8); + + // Q factor (8.8 fixed-point) + const qRaw = packet[29] | (packet[30] << 8); + const q = Math.round((qRaw / 256) * 100) / 100; + + // Gain (8.8 fixed-point signed) + let gainRaw = packet[31] | (packet[32] << 8); + if (gainRaw > 32767) gainRaw -= 65536; + const gain = Math.round((gainRaw / 256) * 100) / 100; + + // Filter type — + const type = convertToFilterType(packet[33]); + + return { + filterIndex, + freq, + q, + gain, + type, + disabled: !(freq || q || gain) + }; + } + + function convertToFilterType(byte) { + switch (byte) { + case 1: return "LSQ"; // Low Shelf (if seen in future captures) + case 2: return "PK"; // Peaking + case 3: return "HSQ"; // High Shelf (future-proof) + default: return "PK"; + } + } + const enablePEQ = async (deviceDetails, enable, slotId) => { + const device = deviceDetails.rawDevice; + if (!enable) { + slotId = 0x00; + } + const packet = [WRITE, CMD.FLASH_EQ, enable ? 1:0, slotId, END]; + await sendReport(device, REPORT_ID, packet); + }; + + +// Internal functions + async function sendReport(device, reportId, packet) { + if (!device) throw new Error("Device not connected."); + const data = new Uint8Array(packet); + console.log(`USB Device PEQ: Walkplay sending report (ID: ${reportId}):`, data); + await device.sendReport(reportId, data); + } + +// Wait for response + async function waitForResponse(device, timeout = 2000) { + return new Promise((resolve, reject) => { + let response = null; + const timer = setTimeout(() => { + console.log(`USB Device PEQ: Walkplay timeout waiting for response after ${timeout}ms`); + reject("Timeout waiting for HID response"); + }, timeout); + + device.oninputreport = (event) => { + clearTimeout(timer); + response = new Uint8Array(event.data.buffer); + console.log(`USB Device PEQ: Walkplay received response:`, response); + resolve(response); + }; + }); + } + + // Read global gain from device + async function readGlobalGain(device) { + return new Promise(async (resolve, reject) => { + const request = new Uint8Array([READ, CMD.GLOBAL_GAIN, 0x00]); + + const timeout = setTimeout(() => { + device.removeEventListener("inputreport", onReport); + reject("Timeout reading global gain"); + }, 100); + + const onReport = (event) => { + const data = new Uint8Array(event.data.buffer); + console.log(`USB Device PEQ: Walkplay onInputReport received global gain data:`, data); + clearTimeout(timeout); + device.removeEventListener("inputreport", onReport); + if (data[0] !== READ || data[1] !== CMD.GLOBAL_GAIN) return; + const int8 = new Int8Array([data[4]])[0]; + const globalGain = int8; + console.log(`USB Device PEQ: Walkplay global gain value: ${globalGain}`); + resolve(globalGain); + }; + + device.addEventListener("inputreport", onReport); + console.log(`USB Device PEQ: Walkplay sending readGlobalGain command:`, request); + await device.sendReport(REPORT_ID, request); + }); + } + +// Write global gain to device + async function writeGlobalGain(device, value) { + const gainValue = Math.round(value); + // Match attached KeyX JS format: [WRITE, GLOBAL_GAIN, 0x02, 0x00, gain] + const request = new Uint8Array([WRITE, CMD.GLOBAL_GAIN, 0x02, 0x00, gainValue]); + console.log(`USB Device PEQ: Walkplay sending writeGlobalGain command:`, request); + await device.sendReport(REPORT_ID, request); + } + + return { + pushToDevice, + pullFromDevice, + getCurrentSlot, + enablePEQ + }; +})(); + +function delay(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +async function waitForFilters(condition, device, timeout, callback) { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + if (!condition()) { + console.warn("Timeout: Filters not fully received."); + // Instead of rejecting with the callback result, create a proper result with partial data + const result = callback(device); + // Add information about the timeout to help with debugging + result.complete = false; + result.receivedCount = result.filters.filter(f => f !== undefined).length; + result.expectedCount = device.max; + // Resolve with partial data instead of rejecting + resolve(result); + } else { + const result = callback(device); + result.complete = true; + resolve(result); + } + }, timeout); + + const interval = setInterval(() => { + if (condition()) { + clearTimeout(timer); + clearInterval(interval); + const result = callback(device); + result.complete = true; + resolve(result); + } + }, 100); + }); +} + + + +// Compute IIR filter +function computeIIRFilter(i, freq, gain, q) { + let bArr = new Array(20).fill(0); + let sqrt = Math.sqrt(Math.pow(10, gain / 20)); + let d3 = (freq * 6.283185307179586) / 96000; + let sin = Math.sin(d3) / (2 * q); + let d4 = sin * sqrt; + let d5 = sin / sqrt; + let d6 = d5 + 1; + let quantizerData = quantizer( + [1, (Math.cos(d3) * -2) / d6, (1 - d5) / d6], + [(d4 + 1) / d6, (Math.cos(d3) * -2) / d6, (1 - d4) / d6] + ); + + let index = 0; + for (let value of quantizerData) { + bArr[index] = value & 0xFF; + bArr[index + 1] = (value >> 8) & 0xFF; + bArr[index + 2] = (value >> 16) & 0xFF; + bArr[index + 3] = (value >> 24) & 0xFF; + index += 4; + } + + return bArr; +} + +// Convert values to byte array +function convertToByteArray(value, length) { + let arr = []; + for (let i = 0; i < length; i++) { + arr.push((value >> (8 * i)) & 0xFF); + } + return arr; +} + +// Quantizer function for IIR filter +function quantizer(dArr, dArr2) { + let iArr = dArr.map(d => Math.round(d * 1073741824)); + let iArr2 = dArr2.map(d => Math.round(d * 1073741824)); + return [iArr2[0], iArr2[1], iArr2[2], -iArr[1], -iArr[2]]; +} diff --git a/assets/js/devicePEQ/wiimNetworkHandler.js b/assets/js/devicePEQ/wiimNetworkHandler.js new file mode 100644 index 00000000..2e569c2d --- /dev/null +++ b/assets/js/devicePEQ/wiimNetworkHandler.js @@ -0,0 +1,263 @@ +// +// Copyright 2024 : Pragmatic Audio +// +// Define the WiiM Network Handler for PEQ over HTTP API +// + +const PLUGIN_URI = "http://moddevices.com/plugins/caps/EqNp"; + +export const wiimNetworkHandler = (function () { + + /** + * Fetch PEQ settings from the device + * @param {string} device - The device + * @param {number} slot - The PEQ slot (currently not used in WiiM API) + * @returns {Promise} The parsed EQ settings + */ + async function pullFromDevice(device, slot) { + try { + const payload = { + source_name: SOURCE_NAME, + pluginURI: PLUGIN_URI + }; + const url = `https://${device.ip}/httpapi.asp?command=EQGetLV2SourceBandEx:${encodeURIComponent(JSON.stringify(payload))}`; + console.log(`Device PEQ: WiiM sending request to fetch EQ data:`, payload); + + const response = await fetch(url, {method: "GET", mode: "no-cors"}); + + if (!response.status) + throw new Error(`Failed to fetch PEQ data: ${response.status}`); + + const data = await response.json(); + if (data.status !== "OK") throw new Error(`PEQ fetch failed: ${JSON.stringify(data)}`); + + console.log("Device PEQ: WiiM received EQ data:", data); + + const filters = parseWiiMEQData(data); + return {filters, globalGain: 0, currentSlot: slot, deviceDetails: {maxFilters: 10}}; + + } catch (error) { + console.error("Error pulling PEQ settings from WiiM:", error); + throw error; + } + } + + /** + * Push PEQ settings to the device + * @param {string} device - The device + * @param {number} slot - The PEQ slot (currently not used in WiiM API) + * @param {number} preamp - The preamp gain + * @param {Array} filters - Array of PEQ filters + * @returns {Promise} Returns true if push was successful + */ + async function pushToDevice(device, phoneObj, slot, preamp, filters, _modelConfig) { + try { + const MAX_BANDS = 10; //fallback to 10 + + // Only take up to MAX_BANDS filters + const effectiveFilters = Array.isArray(filters) ? filters.slice(0, MAX_BANDS) : []; + + // 1) Populate provided filters (a..? up to MAX_BANDS) + const eqBandData = effectiveFilters.map((filter, index) => ({ + param_name: `${String.fromCharCode(97 + index)}_mode`, + value: filter.disabled ? -1 : convertToWiimMode(filter.type), + })); + + effectiveFilters.forEach((filter, index) => { + const band = String.fromCharCode(97 + index); + eqBandData.push( + { param_name: `${band}_freq`, value: filter.freq }, + { param_name: `${band}_q`, value: filter.q }, + { param_name: `${band}_gain`, value: filter.gain } + ); + }); + + // 2) Reset any remaining bands up to MAX_BANDS + // This ensures previously-set filters on the device are cleared. + // We explicitly set gain to 0 and disable the band (mode -1). + for (let i = effectiveFilters.length; i < MAX_BANDS; i++) { + const band = String.fromCharCode(97 + i); // a..j + eqBandData.push( + { param_name: `${band}_mode`, value: -1 }, // Off + { param_name: `${band}_freq`, value: 1000 }, // sensible default (unused when Off) + { param_name: `${band}_q`, value: 1 }, + { param_name: `${band}_gain`, value: 0 } + ); + } + + const payload = { + pluginURI: PLUGIN_URI, // e.g., "http://moddevices.com/plugins/caps/EqNp" + source_name: "wifi", // or "bt", "line_in", etc. Always Wifi for now + EQBand: eqBandData, + EQStat: "On", // Enable EQ + channelMode: "Stereo", // Use stereo mode + }; + + const deviceIp = typeof device === 'string' ? device : device.ip; + const url = `https://${deviceIp}/httpapi.asp?command=EQSetLV2SourceBand:${encodeURIComponent(JSON.stringify(payload))}`; + console.log(`Device PEQ: WiiM sending request to set EQ data:`, payload); + + const response = await fetch(url, { method: "GET", mode: "no-cors" }); + + if (response.status != 0) + throw new Error(`Failed to push PEQ data: ${response.status}`); + + if (response.type !== "opaque") { + const data = await response.json(); + console.log(`Device PEQ: WiiM received response for set EQ:`, data); + if (data.status !== "OK") + throw new Error(`PEQ push failed: ${JSON.stringify(data)}`); + } else { + console.log("Device PEQ: WiiM cannot read response due to security reasons (CORS)"); + } + + // Now set the Preset Name - ultimately get the headphone name from custom parameters but not for now + const presetNamePayload = { + pluginURI: PLUGIN_URI, // e.g., "http://moddevices.com/plugins/caps/EqNp" + source_name: "wifi", // or "bt", "line_in", etc. + Name: "HeadphoneEQ" // Custom preset name + } + // Optional preset naming hint if API supports it in future + if (phoneObj && phoneObj.fileName) { + presetNamePayload.Name = phoneObj.fileName; + } + + const presetNameUrl = `https://${device.ip}/httpapi.asp?command=EQSourceSave:${encodeURIComponent(JSON.stringify(presetNamePayload))}`; + console.log(`Device PEQ: WiiM sending request to save preset name:`, presetNamePayload); + + const presetNameResponse = await fetch(presetNameUrl, { method: "GET", mode: "no-cors" }); + + if (presetNameResponse.status != 0) + throw new Error(`Failed to push PEQ data: ${presetNameResponse.status}`); + + if (presetNameResponse.type !== "opaque") { + const data = await presetNameResponse.json(); + console.log(`Device PEQ: WiiM received response for preset name:`, data); + if (data.status !== "OK") + throw new Error(`PEQ Name push failed: ${JSON.stringify(data)}`); + } else { + console.log("Device PEQ: WiiM cannot read preset name response due to security reasons (CORS)"); + } + + console.log("Device PEQ: WiiM settings successfully pushed to device"); + + + console.log("WiiM PEQ updated successfully"); + return false; // We don't need to restart + + } catch (error) { + console.error("Error pushing PEQ settings to WiiM:", error); + throw error; + } + } + + /** + * Enable or disable PEQ + * @param {string} device - The device + * @param {boolean} enabled - Whether to enable or disable PEQ + * @param {number} slotId - The PEQ slot (currently not used in WiiM API) + * @returns {Promise} + */ + async function enablePEQ(device, enabled, slotId) { + try { + const command = enabled ? "EQChangeSourceFX" : "EQSourceOff"; + const payload = {source_name: SOURCE_NAME, pluginURI: PLUGIN_URI}; + const url = `https://${device.ip}/httpapi.asp?command=${command}:${encodeURIComponent(JSON.stringify(payload))}`; + const response = await fetch(url, {method: "GET"}); + + if (!response.ok) throw new Error(`Failed to ${enabled ? "enable" : "disable"} PEQ: ${response.status}`); + + const data = await response.json(); + if (data.status !== "OK") throw new Error(`PEQ ${enabled ? "enable" : "disable"} failed: ${JSON.stringify(data)}`); + + console.log(`WiiM PEQ ${enabled ? "enabled" : "disabled"} successfully`); + + } catch (error) { + console.error("Error toggling WiiM PEQ:", error); + throw error; + } + } + + /** + * Parse WiiM PEQ JSON response into a standardized format + * @param {Object} data - The WiiM PEQ data + * @returns {Array} Formatted PEQ filter list + */ + function parseWiiMEQData(data) { + const eqBands = data.EQBand || []; + const filters = []; + + for (let i = 0; i < eqBands.length; i += 4) { + const filterType = convertFromWiimMode(eqBands[i].value); + const frequency = eqBands[i + 1].value; + const qFactor = eqBands[i + 2].value; + const gain = eqBands[i + 3].value; + + filters.push({ + type: filterType, + freq: frequency, + q: qFactor, + gain: gain, + disabled: filterType === "Off", + }); + } + + return filters; + } + + /** + * Convert internal filter type to WiiM filter mode + * @param {string} type - Internal filter type (PK, LSQ, HSQ) + * @returns {number} WiiM PEQ mode value + */ + function convertToWiimMode(type) { + const mapping = {"Off": -1, "Low-Shelf": 0, "Peak": 1, "High-Shelf": 2}; + return mapping[type] !== undefined ? mapping[type] : 1; + } + + /** + * Convert WiiM filter mode to internal filter type + * @param {number} mode - WiiM PEQ mode value + * @returns {string} Internal filter type + */ + function convertFromWiimMode(mode) { + switch (mode) { + case 0: + return "Low-Shelf"; + case 1: + return "Peak"; + case 2: + return "High-Shelf"; + default: + return "Off"; + } + } + + async function getCurrentSlot(device) { + return 0; + } + + async function getAvailableSlots(device) { + const url = `https://${device.ip}/httpapi.asp?command=EQv2GetList:${encodeURIComponent(PLUGIN_URI)}`; + try { + const response = await fetch(url, {method: "GET", mode: "no-cors" }); + if (!response.status == 0) { + throw new Error(`Failed to fetch preset list: ${response.status}`); + } + + return [ {id: 0, name: "Cannot read"}]; + + } catch (error) { + console.error("Error retrieving preset list from WiiM:", error); + throw error; + } + } + + return { + getCurrentSlot, + getAvailableSlots, + pullFromDevice, + pushToDevice, + enablePEQ, + }; +})(); diff --git a/assets/js/graphAnalytics.js b/assets/js/graphAnalytics.js index 2c7fef2e..d809ab8b 100644 --- a/assets/js/graphAnalytics.js +++ b/assets/js/graphAnalytics.js @@ -1,6 +1,29 @@ -let analyticsSite = "Generic Graph site", // Site name for attributing analytics events to your site +// Set these variables to your own GTM ID and site name +let analyticsSite = "HarutoHiroki", // Site name for attributing analytics events to your site + analyticsGtmId = "", // GTM ID used for analytics. If you don't already have one, you'' need to create a Google Tag Manager account logAnalytics = true; // If true, events are logged in console +// Load Google Tag Manager onto the page +function setupGraphAnalytics() { + const gtmScriptContent = "(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src='https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f);})(window,document,'script','dataLayer','"+ analyticsGtmId +"');", + gtmIframeSrc = "https://www.googletagmanager.com/ns.html?id="+ analyticsGtmId, + gtmIframeStyle = "display: none; visibility: hidden;", + graphAnalyticsSrc = "graphAnalytics.js"; + + const pageHead = document.querySelector("head"), + pageBody = document.querySelector("body"), + gtmScript = document.createElement("script"), + gtmNoscript = document.createElement("noscript"), + gtmIframe = document.createElement("iframe"), + graphAnalytics = document.createElement("script"); + + gtmScript.textContent = gtmScriptContent; + pageHead.prepend(gtmScript); +} +setupGraphAnalytics(); + +window.dataLayer = window.dataLayer || []; + // ************************************************************* @@ -11,29 +34,43 @@ let analyticsSite = "Generic Graph site", // Site name for attributing function pushPhoneTag(eventName, p, trigger) { let eventTrigger = trigger ? trigger : "user", phoneBrand = p.dispBrand ? p.dispBrand : "Target", - phoneModel = p.dispName, + phoneModel = p.phone, + phoneVariant = p.dispName, value = 1; - // Write function here to push event with the values described above + window.dataLayer.push({ + "event" : eventName, + "trigger" : eventTrigger, + "site": analyticsSite, + "phoneBrand": phoneBrand, + "phoneModel": phoneModel, + "phoneVariant": phoneVariant, + "phoneName" : phoneBrand + ' ' + phoneModel, + "value": value + }); - if (logAnalytics) { console.log("Event: "+ eventName +"\nTrigger: "+ eventTrigger +"\nSite: "+ analyticsSite +"\nPhone: "+ phoneBrand +" "+ phoneModel); } + if (logAnalytics) { console.log("Event: "+ eventName +"\nTrigger: "+ eventTrigger +"\nSite: "+ analyticsSite +"\nPhone: "+ phoneBrand +" "+ phoneModel +"\nVariant: " + phoneVariant); } } - - // For events not related to a specific phone, e.g. user clicked screenshot button -function pushEventTag(eventName, targetWindow, trigger) { +function pushEventTag(eventName, targetWindow, other, trigger) { let eventTrigger = trigger ? trigger : "user", url = targetWindow.location.href, par = "?share=", value = 1, - activePhones = url.includes(par) ? decodeURI(url.replace(/_/g," ").split(par).pop().replace(/,/g, ", ")) : "null"; - - // Write function here to push event with the values described above + activePhones = url.includes(par) ? decodeURI(url.replace(/_/g," ").split(par).pop().replace(/,/g, ", ")) : "null", + otherData = other ? other : "null"; + + window.dataLayer.push({ + "event" : eventName, + "trigger" : eventTrigger, + "site": analyticsSite, + "activePhones": activePhones, + "other": otherData, + "value": value + }); - if (logAnalytics) { console.log("Event: "+ eventName +"\nTrigger: "+ eventTrigger +"\nSite name: "+ analyticsSite +"\nActive: "+activePhones); } + if (logAnalytics) { console.log("Event: "+ eventName +"\nTrigger: "+ eventTrigger +"\nSite name: "+ analyticsSite +"\nActive: "+ activePhones +"\nOther: "+ otherData); } } - - if (logAnalytics) { console.log("... Analytics initialized ... "); } \ No newline at end of file diff --git a/assets/js/graphtool.js b/assets/js/graphtool.js index df5a28e5..f73c65cf 100644 --- a/assets/js/graphtool.js +++ b/assets/js/graphtool.js @@ -1,3 +1,5 @@ +// Original code by Marshall Lochbaum (https://github.com/mlochbaum) +// Heavily modified by HarutoHiroki (https://harutohiroki.com) let doc = d3.select(".graphtool"); doc.html(` @@ -17,6 +19,14 @@ doc.html(` PIN + + + + + + + + @@ -88,7 +98,7 @@ doc.html(`
- Custom Delta Tilt: + Preference Adjustments:
Tilt (dB/Oct) @@ -105,8 +115,8 @@ doc.html(` Ear Gain (dB)
- - + +
@@ -160,15 +170,17 @@ doc.html(`