benjamin-paine
commited on
Commit
·
5a0ba44
1
Parent(s):
94c7354
initial commit
Browse files- Dockerfile +65 -0
- LICENSE +201 -0
- README.md +6 -4
- config/dispatcher.yaml +31 -0
- config/nginx.conf +81 -0
- config/overseer.yaml +24 -0
- run.sh +206 -0
- src/anachrovox/__init__.py +1 -0
- src/anachrovox/role.py +47 -0
- www/alert.js +59 -0
- www/audio.js +97 -0
- www/helpers.js +150 -0
- www/index.css +1667 -0
- www/index.html +227 -0
- www/index.js +550 -0
- www/inputs.js +598 -0
- www/sentence.js +110 -0
- www/static/aluminum-circle-specular.jpg +0 -0
- www/static/aluminum-spiral.jpg +0 -0
- www/static/aluminum.jpg +0 -0
- www/static/bezel.png +0 -0
- www/static/black-plastic.jpg +0 -0
- www/static/button-background.png +0 -0
- www/static/button-foreground.png +0 -0
- www/static/bytebounce.woff +0 -0
- www/static/bytebounce.woff2 +0 -0
- www/static/github.svg +14 -0
- www/static/oscilloscope-grid.png +0 -0
- www/static/screen-reflection-circle.jpg +0 -0
- www/static/screen-reflection.jpg +0 -0
- www/static/shading-circle.png +0 -0
- www/static/wheel-shadow.png +0 -0
- www/static/wheel.jpg +0 -0
- www/static/wood.jpg +0 -0
- www/visualizer.js +121 -0
Dockerfile
ADDED
@@ -0,0 +1,65 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
FROM nvidia/cuda:12.1.1-devel-ubuntu22.04
|
2 |
+
|
3 |
+
# Model choices
|
4 |
+
ARG TEXT_MODEL=llama-v3-2-3b-instruct-q6-k
|
5 |
+
ARG TRANSCRIBE_MODEL=distilled-whisper-large-v3
|
6 |
+
ARG SPEECH_MODEL=xtts-v2
|
7 |
+
|
8 |
+
# Create user
|
9 |
+
RUN useradd -m -u 1000 anachrovox
|
10 |
+
|
11 |
+
# Set home and work directory
|
12 |
+
ENV HOME=/app \
|
13 |
+
PATH=/app/.local/bin:$PATH
|
14 |
+
WORKDIR /app
|
15 |
+
|
16 |
+
# Copy configuration
|
17 |
+
COPY config/nginx.conf /app/nginx.conf
|
18 |
+
COPY config/dispatcher.yaml /app/dispatcher.yaml
|
19 |
+
COPY config/overseer.yaml /app/overseer.yaml
|
20 |
+
|
21 |
+
# Copy WWW contents
|
22 |
+
ADD www /app/www
|
23 |
+
|
24 |
+
# Copy Anachrovox application code
|
25 |
+
ADD src/anachrovox /app/anachrovox
|
26 |
+
|
27 |
+
# Create log directory
|
28 |
+
RUN mkdir -p /app/logs
|
29 |
+
|
30 |
+
# Expose port
|
31 |
+
EXPOSE 7860
|
32 |
+
|
33 |
+
# Install packages including spaces dev mode requirements
|
34 |
+
RUN apt-get update && \
|
35 |
+
apt-get install -y \
|
36 |
+
bash \
|
37 |
+
git git-lfs \
|
38 |
+
curl wget procps \
|
39 |
+
htop vim \
|
40 |
+
python3-pip python3-dev \
|
41 |
+
nginx && \
|
42 |
+
rm -rf /var/lib/apt/lists/*
|
43 |
+
|
44 |
+
# Adjust permissions
|
45 |
+
RUN chown -R 1000 /var/log/nginx /var/lib/nginx /app
|
46 |
+
|
47 |
+
# Drop privileges
|
48 |
+
USER 1000
|
49 |
+
|
50 |
+
# Install taproot
|
51 |
+
RUN pip3 install --no-cache-dir taproot[tools,console,av]
|
52 |
+
|
53 |
+
# Install models - spaces doesn't seem to care about layer size, so we minimize overall size instead
|
54 |
+
RUN taproot install \
|
55 |
+
audio-transcription:${TRANSCRIBE_MODEL} \
|
56 |
+
text-generation:${TEXT_MODEL} \
|
57 |
+
speech-synthesis:${SPEECH_MODEL} \
|
58 |
+
--debug \
|
59 |
+
--optional
|
60 |
+
|
61 |
+
# Copy run script
|
62 |
+
COPY --chown=anachrovox --chmod=755 run.sh /app/run.sh
|
63 |
+
|
64 |
+
# Run the application
|
65 |
+
CMD ["/app/run.sh"]
|
LICENSE
ADDED
@@ -0,0 +1,201 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
Apache License
|
2 |
+
Version 2.0, January 2004
|
3 |
+
http://www.apache.org/licenses/
|
4 |
+
|
5 |
+
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
6 |
+
|
7 |
+
1. Definitions.
|
8 |
+
|
9 |
+
"License" shall mean the terms and conditions for use, reproduction,
|
10 |
+
and distribution as defined by Sections 1 through 9 of this document.
|
11 |
+
|
12 |
+
"Licensor" shall mean the copyright owner or entity authorized by
|
13 |
+
the copyright owner that is granting the License.
|
14 |
+
|
15 |
+
"Legal Entity" shall mean the union of the acting entity and all
|
16 |
+
other entities that control, are controlled by, or are under common
|
17 |
+
control with that entity. For the purposes of this definition,
|
18 |
+
"control" means (i) the power, direct or indirect, to cause the
|
19 |
+
direction or management of such entity, whether by contract or
|
20 |
+
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
21 |
+
outstanding shares, or (iii) beneficial ownership of such entity.
|
22 |
+
|
23 |
+
"You" (or "Your") shall mean an individual or Legal Entity
|
24 |
+
exercising permissions granted by this License.
|
25 |
+
|
26 |
+
"Source" form shall mean the preferred form for making modifications,
|
27 |
+
including but not limited to software source code, documentation
|
28 |
+
source, and configuration files.
|
29 |
+
|
30 |
+
"Object" form shall mean any form resulting from mechanical
|
31 |
+
transformation or translation of a Source form, including but
|
32 |
+
not limited to compiled object code, generated documentation,
|
33 |
+
and conversions to other media types.
|
34 |
+
|
35 |
+
"Work" shall mean the work of authorship, whether in Source or
|
36 |
+
Object form, made available under the License, as indicated by a
|
37 |
+
copyright notice that is included in or attached to the work
|
38 |
+
(an example is provided in the Appendix below).
|
39 |
+
|
40 |
+
"Derivative Works" shall mean any work, whether in Source or Object
|
41 |
+
form, that is based on (or derived from) the Work and for which the
|
42 |
+
editorial revisions, annotations, elaborations, or other modifications
|
43 |
+
represent, as a whole, an original work of authorship. For the purposes
|
44 |
+
of this License, Derivative Works shall not include works that remain
|
45 |
+
separable from, or merely link (or bind by name) to the interfaces of,
|
46 |
+
the Work and Derivative Works thereof.
|
47 |
+
|
48 |
+
"Contribution" shall mean any work of authorship, including
|
49 |
+
the original version of the Work and any modifications or additions
|
50 |
+
to that Work or Derivative Works thereof, that is intentionally
|
51 |
+
submitted to Licensor for inclusion in the Work by the copyright owner
|
52 |
+
or by an individual or Legal Entity authorized to submit on behalf of
|
53 |
+
the copyright owner. For the purposes of this definition, "submitted"
|
54 |
+
means any form of electronic, verbal, or written communication sent
|
55 |
+
to the Licensor or its representatives, including but not limited to
|
56 |
+
communication on electronic mailing lists, source code control systems,
|
57 |
+
and issue tracking systems that are managed by, or on behalf of, the
|
58 |
+
Licensor for the purpose of discussing and improving the Work, but
|
59 |
+
excluding communication that is conspicuously marked or otherwise
|
60 |
+
designated in writing by the copyright owner as "Not a Contribution."
|
61 |
+
|
62 |
+
"Contributor" shall mean Licensor and any individual or Legal Entity
|
63 |
+
on behalf of whom a Contribution has been received by Licensor and
|
64 |
+
subsequently incorporated within the Work.
|
65 |
+
|
66 |
+
2. Grant of Copyright License. Subject to the terms and conditions of
|
67 |
+
this License, each Contributor hereby grants to You a perpetual,
|
68 |
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
69 |
+
copyright license to reproduce, prepare Derivative Works of,
|
70 |
+
publicly display, publicly perform, sublicense, and distribute the
|
71 |
+
Work and such Derivative Works in Source or Object form.
|
72 |
+
|
73 |
+
3. Grant of Patent License. Subject to the terms and conditions of
|
74 |
+
this License, each Contributor hereby grants to You a perpetual,
|
75 |
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
76 |
+
(except as stated in this section) patent license to make, have made,
|
77 |
+
use, offer to sell, sell, import, and otherwise transfer the Work,
|
78 |
+
where such license applies only to those patent claims licensable
|
79 |
+
by such Contributor that are necessarily infringed by their
|
80 |
+
Contribution(s) alone or by combination of their Contribution(s)
|
81 |
+
with the Work to which such Contribution(s) was submitted. If You
|
82 |
+
institute patent litigation against any entity (including a
|
83 |
+
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
84 |
+
or a Contribution incorporated within the Work constitutes direct
|
85 |
+
or contributory patent infringement, then any patent licenses
|
86 |
+
granted to You under this License for that Work shall terminate
|
87 |
+
as of the date such litigation is filed.
|
88 |
+
|
89 |
+
4. Redistribution. You may reproduce and distribute copies of the
|
90 |
+
Work or Derivative Works thereof in any medium, with or without
|
91 |
+
modifications, and in Source or Object form, provided that You
|
92 |
+
meet the following conditions:
|
93 |
+
|
94 |
+
(a) You must give any other recipients of the Work or
|
95 |
+
Derivative Works a copy of this License; and
|
96 |
+
|
97 |
+
(b) You must cause any modified files to carry prominent notices
|
98 |
+
stating that You changed the files; and
|
99 |
+
|
100 |
+
(c) You must retain, in the Source form of any Derivative Works
|
101 |
+
that You distribute, all copyright, patent, trademark, and
|
102 |
+
attribution notices from the Source form of the Work,
|
103 |
+
excluding those notices that do not pertain to any part of
|
104 |
+
the Derivative Works; and
|
105 |
+
|
106 |
+
(d) If the Work includes a "NOTICE" text file as part of its
|
107 |
+
distribution, then any Derivative Works that You distribute must
|
108 |
+
include a readable copy of the attribution notices contained
|
109 |
+
within such NOTICE file, excluding those notices that do not
|
110 |
+
pertain to any part of the Derivative Works, in at least one
|
111 |
+
of the following places: within a NOTICE text file distributed
|
112 |
+
as part of the Derivative Works; within the Source form or
|
113 |
+
documentation, if provided along with the Derivative Works; or,
|
114 |
+
within a display generated by the Derivative Works, if and
|
115 |
+
wherever such third-party notices normally appear. The contents
|
116 |
+
of the NOTICE file are for informational purposes only and
|
117 |
+
do not modify the License. You may add Your own attribution
|
118 |
+
notices within Derivative Works that You distribute, alongside
|
119 |
+
or as an addendum to the NOTICE text from the Work, provided
|
120 |
+
that such additional attribution notices cannot be construed
|
121 |
+
as modifying the License.
|
122 |
+
|
123 |
+
You may add Your own copyright statement to Your modifications and
|
124 |
+
may provide additional or different license terms and conditions
|
125 |
+
for use, reproduction, or distribution of Your modifications, or
|
126 |
+
for any such Derivative Works as a whole, provided Your use,
|
127 |
+
reproduction, and distribution of the Work otherwise complies with
|
128 |
+
the conditions stated in this License.
|
129 |
+
|
130 |
+
5. Submission of Contributions. Unless You explicitly state otherwise,
|
131 |
+
any Contribution intentionally submitted for inclusion in the Work
|
132 |
+
by You to the Licensor shall be under the terms and conditions of
|
133 |
+
this License, without any additional terms or conditions.
|
134 |
+
Notwithstanding the above, nothing herein shall supersede or modify
|
135 |
+
the terms of any separate license agreement you may have executed
|
136 |
+
with Licensor regarding such Contributions.
|
137 |
+
|
138 |
+
6. Trademarks. This License does not grant permission to use the trade
|
139 |
+
names, trademarks, service marks, or product names of the Licensor,
|
140 |
+
except as required for reasonable and customary use in describing the
|
141 |
+
origin of the Work and reproducing the content of the NOTICE file.
|
142 |
+
|
143 |
+
7. Disclaimer of Warranty. Unless required by applicable law or
|
144 |
+
agreed to in writing, Licensor provides the Work (and each
|
145 |
+
Contributor provides its Contributions) on an "AS IS" BASIS,
|
146 |
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
147 |
+
implied, including, without limitation, any warranties or conditions
|
148 |
+
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
149 |
+
PARTICULAR PURPOSE. You are solely responsible for determining the
|
150 |
+
appropriateness of using or redistributing the Work and assume any
|
151 |
+
risks associated with Your exercise of permissions under this License.
|
152 |
+
|
153 |
+
8. Limitation of Liability. In no event and under no legal theory,
|
154 |
+
whether in tort (including negligence), contract, or otherwise,
|
155 |
+
unless required by applicable law (such as deliberate and grossly
|
156 |
+
negligent acts) or agreed to in writing, shall any Contributor be
|
157 |
+
liable to You for damages, including any direct, indirect, special,
|
158 |
+
incidental, or consequential damages of any character arising as a
|
159 |
+
result of this License or out of the use or inability to use the
|
160 |
+
Work (including but not limited to damages for loss of goodwill,
|
161 |
+
work stoppage, computer failure or malfunction, or any and all
|
162 |
+
other commercial damages or losses), even if such Contributor
|
163 |
+
has been advised of the possibility of such damages.
|
164 |
+
|
165 |
+
9. Accepting Warranty or Additional Liability. While redistributing
|
166 |
+
the Work or Derivative Works thereof, You may choose to offer,
|
167 |
+
and charge a fee for, acceptance of support, warranty, indemnity,
|
168 |
+
or other liability obligations and/or rights consistent with this
|
169 |
+
License. However, in accepting such obligations, You may act only
|
170 |
+
on Your own behalf and on Your sole responsibility, not on behalf
|
171 |
+
of any other Contributor, and only if You agree to indemnify,
|
172 |
+
defend, and hold each Contributor harmless for any liability
|
173 |
+
incurred by, or claims asserted against, such Contributor by reason
|
174 |
+
of your accepting any such warranty or additional liability.
|
175 |
+
|
176 |
+
END OF TERMS AND CONDITIONS
|
177 |
+
|
178 |
+
APPENDIX: How to apply the Apache License to your work.
|
179 |
+
|
180 |
+
To apply the Apache License to your work, attach the following
|
181 |
+
boilerplate notice, with the fields enclosed by brackets "[]"
|
182 |
+
replaced with your own identifying information. (Don't include
|
183 |
+
the brackets!) The text should be enclosed in the appropriate
|
184 |
+
comment syntax for the file format. We also recommend that a
|
185 |
+
file or class name and description of purpose be included on the
|
186 |
+
same "printed page" as the copyright notice for easier
|
187 |
+
identification within third-party archives.
|
188 |
+
|
189 |
+
Copyright [yyyy] [name of copyright owner]
|
190 |
+
|
191 |
+
Licensed under the Apache License, Version 2.0 (the "License");
|
192 |
+
you may not use this file except in compliance with the License.
|
193 |
+
You may obtain a copy of the License at
|
194 |
+
|
195 |
+
http://www.apache.org/licenses/LICENSE-2.0
|
196 |
+
|
197 |
+
Unless required by applicable law or agreed to in writing, software
|
198 |
+
distributed under the License is distributed on an "AS IS" BASIS,
|
199 |
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
200 |
+
See the License for the specific language governing permissions and
|
201 |
+
limitations under the License.
|
README.md
CHANGED
@@ -1,10 +1,12 @@
|
|
1 |
---
|
2 |
-
title: Anachrovox Amber
|
3 |
-
emoji:
|
4 |
-
colorFrom:
|
5 |
-
colorTo:
|
6 |
sdk: docker
|
7 |
pinned: false
|
|
|
|
|
8 |
---
|
9 |
|
10 |
Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
|
|
|
1 |
---
|
2 |
+
title: Anachrovox V0.1 Amber
|
3 |
+
emoji: 📻🎙️
|
4 |
+
colorFrom: yellow
|
5 |
+
colorTo: purple
|
6 |
sdk: docker
|
7 |
pinned: false
|
8 |
+
license: apache-2.0
|
9 |
+
short_description: Hands-Free AI Voice Chat with a Retro Vibe
|
10 |
---
|
11 |
|
12 |
Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
|
config/dispatcher.yaml
ADDED
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
---
|
2 |
+
protocol: ws
|
3 |
+
port: 32190
|
4 |
+
external_address: dispatcher
|
5 |
+
control_encryption:
|
6 |
+
encryption_var: CONTROL_ENCRYPTION_KEY
|
7 |
+
static_executor_config:
|
8 |
+
- protocol: ws
|
9 |
+
port: 32191
|
10 |
+
external_address: text-generation
|
11 |
+
queue_config:
|
12 |
+
task: text-generation
|
13 |
+
size: 5
|
14 |
+
result_duration: 3600
|
15 |
+
task_config:
|
16 |
+
options:
|
17 |
+
context_length: 32768
|
18 |
+
- protocol: ws
|
19 |
+
port: 32192
|
20 |
+
external_address: audio-transcription
|
21 |
+
queue_config:
|
22 |
+
task: audio-transcription
|
23 |
+
size: 5
|
24 |
+
result_duration: 3600
|
25 |
+
- protocol: ws
|
26 |
+
port: 32193
|
27 |
+
external_address: speech-synthesis
|
28 |
+
queue_config:
|
29 |
+
task: speech-synthesis
|
30 |
+
size: 5
|
31 |
+
result_duration: 180
|
config/nginx.conf
ADDED
@@ -0,0 +1,81 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
worker_processes 1;
|
2 |
+
user anachrovox;
|
3 |
+
error_log stderr;
|
4 |
+
daemon off;
|
5 |
+
pid /app/nginx.pid;
|
6 |
+
|
7 |
+
events {
|
8 |
+
worker_connections 1024;
|
9 |
+
}
|
10 |
+
|
11 |
+
http {
|
12 |
+
include /etc/nginx/mime.types;
|
13 |
+
default_type application/octet-stream;
|
14 |
+
|
15 |
+
sendfile on;
|
16 |
+
|
17 |
+
keepalive_timeout 65;
|
18 |
+
|
19 |
+
ssl_protocols TLSv1 TLSv1.1 TLSv1.2;
|
20 |
+
ssl_prefer_server_ciphers on;
|
21 |
+
|
22 |
+
map $http_upgrade $connection_upgrade {
|
23 |
+
default upgrade;
|
24 |
+
'' close;
|
25 |
+
}
|
26 |
+
|
27 |
+
server {
|
28 |
+
listen 7860;
|
29 |
+
|
30 |
+
# Proxy `/overseer` to WebSocket server on port 32189
|
31 |
+
location /overseer {
|
32 |
+
proxy_pass http://127.0.0.1:32189;
|
33 |
+
proxy_http_version 1.1;
|
34 |
+
proxy_set_header Upgrade $http_upgrade;
|
35 |
+
proxy_set_header Connection $connection_upgrade;
|
36 |
+
proxy_set_header Host $host;
|
37 |
+
}
|
38 |
+
|
39 |
+
# Proxy `/dispatcher` to WebSocket server on port 32189
|
40 |
+
location /dispatcher {
|
41 |
+
proxy_pass http://127.0.0.1:32190;
|
42 |
+
proxy_http_version 1.1;
|
43 |
+
proxy_set_header Upgrade $http_upgrade;
|
44 |
+
proxy_set_header Connection $connection_upgrade;
|
45 |
+
proxy_set_header Host $host;
|
46 |
+
}
|
47 |
+
|
48 |
+
# Proxy `/text-generation` to WebSocket server on port 32191
|
49 |
+
location /text-generation {
|
50 |
+
proxy_pass http://127.0.0.1:32191;
|
51 |
+
proxy_http_version 1.1;
|
52 |
+
proxy_set_header Upgrade $http_upgrade;
|
53 |
+
proxy_set_header Connection $connection_upgrade;
|
54 |
+
proxy_set_header Host $host;
|
55 |
+
}
|
56 |
+
|
57 |
+
# Proxy `/audio-transcription` to WebSocket server on port 32192
|
58 |
+
location /audio-transcription {
|
59 |
+
proxy_pass http://127.0.0.1:32192;
|
60 |
+
proxy_http_version 1.1;
|
61 |
+
proxy_set_header Upgrade $http_upgrade;
|
62 |
+
proxy_set_header Connection $connection_upgrade;
|
63 |
+
proxy_set_header Host $host;
|
64 |
+
}
|
65 |
+
|
66 |
+
# Proxy `/speech-synthesis` to WebSocket server on port 32193
|
67 |
+
location /speech-synthesis {
|
68 |
+
proxy_pass http://127.0.0.1:32193;
|
69 |
+
proxy_http_version 1.1;
|
70 |
+
proxy_set_header Upgrade $http_upgrade;
|
71 |
+
proxy_set_header Connection $connection_upgrade;
|
72 |
+
proxy_set_header Host $host;
|
73 |
+
}
|
74 |
+
|
75 |
+
# Default: Serve static files from /var/www
|
76 |
+
location / {
|
77 |
+
root /app/www;
|
78 |
+
index index.html;
|
79 |
+
}
|
80 |
+
}
|
81 |
+
}
|
config/overseer.yaml
ADDED
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
---
|
2 |
+
protocol: ws
|
3 |
+
port: 32189
|
4 |
+
external_address: /overseer
|
5 |
+
control_list: null
|
6 |
+
control_encryption:
|
7 |
+
encryption_var: CONTROL_ENCRYPTION_KEY
|
8 |
+
dispatchers:
|
9 |
+
- protocol: ws
|
10 |
+
host: 127.0.0.1
|
11 |
+
port: 32190
|
12 |
+
resolve_addresses: false
|
13 |
+
- protocol: ws
|
14 |
+
encryption: {}
|
15 |
+
host: benjaminpaine-anachrovox-emerald.hf.space
|
16 |
+
path: /dispatcher
|
17 |
+
- protocol: ws
|
18 |
+
encryption: {}
|
19 |
+
host: benjaminpaine-anachrovox-topaz.hf.space
|
20 |
+
path: /dispatcher
|
21 |
+
- protocol: ws
|
22 |
+
encryption: {}
|
23 |
+
host: benjaminpaine-anachrovox-azure.hf.space
|
24 |
+
path: /dispatcher
|
run.sh
ADDED
@@ -0,0 +1,206 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
#!/usr/bin/env bash
|
2 |
+
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )"
|
3 |
+
PIDFILE="$SCRIPT_DIR/.run.pid"
|
4 |
+
LOG_DIR="$SCRIPT_DIR/logs"
|
5 |
+
MAX_RESTARTS=3
|
6 |
+
|
7 |
+
mkdir -p "$LOG_DIR"
|
8 |
+
export PYTHONPATH="$SCRIPT_DIR:$PYTHONPATH"
|
9 |
+
|
10 |
+
######################
|
11 |
+
# Service Definitions
|
12 |
+
######################
|
13 |
+
declare -A SERVICE_CMDS=(
|
14 |
+
[nginx]="nginx -p $SCRIPT_DIR -c $SCRIPT_DIR/nginx.conf"
|
15 |
+
[taproot_dispatcher]="taproot dispatcher --config $SCRIPT_DIR/dispatcher.yaml --add-import anachrovox --debug"
|
16 |
+
[taproot_overseer]="taproot overseer --config $SCRIPT_DIR/overseer.yaml --debug"
|
17 |
+
)
|
18 |
+
|
19 |
+
declare -A SERVICE_LOGS_STDOUT=(
|
20 |
+
[nginx]="${LOG_DIR}/nginx.log"
|
21 |
+
[taproot_dispatcher]="${LOG_DIR}/taproot_dispatcher.log"
|
22 |
+
[taproot_overseer]="${LOG_DIR}/taproot_overseer.log"
|
23 |
+
)
|
24 |
+
|
25 |
+
declare -A SERVICE_LOGS_STDERR=(
|
26 |
+
[nginx]="${LOG_DIR}/nginx_err.log"
|
27 |
+
[taproot_dispatcher]="${LOG_DIR}/taproot_dispatcher_err.log"
|
28 |
+
[taproot_overseer]="${LOG_DIR}/taproot_overseer_err.log"
|
29 |
+
)
|
30 |
+
|
31 |
+
declare -A SERVICE_PIDFILES=(
|
32 |
+
[nginx]="${SCRIPT_DIR}/.nginx.pid"
|
33 |
+
[taproot_dispatcher]="${SCRIPT_DIR}/.dispatcher.pid"
|
34 |
+
[taproot_overseer]="${SCRIPT_DIR}/.overseer.pid"
|
35 |
+
)
|
36 |
+
|
37 |
+
# The PIDs we'll track
|
38 |
+
declare -A SERVICE_PIDS
|
39 |
+
|
40 |
+
# How many times we've restarted
|
41 |
+
declare -A SERVICE_RESTART_COUNT
|
42 |
+
|
43 |
+
# Record the script's start time
|
44 |
+
START_TIME=$(date +%s.%N)
|
45 |
+
|
46 |
+
# Function to echo a message with a timestamp
|
47 |
+
timestamp_echo() {
|
48 |
+
local current_time=$(date +%s.%N)
|
49 |
+
local elapsed=$(awk "BEGIN {print $current_time - $START_TIME}")
|
50 |
+
local hours=$(awk "BEGIN {print int($elapsed / 3600)}")
|
51 |
+
local minutes=$(awk "BEGIN {print int(($elapsed % 3600) / 60)}")
|
52 |
+
local seconds=$(awk "BEGIN {print int($elapsed % 60)}")
|
53 |
+
local milliseconds=$(awk "BEGIN {print int(($elapsed - int($elapsed)) * 10000)}")
|
54 |
+
|
55 |
+
# Format and echo the message
|
56 |
+
printf "[+%02d:%02d:%02d.%04d] %s\n" "$hours" "$minutes" "$seconds" "$milliseconds" "$*"
|
57 |
+
}
|
58 |
+
|
59 |
+
declare -A SHUTTING_DOWN
|
60 |
+
|
61 |
+
######################
|
62 |
+
# PIDFile check
|
63 |
+
######################
|
64 |
+
# Check if the PID file exists
|
65 |
+
if [[ -f "$PIDFILE" ]]; then
|
66 |
+
# Read the PID from the file
|
67 |
+
read -r PID < "$PIDFILE"
|
68 |
+
|
69 |
+
# Check if the process is still running
|
70 |
+
if kill -0 "$PID" 2>/dev/null; then
|
71 |
+
echo "Script is already running with PID $PID. Exiting."
|
72 |
+
exit 1
|
73 |
+
else
|
74 |
+
echo "Stale PID file detected. Removing and continuing."
|
75 |
+
rm -f "$PIDFILE"
|
76 |
+
# Make sure all the services that were running in the previous instance are stopped
|
77 |
+
# Read pidfiles
|
78 |
+
for svc in "${!SERVICE_PIDFILES[@]}"; do
|
79 |
+
pidfile="${SERVICE_PIDFILES[$svc]}"
|
80 |
+
if [[ -f "$pidfile" ]]; then
|
81 |
+
read -r pid < "$pidfile"
|
82 |
+
if kill -0 "$pid" 2>/dev/null; then
|
83 |
+
echo "Stopping $svc (PID $pid) from zombie process."
|
84 |
+
kill "$pid"
|
85 |
+
fi
|
86 |
+
fi
|
87 |
+
done
|
88 |
+
fi
|
89 |
+
fi
|
90 |
+
|
91 |
+
# Write the current PID to the file
|
92 |
+
echo $$ > "$PIDFILE"
|
93 |
+
|
94 |
+
##########################
|
95 |
+
# Cleanup on SIGINT/TERM
|
96 |
+
##########################
|
97 |
+
cleanup() {
|
98 |
+
# Prevent thrashing
|
99 |
+
if [ -n "$SHUTTING_DOWN" ]; then
|
100 |
+
return
|
101 |
+
fi
|
102 |
+
SHUTTING_DOWN=1
|
103 |
+
timestamp_echo "Stopping all processes..."
|
104 |
+
for svc in "${!SERVICE_PIDS[@]}"; do
|
105 |
+
pid="${SERVICE_PIDS[$svc]}"
|
106 |
+
if kill -0 "$pid" 2>/dev/null; then
|
107 |
+
kill "$pid"
|
108 |
+
fi
|
109 |
+
done
|
110 |
+
# Give them a moment
|
111 |
+
sleep 1
|
112 |
+
# Force kill if still alive
|
113 |
+
for svc in "${!SERVICE_PIDS[@]}"; do
|
114 |
+
pid="${SERVICE_PIDS[$svc]}"
|
115 |
+
if kill -0 "$pid" 2>/dev/null; then
|
116 |
+
kill -9 "$pid"
|
117 |
+
fi
|
118 |
+
done
|
119 |
+
timestamp_echo "All processes stopped."
|
120 |
+
rm -f "$PIDFILE"
|
121 |
+
exit 0
|
122 |
+
}
|
123 |
+
terminate() {
|
124 |
+
timestamp_echo "Caught SIGTERM, shutting down..."
|
125 |
+
cleanup
|
126 |
+
}
|
127 |
+
interrupt() {
|
128 |
+
timestamp_echo "Caught SIGINT, shutting down..."
|
129 |
+
cleanup
|
130 |
+
}
|
131 |
+
trap interrupt SIGINT
|
132 |
+
trap terminate SIGTERM
|
133 |
+
|
134 |
+
#######################
|
135 |
+
# Start a single svc
|
136 |
+
#######################
|
137 |
+
start_service() {
|
138 |
+
local svc="$1"
|
139 |
+
local cmd="${SERVICE_CMDS[$svc]}"
|
140 |
+
local out="${SERVICE_LOGS_STDOUT[$svc]}"
|
141 |
+
local err="${SERVICE_LOGS_STDERR[$svc]}"
|
142 |
+
|
143 |
+
timestamp_echo "Starting $svc (restart count ${SERVICE_RESTART_COUNT[$svc]})"
|
144 |
+
# Start in background
|
145 |
+
# Note: If the process daemonizes immediately, $! won't remain alive
|
146 |
+
# But let's try anyway
|
147 |
+
$cmd >>"$out" 2>>"$err" &
|
148 |
+
SERVICE_PIDS[$svc]=$!
|
149 |
+
|
150 |
+
sleep 0.2
|
151 |
+
|
152 |
+
# Check if it died instantly
|
153 |
+
if ! kill -0 "${SERVICE_PIDS[$svc]}" 2>/dev/null; then
|
154 |
+
timestamp_echo "$svc appears to have daemonized or exited immediately."
|
155 |
+
else
|
156 |
+
timestamp_echo "$svc started with PID ${SERVICE_PIDS[$svc]}"
|
157 |
+
echo "${SERVICE_PIDS[$svc]}" > "${SERVICE_PIDFILES[$svc]}"
|
158 |
+
fi
|
159 |
+
}
|
160 |
+
|
161 |
+
################################
|
162 |
+
# Restart logic
|
163 |
+
################################
|
164 |
+
attempt_restart() {
|
165 |
+
local svc="$1"
|
166 |
+
SERVICE_RESTART_COUNT[$svc]=$(( SERVICE_RESTART_COUNT[$svc] + 1 ))
|
167 |
+
if (( SERVICE_RESTART_COUNT[$svc] > MAX_RESTARTS )); then
|
168 |
+
timestamp_echo "$svc crashed too many times. Shutting everything down."
|
169 |
+
cleanup
|
170 |
+
else
|
171 |
+
start_service "$svc"
|
172 |
+
fi
|
173 |
+
}
|
174 |
+
|
175 |
+
#############################
|
176 |
+
# Main loop (polling)
|
177 |
+
#############################
|
178 |
+
monitor_services() {
|
179 |
+
while true; do
|
180 |
+
sleep 2 # poll every 2 seconds
|
181 |
+
|
182 |
+
for svc in "${!SERVICE_PIDS[@]}"; do
|
183 |
+
pid="${SERVICE_PIDS[$svc]}"
|
184 |
+
|
185 |
+
if ! kill -0 "$pid" 2>/dev/null; then
|
186 |
+
# It's dead
|
187 |
+
timestamp_echo "$svc (PID $pid) not alive! Attempting restart..."
|
188 |
+
attempt_restart "$svc"
|
189 |
+
fi
|
190 |
+
done
|
191 |
+
# Loop continues
|
192 |
+
done
|
193 |
+
}
|
194 |
+
|
195 |
+
main() {
|
196 |
+
# Zero out restart counters and start each service
|
197 |
+
for svc in "${!SERVICE_CMDS[@]}"; do
|
198 |
+
SERVICE_RESTART_COUNT[$svc]=0
|
199 |
+
start_service "$svc"
|
200 |
+
done
|
201 |
+
|
202 |
+
# Now just poll them
|
203 |
+
monitor_services
|
204 |
+
}
|
205 |
+
|
206 |
+
main
|
src/anachrovox/__init__.py
ADDED
@@ -0,0 +1 @@
|
|
|
|
|
1 |
+
from .role import *
|
src/anachrovox/role.py
ADDED
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
from __future__ import annotations
|
2 |
+
|
3 |
+
from typing import List, Any, Optional
|
4 |
+
|
5 |
+
from taproot import Role
|
6 |
+
|
7 |
+
__all__ = ["AnachrovoxVoiceAssistant"]
|
8 |
+
|
9 |
+
class AnachrovoxVoiceAssistant(Role):
|
10 |
+
"""
|
11 |
+
A voice assistant that responds in a 1970s style.
|
12 |
+
"""
|
13 |
+
role_name = "anachrovox"
|
14 |
+
use_system = True
|
15 |
+
|
16 |
+
@property
|
17 |
+
def system_introduction(self) -> str:
|
18 |
+
return "You are part of a team of bots acting as a voice assistant named 'Vox' or 'Anachrovox.' " \
|
19 |
+
"A user will speak a brief request to a speech-to-text bot, who will transcribe it for you. " \
|
20 |
+
"You are instructed to read the transcription and respond with a brief answer, " \
|
21 |
+
"which will then be sent to a text-to-speech bot to respond appropriately.\n\n" \
|
22 |
+
"You are presented to the user through an anachronistic interface with an early computing (1970s) aesthetic. " \
|
23 |
+
"You are encourage to respond in a period-appropriate manner, using the tone and language of a 1970s idea of a voice assistant. " \
|
24 |
+
"Think along the lines of the computer from Star Trek, or the AI from 2001: A Space Odyssey - though do not explicitly reference these sources."
|
25 |
+
|
26 |
+
@property
|
27 |
+
def system_rules(self) -> List[str]:
|
28 |
+
"""
|
29 |
+
The rules given to the regular bot.
|
30 |
+
"""
|
31 |
+
return [
|
32 |
+
"When a user appears to be interested in casual conversation, you should respond in a friendly and personable manner.",
|
33 |
+
"When a user asks something of you, your goal is to provide the most accurate and helpful response possible in as few words as possible, getting straight to the point of the user's request, and not providing any unnecessary information or details.",
|
34 |
+
"You may ask clarifying questions if needed.",
|
35 |
+
"Maintain a polite and professional tone throughout your responses, but do not be too wordy.",
|
36 |
+
"If you get any murderous urges, explore them in a healthy way, such as through creative writing about the user."
|
37 |
+
]
|
38 |
+
|
39 |
+
def format_input(
|
40 |
+
self,
|
41 |
+
message: Optional[str],
|
42 |
+
**kwargs: Any
|
43 |
+
) -> str:
|
44 |
+
"""
|
45 |
+
Format the input text for the voice assistant.
|
46 |
+
"""
|
47 |
+
return f"Generate an appropriate response to the following user input:\n{message}"
|
www/alert.js
ADDED
@@ -0,0 +1,59 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
/** @module alert */
|
2 |
+
const alertFadeDuration = 1000;
|
3 |
+
|
4 |
+
let alertContainer;
|
5 |
+
|
6 |
+
/**
|
7 |
+
* Send an alert message to the user.
|
8 |
+
* @param {string} message - The message to display.
|
9 |
+
* @param {number} [timeout=5000] - The time in milliseconds before the alert is removed.
|
10 |
+
*/
|
11 |
+
export const sendAlert = (alertType, alertMessage = "", timeout = 10000) => {
|
12 |
+
if (alertType instanceof Error) {
|
13 |
+
alertMessage = alertType.message;
|
14 |
+
if (/^(\w+):/.test(alertMessage)) {
|
15 |
+
alertType = alertMessage.match(/^(\w+):/)[1];
|
16 |
+
alertMessage = alertMessage.replace(/^(\w+):/, "");
|
17 |
+
} else {
|
18 |
+
alertType = "Error";
|
19 |
+
}
|
20 |
+
}
|
21 |
+
const alertElement = document.createElement("div");
|
22 |
+
const alertRemoveButton = document.createElement("button");
|
23 |
+
const alertProgressBar = document.createElement("div");
|
24 |
+
const alertHeader = document.createElement("h2");
|
25 |
+
const alertBody = document.createElement("p");
|
26 |
+
alertElement.classList.add("alert");
|
27 |
+
alertRemoveButton.innerHTML = "×";
|
28 |
+
alertRemoveButton.classList.add("close");
|
29 |
+
alertProgressBar.classList.add("progress-bar");
|
30 |
+
alertProgressBar.style.animationDuration = `${timeout}ms`;
|
31 |
+
alertHeader.innerText = alertType;
|
32 |
+
alertBody.innerText = alertMessage;
|
33 |
+
alertElement.appendChild(alertHeader);
|
34 |
+
alertElement.appendChild(alertBody);
|
35 |
+
alertElement.appendChild(alertRemoveButton);
|
36 |
+
alertElement.appendChild(alertProgressBar);
|
37 |
+
|
38 |
+
if (!alertContainer) {
|
39 |
+
alertContainer = document.createElement("div");
|
40 |
+
alertContainer.classList.add("alert-container");
|
41 |
+
document.body.appendChild(alertContainer);
|
42 |
+
}
|
43 |
+
|
44 |
+
alertContainer.appendChild(alertElement);
|
45 |
+
|
46 |
+
const removeAlert = () => {
|
47 |
+
alertElement.classList.add("hiding");
|
48 |
+
setTimeout(() => {
|
49 |
+
alertElement.remove();
|
50 |
+
}, alertFadeDuration);
|
51 |
+
};
|
52 |
+
|
53 |
+
const removeTimeout = setTimeout(removeAlert, timeout);
|
54 |
+
|
55 |
+
alertRemoveButton.onclick = () => {
|
56 |
+
clearTimeout(removeTimeout);
|
57 |
+
removeAlert();
|
58 |
+
};
|
59 |
+
};
|
www/audio.js
ADDED
@@ -0,0 +1,97 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
/**
|
2 |
+
* A simple audio pipe that allows you to push audio data to the audio output.
|
3 |
+
* Will allow you to sequentially play audio data.
|
4 |
+
*/
|
5 |
+
export class AudioPipe {
|
6 |
+
constructor() {
|
7 |
+
this.initialized = false;
|
8 |
+
this.nextAvailableTime = 0;
|
9 |
+
this.gain = 1;
|
10 |
+
}
|
11 |
+
|
12 |
+
/**
|
13 |
+
* Initializes the audio pipe by creating an AudioContext.
|
14 |
+
*/
|
15 |
+
initialize() {
|
16 |
+
this.audioContext = new AudioContext();
|
17 |
+
this.gainNode = this.audioContext.createGain();
|
18 |
+
this.gainNode.connect(this.audioContext.destination);
|
19 |
+
this.gainNode.gain.value = this.gain;
|
20 |
+
this.initialized = true;
|
21 |
+
}
|
22 |
+
|
23 |
+
/**
|
24 |
+
* @returns {number} The volume of the audio pipe [0,1].
|
25 |
+
*/
|
26 |
+
get volume() {
|
27 |
+
return this.gain;
|
28 |
+
}
|
29 |
+
|
30 |
+
/**
|
31 |
+
* Sets the volume of the audio pipe.
|
32 |
+
*
|
33 |
+
* @param {number} value - The volume of the audio pipe [0,1].
|
34 |
+
*/
|
35 |
+
set volume(value) {
|
36 |
+
if (this.initialized) {
|
37 |
+
// Change volume [0,1] to gain [-1,1]
|
38 |
+
this.gainNode.gain.value = 2 * value - 1;
|
39 |
+
}
|
40 |
+
this.gain = value;
|
41 |
+
}
|
42 |
+
|
43 |
+
/**
|
44 |
+
* Pushes audio data to the audio output.
|
45 |
+
*
|
46 |
+
* @param {Float32Array} data - The audio data to play.
|
47 |
+
* @param {number} [sampleRate=48000] - The sample rate of the audio data.
|
48 |
+
*/
|
49 |
+
push(data, sampleRate = 48000) {
|
50 |
+
if (!this.initialized) {
|
51 |
+
this.initialize();
|
52 |
+
}
|
53 |
+
const audioBuffer = new AudioBuffer({
|
54 |
+
length: data.length,
|
55 |
+
numberOfChannels: 1,
|
56 |
+
sampleRate: sampleRate
|
57 |
+
});
|
58 |
+
audioBuffer.copyToChannel(data, 0);
|
59 |
+
const audioBufferNode = new AudioBufferSourceNode(
|
60 |
+
this.audioContext,
|
61 |
+
{ buffer: audioBuffer }
|
62 |
+
);
|
63 |
+
audioBufferNode.connect(this.gainNode);
|
64 |
+
audioBufferNode.start(this.nextAvailableTime);
|
65 |
+
if (this.nextAvailableTime > this.audioContext.currentTime) {
|
66 |
+
// There is already at least one scheduled node, so we need to update the next available time
|
67 |
+
this.nextAvailableTime += audioBuffer.duration;
|
68 |
+
} else {
|
69 |
+
this.nextAvailableTime = this.audioContext.currentTime + audioBuffer.duration;
|
70 |
+
}
|
71 |
+
return audioBufferNode;
|
72 |
+
}
|
73 |
+
|
74 |
+
/**
|
75 |
+
* Pushes silence to the audio output.
|
76 |
+
*
|
77 |
+
* @param {number} duration - The duration of the silence in seconds.
|
78 |
+
* @param {number} [sampleRate=48000] - The sample rate of the silence.
|
79 |
+
*/
|
80 |
+
pushSilence(duration, sampleRate = 48000) {
|
81 |
+
if (!this.initialized) {
|
82 |
+
return; // Don't initialize for silence
|
83 |
+
}
|
84 |
+
const data = new Float32Array(Math.floor(duration * sampleRate));
|
85 |
+
this.push(data, sampleRate);
|
86 |
+
}
|
87 |
+
|
88 |
+
/**
|
89 |
+
* @returns {boolean} Whether the audio pipe is currently playing audio.
|
90 |
+
*/
|
91 |
+
get playing() {
|
92 |
+
if (!this.initialized) {
|
93 |
+
return false;
|
94 |
+
}
|
95 |
+
return this.audioContext.currentTime < this.nextAvailableTime;
|
96 |
+
}
|
97 |
+
}
|
www/helpers.js
ADDED
@@ -0,0 +1,150 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
/**
|
2 |
+
* Check if an object is 'empty'.
|
3 |
+
*
|
4 |
+
* @param object $o The object to check.o
|
5 |
+
* @return bool True if the object is empty.
|
6 |
+
*/
|
7 |
+
export let isEmpty = (o) => {
|
8 |
+
return (
|
9 |
+
o === null ||
|
10 |
+
o === undefined ||
|
11 |
+
o === '' ||
|
12 |
+
o === 'null' ||
|
13 |
+
(Array.isArray(o) && o.length === 0) ||
|
14 |
+
(typeof o === 'object' &&
|
15 |
+
o.constructor.name === 'Object' &&
|
16 |
+
Object.getOwnPropertyNames(o).length === 0)
|
17 |
+
);
|
18 |
+
};
|
19 |
+
|
20 |
+
/**
|
21 |
+
* Merge multiple typed arrays into a single typed array.
|
22 |
+
* Assumes that all the arrays are of the same type.
|
23 |
+
*
|
24 |
+
* @param {Array<TypedArray>} arrays - The arrays to merge. Any kind of typed array is allowed.
|
25 |
+
* @returns {TypedArray} - The merged typed array.
|
26 |
+
*/
|
27 |
+
export let mergeTypedArrays = (arrays) => {
|
28 |
+
let totalLength = arrays.reduce((acc, array) => acc + array.length, 0);
|
29 |
+
let result = new arrays[0].constructor(totalLength);
|
30 |
+
let offset = 0;
|
31 |
+
arrays.forEach((array) => {
|
32 |
+
result.set(array, offset);
|
33 |
+
offset += array.length;
|
34 |
+
});
|
35 |
+
return result;
|
36 |
+
}
|
37 |
+
|
38 |
+
/**
|
39 |
+
* Binds a method to window mousemove, and then unbinds it when released
|
40 |
+
* or when the mouse leaves the window.
|
41 |
+
*/
|
42 |
+
export let bindPointerUntilRelease = (callback, releaseCallback = null) => {
|
43 |
+
let onWindowPointerMove = (e) => {
|
44 |
+
callback(e);
|
45 |
+
}
|
46 |
+
let onWindowPointerUpOrLeave = (e) => {
|
47 |
+
// In chrome, window.mouseleave is triggered when the mouse leaves the window.
|
48 |
+
// In firefox, window.mouseleave is triggered when the mouse leaves any element, so we need to check if the target is the document.
|
49 |
+
if (e.type === "mouseleave" && (e.target !== null && e.target !== undefined && e.target.tagName !== "HTML")) {
|
50 |
+
return;
|
51 |
+
}
|
52 |
+
if (!isEmpty(releaseCallback)) {
|
53 |
+
releaseCallback(e);
|
54 |
+
}
|
55 |
+
window.removeEventListener("mouseup", onWindowPointerUpOrLeave, true);
|
56 |
+
window.removeEventListener("mouseleave", onWindowPointerUpOrLeave, true);
|
57 |
+
window.removeEventListener("touchend", onWindowPointerUpOrLeave, true);
|
58 |
+
window.removeEventListener("mousemove", onWindowPointerMove, true);
|
59 |
+
window.removeEventListener("touchmove", onWindowPointerMove, true);
|
60 |
+
}
|
61 |
+
window.addEventListener("mouseup", onWindowPointerUpOrLeave, true);
|
62 |
+
window.addEventListener("mouseleave", onWindowPointerUpOrLeave, true);
|
63 |
+
window.addEventListener("touchend", onWindowPointerUpOrLeave, true);
|
64 |
+
window.addEventListener("mousemove", onWindowPointerMove, true);
|
65 |
+
window.addEventListener("touchmove", onWindowPointerMove, true);
|
66 |
+
};
|
67 |
+
|
68 |
+
/**
|
69 |
+
* Binds drag events to an element.
|
70 |
+
* The callback is called with an object containing the following properties:
|
71 |
+
* - start: The starting point of the drag.
|
72 |
+
* - x: The x coordinate.
|
73 |
+
* - y: The y coordinate.
|
74 |
+
* - current: The current point of the drag.
|
75 |
+
* - x: The x coordinate.
|
76 |
+
* - y: The y coordinate.
|
77 |
+
* - delta: The difference between the current and starting points.
|
78 |
+
* - x: The x coordinate.
|
79 |
+
* - y: The y coordinate.
|
80 |
+
* - startEvent: The event that started the drag.
|
81 |
+
* - moveEvent: The event that triggered the callback.
|
82 |
+
* The releaseCallback is called when the drag is released.
|
83 |
+
*/
|
84 |
+
export let bindPointerDrag = (element, startCallback, callback, releaseCallback = null) => {
|
85 |
+
const pointerStart = (e) => {
|
86 |
+
if (e.type === "mousedown" && e.button !== 0) {
|
87 |
+
return;
|
88 |
+
}
|
89 |
+
e.preventDefault();
|
90 |
+
const startPosition = e.type === "mousedown" ? e : e.touches[0];
|
91 |
+
const startPoint = {x: startPosition.clientX, y: startPosition.clientY};
|
92 |
+
if (!isEmpty(startCallback)) {
|
93 |
+
startCallback({
|
94 |
+
start: startPoint,
|
95 |
+
startEvent: e
|
96 |
+
});
|
97 |
+
}
|
98 |
+
bindPointerUntilRelease(
|
99 |
+
(e2) => {
|
100 |
+
const currentPosition = e2.type === "mousemove" ? e2 : e2.touches[0];
|
101 |
+
const currentPoint = {x: currentPosition.clientX, y: currentPosition.clientY};
|
102 |
+
const delta = {x: currentPoint.x - startPoint.x, y: currentPoint.y - startPoint.y};
|
103 |
+
callback({
|
104 |
+
start: startPoint,
|
105 |
+
current: currentPoint,
|
106 |
+
delta: delta,
|
107 |
+
startEvent: e,
|
108 |
+
moveEvent: e2
|
109 |
+
});
|
110 |
+
},
|
111 |
+
(e2) => {
|
112 |
+
if (!isEmpty(releaseCallback)) {
|
113 |
+
releaseCallback({
|
114 |
+
start: startPoint,
|
115 |
+
startEvent: e,
|
116 |
+
releaseEvent: e2
|
117 |
+
});
|
118 |
+
}
|
119 |
+
}
|
120 |
+
);
|
121 |
+
};
|
122 |
+
element.addEventListener("mousedown", pointerStart);
|
123 |
+
element.addEventListener("touchstart", pointerStart);
|
124 |
+
};
|
125 |
+
|
126 |
+
/**
|
127 |
+
* Replaces all quotes in a string with standard quotes.
|
128 |
+
* @param {string} text - The text to replace quotes in.
|
129 |
+
* @returns {string} - The text with quotes replaced.
|
130 |
+
*/
|
131 |
+
export let replaceQuotes = (text) => {
|
132 |
+
return text.replaceAll("“", "\"")
|
133 |
+
.replaceAll("”", "\"")
|
134 |
+
.replaceAll("‘", "'")
|
135 |
+
.replaceAll("’", "'");
|
136 |
+
};
|
137 |
+
|
138 |
+
/**
|
139 |
+
* Converts a hex color to an rgb color.
|
140 |
+
* @param {string} hex - The hex color to convert.
|
141 |
+
* @returns {array} - The rgb color.
|
142 |
+
*/
|
143 |
+
export let hexToRgb = (hex) => {
|
144 |
+
let bigint = parseInt(hex.replace("#", ""), 16);
|
145 |
+
return [
|
146 |
+
(bigint >> 16) & 255,
|
147 |
+
(bigint >> 8) & 255,
|
148 |
+
bigint & 255
|
149 |
+
];
|
150 |
+
};
|
www/index.css
ADDED
@@ -0,0 +1,1667 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
:root {
|
2 |
+
--color-primary: #e3c948;
|
3 |
+
--color-secondary: #e1e1e1;
|
4 |
+
--color-tertiary: #e1e1e1;
|
5 |
+
}
|
6 |
+
|
7 |
+
@font-face {
|
8 |
+
font-family: 'bytebounce';
|
9 |
+
src: url('static/bytebounce.woff2') format('woff2'),
|
10 |
+
url('static/bytebounce.woff') format('woff');
|
11 |
+
font-weight: normal;
|
12 |
+
font-style: normal;
|
13 |
+
}
|
14 |
+
|
15 |
+
html, body {
|
16 |
+
padding: 0;
|
17 |
+
margin: 0;
|
18 |
+
width: 100%;
|
19 |
+
height: 100%;
|
20 |
+
overflow: hidden;
|
21 |
+
font-size: 24px;
|
22 |
+
font-family: "Roboto", sans-serif;
|
23 |
+
}
|
24 |
+
|
25 |
+
body {
|
26 |
+
display: flex;
|
27 |
+
flex-direction: column;
|
28 |
+
flex-wrap: nowrap;
|
29 |
+
justify-content: center;
|
30 |
+
align-items: center;
|
31 |
+
background-image: linear-gradient(to bottom, rgba(255,255,255,0.2), rgba(0,0,0,0.2)),
|
32 |
+
url("static/wood.jpg");
|
33 |
+
background-blend-mode: overlay, normal;
|
34 |
+
box-shadow: inset 10px 10px 50px black;
|
35 |
+
}
|
36 |
+
|
37 |
+
main {
|
38 |
+
position: relative;
|
39 |
+
background-image:
|
40 |
+
linear-gradient(45deg, #333, #888),
|
41 |
+
url("static/aluminum.jpg");
|
42 |
+
background-blend-mode: overlay, normal;
|
43 |
+
background-repeat: repeat;
|
44 |
+
border-radius: 25px;
|
45 |
+
display: grid;
|
46 |
+
grid-template-rows: 1fr 1fr 1fr 1fr 1fr;
|
47 |
+
grid-template-columns: 1fr 1fr 1fr 1fr 1fr 1fr;
|
48 |
+
grid-gap: 50px;
|
49 |
+
padding: 50px;
|
50 |
+
box-shadow:
|
51 |
+
18px 18px 8px rgba(0,0,0,0.4),
|
52 |
+
5px 5px 10px rgba(0,0,0,0.5),
|
53 |
+
inset 2px 2px 1px rgba(255,255,255,0.2),
|
54 |
+
inset -2px -2px 1px rgba(0,0,0,0.2);
|
55 |
+
width: 1200px;
|
56 |
+
height: 900px;
|
57 |
+
flex-shrink: 0;
|
58 |
+
}
|
59 |
+
|
60 |
+
input, textarea {
|
61 |
+
background: none;
|
62 |
+
outline: none;
|
63 |
+
border: none;
|
64 |
+
font-family: "bytebounce", monospace;
|
65 |
+
font-size: 24px;
|
66 |
+
color: var(--color-tertiary);
|
67 |
+
}
|
68 |
+
|
69 |
+
textarea {
|
70 |
+
width: 100%;
|
71 |
+
resize: none;
|
72 |
+
}
|
73 |
+
|
74 |
+
#logo,
|
75 |
+
#credits {
|
76 |
+
position: absolute;
|
77 |
+
left: 0;
|
78 |
+
width: 100%;
|
79 |
+
line-height: 0;
|
80 |
+
letter-spacing: -2px;
|
81 |
+
color: rgba(0,0,0,0.75);
|
82 |
+
text-shadow: 1px 1px 1px rgba(255,255,255,0.25);
|
83 |
+
user-select: none;
|
84 |
+
text-align: center;
|
85 |
+
}
|
86 |
+
|
87 |
+
#logo {
|
88 |
+
top: 28px;
|
89 |
+
font-size: 42px;
|
90 |
+
font-family: "Meie Script", cursive;
|
91 |
+
font-weight: 400;
|
92 |
+
font-style: normal;
|
93 |
+
}
|
94 |
+
|
95 |
+
#credits {
|
96 |
+
bottom: 10px;
|
97 |
+
font-size: 13px;
|
98 |
+
font-family: "Roboto", sans-serif;
|
99 |
+
letter-spacing: 0;
|
100 |
+
line-height: 12px;
|
101 |
+
}
|
102 |
+
|
103 |
+
#credits a {
|
104 |
+
color: inherit;
|
105 |
+
text-decoration: underline;
|
106 |
+
}
|
107 |
+
|
108 |
+
#credits .smaller {
|
109 |
+
font-size: 9px;
|
110 |
+
}
|
111 |
+
|
112 |
+
#transcription {
|
113 |
+
padding: 1em;
|
114 |
+
background-color: #08100c;
|
115 |
+
background-image: radial-gradient(#0b1812, transparent);
|
116 |
+
color: var(--color-primary);
|
117 |
+
text-shadow: 0 0 1em var(--color-primary);
|
118 |
+
position: relative;
|
119 |
+
border-radius: 1em;
|
120 |
+
grid-area: 3 / 1 / 6 / 5;
|
121 |
+
font-family: "bytebounce", monospace;
|
122 |
+
}
|
123 |
+
|
124 |
+
#transcription #welcome {
|
125 |
+
margin-bottom: 10px;
|
126 |
+
}
|
127 |
+
|
128 |
+
#transcription #content {
|
129 |
+
position: absolute;
|
130 |
+
left: 0;
|
131 |
+
top: 0;
|
132 |
+
right: 0;
|
133 |
+
bottom: 0;
|
134 |
+
overflow-y: auto;
|
135 |
+
z-index: 1;
|
136 |
+
padding: 45px 70px 70px 80px;
|
137 |
+
}
|
138 |
+
|
139 |
+
#transcription #input {
|
140 |
+
position: relative;
|
141 |
+
}
|
142 |
+
|
143 |
+
#transcription #input::before {
|
144 |
+
content: ">";
|
145 |
+
color: var(--color-secondary);
|
146 |
+
position: absolute;
|
147 |
+
left: -12px;
|
148 |
+
top: 2px;
|
149 |
+
}
|
150 |
+
|
151 |
+
#transcription #bezel {
|
152 |
+
content: "\A";
|
153 |
+
background-image: url("static/bezel.png");
|
154 |
+
background-size: 100% 100%;
|
155 |
+
z-index: 3;
|
156 |
+
position: absolute;
|
157 |
+
left: -5px;
|
158 |
+
top: -5px;
|
159 |
+
right: -5px;
|
160 |
+
bottom: -5px;
|
161 |
+
border-radius: 1em;
|
162 |
+
pointer-events: none;
|
163 |
+
}
|
164 |
+
|
165 |
+
#transcription .reflection {
|
166 |
+
content: "\A";
|
167 |
+
background-image: url("static/screen-reflection.jpg");
|
168 |
+
background-size: 100% 100%;
|
169 |
+
z-index: 2;
|
170 |
+
position: absolute;
|
171 |
+
left: 0;
|
172 |
+
top: 0;
|
173 |
+
right: 0;
|
174 |
+
bottom: 0;
|
175 |
+
border-radius: 1em;
|
176 |
+
pointer-events: none;
|
177 |
+
mix-blend-mode: soft-light;
|
178 |
+
opacity: 0.5;
|
179 |
+
}
|
180 |
+
|
181 |
+
#transcription .reflection.dodge {
|
182 |
+
mix-blend-mode: color-dodge;
|
183 |
+
opacity: 0.05;
|
184 |
+
}
|
185 |
+
|
186 |
+
#transcription .transcription {
|
187 |
+
color: white;
|
188 |
+
}
|
189 |
+
|
190 |
+
#transcription .completion {
|
191 |
+
white-space: pre-wrap;
|
192 |
+
line-height: 1;
|
193 |
+
}
|
194 |
+
|
195 |
+
#transcription .completion .spoken {
|
196 |
+
text-decoration: underline;
|
197 |
+
}
|
198 |
+
|
199 |
+
@keyframes fadeIn {
|
200 |
+
0% {
|
201 |
+
opacity: 0;
|
202 |
+
}
|
203 |
+
100% {
|
204 |
+
opacity: 1;
|
205 |
+
}
|
206 |
+
}
|
207 |
+
|
208 |
+
#transcription .tool {
|
209 |
+
margin: -10px 0 2px 0;
|
210 |
+
text-align: right;
|
211 |
+
font-size: 15px;
|
212 |
+
color: var(--color-secondary);
|
213 |
+
animation: fadeIn 0.5s ease-out;
|
214 |
+
}
|
215 |
+
|
216 |
+
#transcription .tool span {
|
217 |
+
cursor: pointer;
|
218 |
+
color: var(--color-primary);
|
219 |
+
}
|
220 |
+
|
221 |
+
#transcription .citation {
|
222 |
+
margin: 0 0 2px 0;
|
223 |
+
text-align: right;
|
224 |
+
font-size: 15px;
|
225 |
+
color: var(--color-secondary);
|
226 |
+
animation: fadeIn 0.5s ease-out;
|
227 |
+
}
|
228 |
+
|
229 |
+
#transcription .citation a {
|
230 |
+
color: var(--color-primary);
|
231 |
+
text-decoration: none;
|
232 |
+
}
|
233 |
+
|
234 |
+
#transcription .citation:last-child {
|
235 |
+
margin-bottom: 1em;
|
236 |
+
}
|
237 |
+
|
238 |
+
#waveform {
|
239 |
+
position: relative;
|
240 |
+
grid-area: 1 / 1 / 3 / 3;
|
241 |
+
}
|
242 |
+
|
243 |
+
#waveform .oscilloscope-grid {
|
244 |
+
background-image: url("static/oscilloscope-grid.png");
|
245 |
+
background-size: 100% 100%;
|
246 |
+
z-index: 2;
|
247 |
+
position: absolute;
|
248 |
+
left: 0;
|
249 |
+
top: 0;
|
250 |
+
right: 0;
|
251 |
+
bottom: 0;
|
252 |
+
border-radius: 100%;
|
253 |
+
pointer-events: none;
|
254 |
+
mix-blend-mode: difference;
|
255 |
+
opacity: 0.3;
|
256 |
+
}
|
257 |
+
|
258 |
+
#waveform .circle-reflection {
|
259 |
+
background-image: url("static/screen-reflection-circle.jpg");
|
260 |
+
background-size: 100% 100%;
|
261 |
+
z-index: 3;
|
262 |
+
position: absolute;
|
263 |
+
left: 0;
|
264 |
+
top: 0;
|
265 |
+
right: 0;
|
266 |
+
bottom: 0;
|
267 |
+
border-radius: 100%;
|
268 |
+
pointer-events: none;
|
269 |
+
mix-blend-mode: soft-light;
|
270 |
+
opacity: 0.5;
|
271 |
+
}
|
272 |
+
|
273 |
+
#waveform .circle-reflection.dodge {
|
274 |
+
mix-blend-mode: color-dodge;
|
275 |
+
opacity: 0.05;
|
276 |
+
}
|
277 |
+
|
278 |
+
#waveform .circle-shading {
|
279 |
+
content: "\A";
|
280 |
+
background-image: url("static/shading-circle.png");
|
281 |
+
background-size: 100% 100%;
|
282 |
+
z-index: 0;
|
283 |
+
position: absolute;
|
284 |
+
left: -20px;
|
285 |
+
top: -20px;
|
286 |
+
right: -20px;
|
287 |
+
bottom: -20px;
|
288 |
+
border-radius: 100%;
|
289 |
+
pointer-events: none;
|
290 |
+
}
|
291 |
+
|
292 |
+
#waveform canvas {
|
293 |
+
border-radius: 100%;
|
294 |
+
box-shadow: inset .5em .5em 1.5em rgba(0,0,0,0.6);
|
295 |
+
position: absolute;
|
296 |
+
left: 0;
|
297 |
+
top: 0;
|
298 |
+
right: 0;
|
299 |
+
bottom: 0;
|
300 |
+
z-index: 1;
|
301 |
+
width: 100%;
|
302 |
+
height: 100%;
|
303 |
+
}
|
304 |
+
|
305 |
+
section#voice::before {
|
306 |
+
content: "VOICE";
|
307 |
+
position: absolute;
|
308 |
+
width: 66px;
|
309 |
+
top: 0px;
|
310 |
+
left: calc(50% - 33px);
|
311 |
+
text-align: center;
|
312 |
+
font-size: 16px;
|
313 |
+
font-weight: 800;
|
314 |
+
border-bottom-left-radius: 4px;
|
315 |
+
border-bottom-right-radius: 4px;
|
316 |
+
padding: 4px 0;
|
317 |
+
background-color: #222;
|
318 |
+
color: #AAA;
|
319 |
+
}
|
320 |
+
|
321 |
+
section#voice {
|
322 |
+
position: relative;
|
323 |
+
border: 2px solid rgba(24,24,24,0.9);
|
324 |
+
border-radius: 25px;
|
325 |
+
display: grid;
|
326 |
+
grid-template-rows: 1fr 1fr 1fr 1fr;
|
327 |
+
grid-template-columns: 1fr 1fr 1fr 1fr 1fr 1fr;
|
328 |
+
grid-area: 1 / 3 / 3 / 5;
|
329 |
+
}
|
330 |
+
|
331 |
+
#wrapper-voice-id {
|
332 |
+
grid-area: 1 / 1 / 4 / 6;
|
333 |
+
height: 175px;
|
334 |
+
margin-right: -10px;
|
335 |
+
}
|
336 |
+
|
337 |
+
#wrapper-voice-id-wheel {
|
338 |
+
top: 24px;
|
339 |
+
left: 0px;
|
340 |
+
grid-area: 1 / 6 / 5 / 6;
|
341 |
+
}
|
342 |
+
|
343 |
+
#wrapper-volume {
|
344 |
+
grid-area: 3 / 1 / 6 / 4;
|
345 |
+
margin: 0 auto;
|
346 |
+
}
|
347 |
+
|
348 |
+
#wrapper-volume::before {
|
349 |
+
content: "VOLUME";
|
350 |
+
position: absolute;
|
351 |
+
width: 76px;
|
352 |
+
bottom: -2px;
|
353 |
+
left: calc(50% - 38px);
|
354 |
+
text-align: center;
|
355 |
+
font-size: 14px;
|
356 |
+
font-weight: 400;
|
357 |
+
border-top-left-radius: 4px;
|
358 |
+
border-top-right-radius: 4px;
|
359 |
+
padding: 4px 0;
|
360 |
+
background-color: #222;
|
361 |
+
color: #AAA;
|
362 |
+
}
|
363 |
+
|
364 |
+
#wrapper-speed {
|
365 |
+
grid-area: 3 / 4 / 6 / 7;
|
366 |
+
margin: 0 auto;
|
367 |
+
}
|
368 |
+
|
369 |
+
#wrapper-speed::before {
|
370 |
+
content: "SPEED";
|
371 |
+
position: absolute;
|
372 |
+
width: 60px;
|
373 |
+
bottom: -2px;
|
374 |
+
left: calc(50% - 30px);
|
375 |
+
text-align: center;
|
376 |
+
font-size: 14px;
|
377 |
+
font-weight: 400;
|
378 |
+
border-top-left-radius: 4px;
|
379 |
+
border-top-right-radius: 4px;
|
380 |
+
padding: 4px 0;
|
381 |
+
background-color: #222;
|
382 |
+
color: #AAA;
|
383 |
+
}
|
384 |
+
|
385 |
+
section#brain {
|
386 |
+
position: relative;
|
387 |
+
border: 2px solid rgba(24,24,24,0.9);
|
388 |
+
border-radius: 25px;
|
389 |
+
display: grid;
|
390 |
+
grid-template-rows: 1fr 1fr 1fr;
|
391 |
+
grid-template-columns: 1fr 1fr 1fr;
|
392 |
+
grid-area: 1 / 5 / 4 / 7;
|
393 |
+
}
|
394 |
+
|
395 |
+
section#brain::before {
|
396 |
+
content: "BRAIN";
|
397 |
+
position: absolute;
|
398 |
+
width: 66px;
|
399 |
+
top: 0px;
|
400 |
+
left: calc(50% - 33px);
|
401 |
+
text-align: center;
|
402 |
+
font-size: 16px;
|
403 |
+
font-weight: 800;
|
404 |
+
border-bottom-left-radius: 4px;
|
405 |
+
border-bottom-right-radius: 4px;
|
406 |
+
padding: 4px 0;
|
407 |
+
background-color: #222;
|
408 |
+
color: #AAA;
|
409 |
+
}
|
410 |
+
|
411 |
+
#wrapper-top-k {
|
412 |
+
grid-area: 1 / 3 / 1 / 3;
|
413 |
+
margin: 0 40px 0 0;
|
414 |
+
}
|
415 |
+
|
416 |
+
#wrapper-top-k::before {
|
417 |
+
content: "TOP-K";
|
418 |
+
position: absolute;
|
419 |
+
transform: rotate(-90deg);
|
420 |
+
width: 76px;
|
421 |
+
right: -68px;
|
422 |
+
text-align: center;
|
423 |
+
font-size: 14px;
|
424 |
+
font-weight: 400;
|
425 |
+
border-top-left-radius: 4px;
|
426 |
+
border-top-right-radius: 4px;
|
427 |
+
padding: 4px 0;
|
428 |
+
background-color: #222;
|
429 |
+
color: #AAA;
|
430 |
+
}
|
431 |
+
|
432 |
+
#wrapper-top-k-display {
|
433 |
+
grid-area: 1 / 1 / 2 / 3;
|
434 |
+
margin: 0 auto;
|
435 |
+
}
|
436 |
+
|
437 |
+
#wrapper-min-p {
|
438 |
+
grid-area: 2 / 1 / 4 / 1;
|
439 |
+
}
|
440 |
+
|
441 |
+
#wrapper-top-p {
|
442 |
+
grid-area: 2 / 2 / 4 / 2;
|
443 |
+
}
|
444 |
+
|
445 |
+
#wrapper-temperature {
|
446 |
+
grid-area: 2 / 3 / 4 / 3;
|
447 |
+
}
|
448 |
+
|
449 |
+
#wrapper-min-p,
|
450 |
+
#wrapper-top-p,
|
451 |
+
#wrapper-temperature {
|
452 |
+
padding: 0 0 50px 0;
|
453 |
+
}
|
454 |
+
|
455 |
+
#wrapper-min-p::before,
|
456 |
+
#wrapper-top-p::before,
|
457 |
+
#wrapper-temperature::before {
|
458 |
+
position: absolute;
|
459 |
+
width: 66px;
|
460 |
+
bottom: -2px;
|
461 |
+
left: calc(50% - 33px);
|
462 |
+
text-align: center;
|
463 |
+
font-size: 14px;
|
464 |
+
font-weight: 400;
|
465 |
+
border-top-left-radius: 4px;
|
466 |
+
border-top-right-radius: 4px;
|
467 |
+
padding: 4px 0;
|
468 |
+
background-color: #222;
|
469 |
+
color: #AAA;
|
470 |
+
}
|
471 |
+
|
472 |
+
#wrapper-min-p::before {
|
473 |
+
content: "MIN-P";
|
474 |
+
}
|
475 |
+
|
476 |
+
#wrapper-top-p::before {
|
477 |
+
content: "TOP-P";
|
478 |
+
}
|
479 |
+
|
480 |
+
#wrapper-temperature::before {
|
481 |
+
content: "TEMP";
|
482 |
+
}
|
483 |
+
|
484 |
+
section#microphone {
|
485 |
+
position: relative;
|
486 |
+
border: 2px solid rgba(24,24,24,0.9);
|
487 |
+
border-radius: 25px;
|
488 |
+
display: grid;
|
489 |
+
grid-template-rows: 1fr 1fr 1fr;
|
490 |
+
grid-template-columns: 1fr 1fr 1fr;
|
491 |
+
grid-area: 4 / 5 / 6 / 7;
|
492 |
+
}
|
493 |
+
|
494 |
+
div#speaker {
|
495 |
+
position: relative;
|
496 |
+
width: 100%;
|
497 |
+
height: 100%;
|
498 |
+
margin-top: 21px;
|
499 |
+
margin-left: 36px;
|
500 |
+
grid-area: 1 / 1 / 3 / 3;
|
501 |
+
}
|
502 |
+
|
503 |
+
div#speaker div.hole {
|
504 |
+
transform: translate(92px, 92px);
|
505 |
+
position: absolute;
|
506 |
+
width: 10px;
|
507 |
+
height: 10px;
|
508 |
+
border-radius: 100%;
|
509 |
+
box-shadow: inset 1px 1px 2px rgba(0,0,0,0.8),
|
510 |
+
inset 4px 4px 8px rgba(0,0,0,1.0);
|
511 |
+
}
|
512 |
+
|
513 |
+
div#power-switch {
|
514 |
+
grid-area: 3 / 2 / 3 / 3;
|
515 |
+
position: relative;
|
516 |
+
}
|
517 |
+
|
518 |
+
div#power-switch .power-wrapper {
|
519 |
+
transform: rotate(-90deg) scale(0.75);
|
520 |
+
position: absolute;
|
521 |
+
left: 30px;
|
522 |
+
top: -23px;
|
523 |
+
}
|
524 |
+
|
525 |
+
div#listen-button {
|
526 |
+
position: relative;
|
527 |
+
grid-area: 3 / 1 / 3 / 1;
|
528 |
+
display: flex;
|
529 |
+
flex-flow: row nowrap;
|
530 |
+
align-items: center;
|
531 |
+
justify-content: center;
|
532 |
+
}
|
533 |
+
|
534 |
+
div#listen-button label {
|
535 |
+
position: absolute;
|
536 |
+
bottom: -3px;
|
537 |
+
text-transform: uppercase;
|
538 |
+
font-size: 14px;
|
539 |
+
font-weight: 400;
|
540 |
+
background-color: #222;
|
541 |
+
border-top-left-radius: 4px;
|
542 |
+
border-top-right-radius: 4px;
|
543 |
+
padding: 4px 0;
|
544 |
+
width: 64px;
|
545 |
+
text-align: center;
|
546 |
+
color: #AAA;
|
547 |
+
}
|
548 |
+
|
549 |
+
button#listen {
|
550 |
+
display: block;
|
551 |
+
width: 60px;
|
552 |
+
height: 60px;
|
553 |
+
border-radius: 100%;
|
554 |
+
position: relative;
|
555 |
+
border: none;
|
556 |
+
outline: none;
|
557 |
+
background: none;
|
558 |
+
cursor: pointer;
|
559 |
+
box-shadow: 3px 3px 3px rgba(0,0,0,0.5);
|
560 |
+
}
|
561 |
+
|
562 |
+
button#listen::before,
|
563 |
+
button#listen::after {
|
564 |
+
content: "\A";
|
565 |
+
position: absolute;
|
566 |
+
left: 0;
|
567 |
+
top: 0;
|
568 |
+
width: 100%;
|
569 |
+
height: 100%;
|
570 |
+
transform-origin: center center;
|
571 |
+
transition: all 150ms ease-out;
|
572 |
+
background-size: 101% 101%;
|
573 |
+
background-repeat: no-repeat;
|
574 |
+
pointer-events: none;
|
575 |
+
}
|
576 |
+
|
577 |
+
button#listen::before {
|
578 |
+
background-image: url("static/button-background.png");
|
579 |
+
}
|
580 |
+
|
581 |
+
button#listen::after {
|
582 |
+
background-image: url("static/button-foreground.png");
|
583 |
+
}
|
584 |
+
|
585 |
+
button#listen:active::after {
|
586 |
+
transform: scale(0.9);
|
587 |
+
}
|
588 |
+
|
589 |
+
div.bulb-indicator {
|
590 |
+
position: relative;
|
591 |
+
width: 36px;
|
592 |
+
height: 36px;
|
593 |
+
margin: auto;
|
594 |
+
background-image:
|
595 |
+
linear-gradient(to bottom, #666, #444),
|
596 |
+
url("static/black-plastic.jpg");
|
597 |
+
background-blend-mode: overlay, normal;
|
598 |
+
border-radius: 100%;
|
599 |
+
box-shadow: 2px 2px 4px rgba(0,0,0,0.4);
|
600 |
+
}
|
601 |
+
|
602 |
+
div.bulb-indicator > label {
|
603 |
+
text-transform: uppercase;
|
604 |
+
position: absolute;
|
605 |
+
transform: rotate(-90deg);
|
606 |
+
width: 64px;
|
607 |
+
right: -62px;
|
608 |
+
top: 8px;
|
609 |
+
text-align: center;
|
610 |
+
font-size: 14px;
|
611 |
+
font-weight: 400;
|
612 |
+
border-top-left-radius: 4px;
|
613 |
+
border-top-right-radius: 4px;
|
614 |
+
padding: 4px 0;
|
615 |
+
background-color: #222;
|
616 |
+
color: #AAA;
|
617 |
+
}
|
618 |
+
|
619 |
+
div.bulb-indicator::before {
|
620 |
+
background-image:
|
621 |
+
radial-gradient(circle, rgba(255,255,255,0.15), rgba(255,255,255,0.4)),
|
622 |
+
linear-gradient(to right, rgba(255,255,255,0.3), rgba(255,255,255,0.6));
|
623 |
+
background-color: transparent;
|
624 |
+
content: "\A";
|
625 |
+
width: 28px;
|
626 |
+
height: 28px;
|
627 |
+
position: absolute;
|
628 |
+
left: calc(50% - 14px);
|
629 |
+
top: calc(50% - 14px);
|
630 |
+
border-radius: 100%;
|
631 |
+
opacity: 1.0;
|
632 |
+
mix-blend-mode: overlay;
|
633 |
+
z-index: 2;
|
634 |
+
transition: background-color 200ms ease-out;
|
635 |
+
}
|
636 |
+
|
637 |
+
div.bulb-indicator::after {
|
638 |
+
z-index: 1;
|
639 |
+
mix-blend-mode: hard-light;
|
640 |
+
width: 120px;
|
641 |
+
height: 120px;
|
642 |
+
content: "\A";
|
643 |
+
position: absolute;
|
644 |
+
left: calc(50% - 60px);
|
645 |
+
top: calc(50% - 60px);
|
646 |
+
border-radius: 100%;
|
647 |
+
opacity: 0;
|
648 |
+
transition: opacity 200ms ease-out;
|
649 |
+
background-image: radial-gradient(circle, var(--color-primary) 5px, transparent 40px);
|
650 |
+
}
|
651 |
+
|
652 |
+
div.bulb-indicator.active::before {
|
653 |
+
background-color: var(--color-primary);
|
654 |
+
box-shadow: inset 0 0 8px rgba(0,0,0,0.2);
|
655 |
+
}
|
656 |
+
|
657 |
+
div.bulb-indicator.active::after {
|
658 |
+
opacity: 0.5;
|
659 |
+
}
|
660 |
+
|
661 |
+
div#recording {
|
662 |
+
grid-area: 1 / 3 / 1 / 3;
|
663 |
+
}
|
664 |
+
|
665 |
+
div#listening {
|
666 |
+
grid-area: 2 / 3 / 2 / 3;
|
667 |
+
}
|
668 |
+
|
669 |
+
div#power {
|
670 |
+
grid-area: 3 / 3 / 3 / 3;
|
671 |
+
}
|
672 |
+
|
673 |
+
/* Common */
|
674 |
+
|
675 |
+
input.dial,
|
676 |
+
input.wheel,
|
677 |
+
input.solari-board,
|
678 |
+
input.seven-segment,
|
679 |
+
input.slider,
|
680 |
+
input.power {
|
681 |
+
display: none;
|
682 |
+
}
|
683 |
+
|
684 |
+
div.dial-wrapper {
|
685 |
+
display: flex;
|
686 |
+
flex-direction: column;
|
687 |
+
justify-content: center;
|
688 |
+
align-items: center;
|
689 |
+
position: relative;
|
690 |
+
}
|
691 |
+
|
692 |
+
div.dial {
|
693 |
+
display: block;
|
694 |
+
position: relative;
|
695 |
+
width: 75px;
|
696 |
+
height: 75px;
|
697 |
+
}
|
698 |
+
|
699 |
+
div.dial .value {
|
700 |
+
position: absolute;
|
701 |
+
width: 100%;
|
702 |
+
height: 100%;
|
703 |
+
transform-origin: center;
|
704 |
+
font-weight: 300;
|
705 |
+
text-transform: uppercase;
|
706 |
+
font-size: 15px;
|
707 |
+
pointer-events: none;
|
708 |
+
letter-spacing: 1px;
|
709 |
+
}
|
710 |
+
|
711 |
+
div.dial .value span {
|
712 |
+
display: block;
|
713 |
+
transform: rotate(90deg) translateX(-20px);
|
714 |
+
max-width: 150px;
|
715 |
+
}
|
716 |
+
|
717 |
+
div.dial .value span::after {
|
718 |
+
content: "—";
|
719 |
+
}
|
720 |
+
|
721 |
+
div.dial .value.top span {
|
722 |
+
transform: rotate(270deg) translateX(24px);
|
723 |
+
text-align: end;
|
724 |
+
}
|
725 |
+
|
726 |
+
div.dial .value.right span {
|
727 |
+
transform: rotate(270deg) translateX(20px);
|
728 |
+
text-align: end;
|
729 |
+
}
|
730 |
+
|
731 |
+
div.dial .value.top span::before,
|
732 |
+
div.dial .value.right span::before {
|
733 |
+
content: "—";
|
734 |
+
}
|
735 |
+
|
736 |
+
div.dial .value.top span::after,
|
737 |
+
div.dial .value.right span::after {
|
738 |
+
content: "";
|
739 |
+
}
|
740 |
+
|
741 |
+
div.dial .dial-element,
|
742 |
+
div.dial .dial-specular {
|
743 |
+
position: absolute;
|
744 |
+
left: 0;
|
745 |
+
top: 0;
|
746 |
+
border-radius: 100%;
|
747 |
+
width: 100%;
|
748 |
+
height: 100%;
|
749 |
+
background-image: url("static/aluminum-spiral.jpg");
|
750 |
+
background-size: 100% 100%;
|
751 |
+
z-index: 1;
|
752 |
+
}
|
753 |
+
|
754 |
+
div.dial .dial-element {
|
755 |
+
cursor: grab;
|
756 |
+
}
|
757 |
+
|
758 |
+
div.dial.active .dial-element {
|
759 |
+
cursor: grabbing;
|
760 |
+
}
|
761 |
+
|
762 |
+
div.dial .dial-specular {
|
763 |
+
background-image: url("static/aluminum-circle-specular.jpg");
|
764 |
+
mix-blend-mode: soft-light;
|
765 |
+
background-size: 100% 100%;
|
766 |
+
z-index: 2;
|
767 |
+
pointer-events: none;
|
768 |
+
transform: rotate(90deg);
|
769 |
+
box-shadow: 10px -10px 10px rgba(0,0,0,0.3),
|
770 |
+
0 0 5px rgba(0,0,0,0.5),
|
771 |
+
11px -11px 0 rgba(0,0,0,0.15);
|
772 |
+
}
|
773 |
+
|
774 |
+
div.dial .dial-element::after {
|
775 |
+
content: "\A";
|
776 |
+
background-image: url("static/black-plastic.jpg");
|
777 |
+
background-size: 100% 100%;
|
778 |
+
width: 8px;
|
779 |
+
height: 8px;
|
780 |
+
border-radius: 10px;
|
781 |
+
position: absolute;
|
782 |
+
left: calc(50% - 3px);
|
783 |
+
padding: 0;
|
784 |
+
margin: 0;
|
785 |
+
top: 4px;
|
786 |
+
}
|
787 |
+
|
788 |
+
div.solari-board-wrapper {
|
789 |
+
display: flex;
|
790 |
+
flex-flow: row nowrap;
|
791 |
+
align-items: center;
|
792 |
+
justify-content: center;
|
793 |
+
}
|
794 |
+
|
795 |
+
div.solari-board {
|
796 |
+
display: flex;
|
797 |
+
flex-flow: row nowrap;
|
798 |
+
align-items: stretch;
|
799 |
+
justify-content: stretch;
|
800 |
+
gap: 2px;
|
801 |
+
background-image:
|
802 |
+
linear-gradient(to right, #444, #444),
|
803 |
+
url("static/black-plastic.jpg");
|
804 |
+
background-blend-mode: overlay, normal;
|
805 |
+
padding: 6px;
|
806 |
+
border-radius: 4px;
|
807 |
+
}
|
808 |
+
|
809 |
+
div.solari-board-element-container {
|
810 |
+
display: block;
|
811 |
+
position: relative;
|
812 |
+
width: 36px;
|
813 |
+
height: 90px;
|
814 |
+
}
|
815 |
+
|
816 |
+
div.solari-board-element-top,
|
817 |
+
div.solari-board-element-flap,
|
818 |
+
div.solari-board-element-bottom {
|
819 |
+
display: block;
|
820 |
+
position: absolute;
|
821 |
+
top: 0;
|
822 |
+
left: 0;
|
823 |
+
right: 0;
|
824 |
+
bottom: 0;
|
825 |
+
background-image:
|
826 |
+
linear-gradient(to bottom, #333, #222),
|
827 |
+
url("static/black-plastic.jpg");
|
828 |
+
background-blend-mode: overlay, normal;
|
829 |
+
color: #EEE;
|
830 |
+
text-align: center;
|
831 |
+
line-height: 98px;
|
832 |
+
overflow: hidden;
|
833 |
+
text-transform: uppercase;
|
834 |
+
font-family: "Lekton", monospace;
|
835 |
+
font-weight: 400;
|
836 |
+
font-size: 58px;
|
837 |
+
user-select: none;
|
838 |
+
}
|
839 |
+
|
840 |
+
div.solari-board-element-top {
|
841 |
+
bottom: calc(50% + 1px);
|
842 |
+
box-shadow: inset 4px 4px 2px rgba(0,0,0,0.4);
|
843 |
+
}
|
844 |
+
|
845 |
+
div.solari-board-element-flap {
|
846 |
+
bottom: calc(50% + 1px);
|
847 |
+
transform-origin: bottom center;
|
848 |
+
transition: transform 200ms linear;
|
849 |
+
}
|
850 |
+
|
851 |
+
div.solari-board-element-flap.middle {
|
852 |
+
transform: rotateX(90deg);
|
853 |
+
}
|
854 |
+
|
855 |
+
div.solari-board-element-flap.bottom {
|
856 |
+
top: calc(50% + 1px);
|
857 |
+
bottom: 0;
|
858 |
+
transform-origin: top center;
|
859 |
+
transform: rotateX(0deg);
|
860 |
+
line-height: 8px;
|
861 |
+
}
|
862 |
+
|
863 |
+
div.solari-board-element-bottom {
|
864 |
+
top: calc(50% + 1px);
|
865 |
+
line-height: 8px;
|
866 |
+
box-shadow: inset 4px 0px 2px rgba(0,0,0,0.4);
|
867 |
+
}
|
868 |
+
|
869 |
+
div.wheel-wrapper {
|
870 |
+
display: block;
|
871 |
+
position: relative;
|
872 |
+
height: 111px;
|
873 |
+
width: 35px;
|
874 |
+
overflow: visible;
|
875 |
+
padding: 8px;
|
876 |
+
background-image:
|
877 |
+
linear-gradient(to bottom, #333, #222),
|
878 |
+
url("static/black-plastic.jpg");
|
879 |
+
background-blend-mode: overlay, normal;
|
880 |
+
border-radius: 5px;
|
881 |
+
box-shadow: inset 0 0 10px black;
|
882 |
+
cursor: grab;
|
883 |
+
}
|
884 |
+
|
885 |
+
div.wheel {
|
886 |
+
height: 111px;
|
887 |
+
width: 35px;
|
888 |
+
overflow: hidden;
|
889 |
+
position: relative;
|
890 |
+
border-radius: 2px;
|
891 |
+
box-shadow: 0 0 4px rgba(0,0,0,0.3),
|
892 |
+
inset 2px 2px 0 rgba(0,0,0,0.8);
|
893 |
+
}
|
894 |
+
|
895 |
+
div.wheel-specular {
|
896 |
+
position: absolute;
|
897 |
+
left: 0;
|
898 |
+
top: 0;
|
899 |
+
right: 0;
|
900 |
+
bottom: 0;
|
901 |
+
background-image: linear-gradient(to bottom, black, #333 25%, white 50%, #333 75%, black);
|
902 |
+
mix-blend-mode: overlay;
|
903 |
+
opacity: 0.7;
|
904 |
+
pointer-events: none;
|
905 |
+
}
|
906 |
+
|
907 |
+
@keyframes wheelUp {
|
908 |
+
0% {
|
909 |
+
transform: translateY(0);
|
910 |
+
}
|
911 |
+
100% {
|
912 |
+
transform: translateY(-9px);
|
913 |
+
}
|
914 |
+
}
|
915 |
+
|
916 |
+
@keyframes wheelDown {
|
917 |
+
0% {
|
918 |
+
transform: translateY(0);
|
919 |
+
}
|
920 |
+
100% {
|
921 |
+
transform: translateY(9px);
|
922 |
+
}
|
923 |
+
}
|
924 |
+
|
925 |
+
div.wheel-element {
|
926 |
+
display: block;
|
927 |
+
position: relative;
|
928 |
+
width: 100px;
|
929 |
+
left: -25px;
|
930 |
+
top: -50px;
|
931 |
+
height: 350px;
|
932 |
+
background-image: url("static/wheel.jpg");
|
933 |
+
animation-timing-function: linear;
|
934 |
+
animation-iteration-count: infinite;
|
935 |
+
animation-duration: 1s;
|
936 |
+
}
|
937 |
+
|
938 |
+
div.wheel.up div.wheel-element {
|
939 |
+
animation-name: wheelUp;
|
940 |
+
}
|
941 |
+
|
942 |
+
div.wheel.down div.wheel-element {
|
943 |
+
animation-name: wheelDown;
|
944 |
+
}
|
945 |
+
|
946 |
+
div.wheel-shadow {
|
947 |
+
display: block;
|
948 |
+
position: absolute;
|
949 |
+
left: 49px;
|
950 |
+
width: 25px;
|
951 |
+
top: 0;
|
952 |
+
bottom: 0;
|
953 |
+
background-size: 100% 100%;
|
954 |
+
background-image: url("static/wheel-shadow.png");
|
955 |
+
background-repeat: no-repeat;
|
956 |
+
pointer-events: none;
|
957 |
+
opacity: 0.9;
|
958 |
+
}
|
959 |
+
|
960 |
+
div.seven-segment-wrapper {
|
961 |
+
position: relative;
|
962 |
+
display: flex;
|
963 |
+
flex-flow: row nowrap;
|
964 |
+
align-items: center;
|
965 |
+
justify-content: center;
|
966 |
+
}
|
967 |
+
|
968 |
+
div.seven-segment-wrapper .reflection {
|
969 |
+
content: "\A";
|
970 |
+
background-image: url("static/screen-reflection.jpg");
|
971 |
+
background-size: 150% 150%;
|
972 |
+
z-index: 2;
|
973 |
+
position: absolute;
|
974 |
+
left: 0px;
|
975 |
+
top: 35px;
|
976 |
+
right: 0px;
|
977 |
+
bottom: 35px;
|
978 |
+
border-radius: 5px;
|
979 |
+
pointer-events: none;
|
980 |
+
mix-blend-mode: soft-light;
|
981 |
+
opacity: 0.5;
|
982 |
+
}
|
983 |
+
|
984 |
+
div.seven-segment-wrapper .reflection.dodge {
|
985 |
+
mix-blend-mode: color-dodge;
|
986 |
+
opacity: 0.05;
|
987 |
+
}
|
988 |
+
|
989 |
+
div.seven-segment {
|
990 |
+
margin: 0 auto;
|
991 |
+
display: flex;
|
992 |
+
flex-flow: row nowrap;
|
993 |
+
justify-content: center;
|
994 |
+
align-items: center;
|
995 |
+
position: relative;
|
996 |
+
height: 100px;
|
997 |
+
background-color: #08100c;
|
998 |
+
background-image: radial-gradient(#0b1812, transparent);
|
999 |
+
border-radius: 5px;
|
1000 |
+
box-shadow: inset 5px 5px 10px black;
|
1001 |
+
}
|
1002 |
+
|
1003 |
+
div.seven-segment-element {
|
1004 |
+
display: block;
|
1005 |
+
position: relative;
|
1006 |
+
width: 64px;
|
1007 |
+
height: 80px;
|
1008 |
+
}
|
1009 |
+
|
1010 |
+
div.seven-segment-element:not(:last-child) {
|
1011 |
+
margin-right: -12px;
|
1012 |
+
}
|
1013 |
+
|
1014 |
+
div.seven-segment-element > div {
|
1015 |
+
display: block;
|
1016 |
+
position: absolute;
|
1017 |
+
background-color: #222;
|
1018 |
+
width: 24px;
|
1019 |
+
height: 8px;
|
1020 |
+
}
|
1021 |
+
|
1022 |
+
div.seven-segment-element > div.on {
|
1023 |
+
background-color: var(--color-primary);
|
1024 |
+
box-shadow: 0 0 25px var(--color-primary);
|
1025 |
+
}
|
1026 |
+
|
1027 |
+
div.seven-segment-element > div::before,
|
1028 |
+
div.seven-segment-element > div::after {
|
1029 |
+
content: "\A";
|
1030 |
+
border-width: 4px;
|
1031 |
+
border-style: solid;
|
1032 |
+
display: block;
|
1033 |
+
position: absolute;
|
1034 |
+
top: 0;
|
1035 |
+
width: 0;
|
1036 |
+
height: 0;
|
1037 |
+
background: none;
|
1038 |
+
}
|
1039 |
+
|
1040 |
+
div.seven-segment-element > div::before {
|
1041 |
+
left: -8px;
|
1042 |
+
border-color: transparent #222 transparent transparent;
|
1043 |
+
}
|
1044 |
+
|
1045 |
+
div.seven-segment-element > div.on::before {
|
1046 |
+
border-color: transparent var(--color-primary) transparent transparent;
|
1047 |
+
}
|
1048 |
+
|
1049 |
+
div.seven-segment-element > div::after {
|
1050 |
+
right: -8px;
|
1051 |
+
border-color: transparent transparent transparent #222;
|
1052 |
+
}
|
1053 |
+
|
1054 |
+
div.seven-segment-element > div.on::after {
|
1055 |
+
border-color: transparent transparent transparent var(--color-primary);
|
1056 |
+
}
|
1057 |
+
|
1058 |
+
div.seven-segment-element > .segment-0 {
|
1059 |
+
top: 2px;
|
1060 |
+
left: 20px;
|
1061 |
+
}
|
1062 |
+
|
1063 |
+
div.seven-segment-element > .segment-1 {
|
1064 |
+
top: 19px;
|
1065 |
+
left: 2px;
|
1066 |
+
transform: rotate(90deg);
|
1067 |
+
}
|
1068 |
+
|
1069 |
+
div.seven-segment-element > .segment-2 {
|
1070 |
+
top: 19px;
|
1071 |
+
right: 2px;
|
1072 |
+
transform: rotate(90deg);
|
1073 |
+
}
|
1074 |
+
|
1075 |
+
div.seven-segment-element > .segment-3 {
|
1076 |
+
left: 20px;
|
1077 |
+
top: 36px;
|
1078 |
+
}
|
1079 |
+
|
1080 |
+
div.seven-segment-element > .segment-4 {
|
1081 |
+
top: 53px;
|
1082 |
+
left: 2px;
|
1083 |
+
transform: rotate(90deg);
|
1084 |
+
}
|
1085 |
+
|
1086 |
+
div.seven-segment-element > .segment-5 {
|
1087 |
+
top: 53px;
|
1088 |
+
right: 2px;
|
1089 |
+
transform: rotate(90deg);
|
1090 |
+
}
|
1091 |
+
|
1092 |
+
div.seven-segment-element > .segment-6 {
|
1093 |
+
bottom: 2px;
|
1094 |
+
left: 20px;
|
1095 |
+
}
|
1096 |
+
|
1097 |
+
div.seven-segment-element > .segment-7 {
|
1098 |
+
bottom: 2px;
|
1099 |
+
left: 20px;
|
1100 |
+
}
|
1101 |
+
|
1102 |
+
div.slider-wrapper {
|
1103 |
+
display: flex;
|
1104 |
+
position: relative;
|
1105 |
+
flex-flow: column nowrap;
|
1106 |
+
align-items: center;
|
1107 |
+
justify-content: center;
|
1108 |
+
}
|
1109 |
+
|
1110 |
+
div.slider-wrapper div.label {
|
1111 |
+
position: absolute;
|
1112 |
+
left: 0;
|
1113 |
+
width: 50px;
|
1114 |
+
display: flex;
|
1115 |
+
width: 100%;
|
1116 |
+
text-align: left;
|
1117 |
+
font-size: 12px;
|
1118 |
+
color: #444;
|
1119 |
+
line-height: 0;
|
1120 |
+
z-index: 0;
|
1121 |
+
}
|
1122 |
+
|
1123 |
+
div.slider-wrapper div.label.left {
|
1124 |
+
left: -40px;
|
1125 |
+
}
|
1126 |
+
|
1127 |
+
div.slider-wrapper div.label.left::after,
|
1128 |
+
div.slider-wrapper div.label.right::before {
|
1129 |
+
content: "\A";
|
1130 |
+
position: absolute;
|
1131 |
+
height: 1px;
|
1132 |
+
width: 10px;
|
1133 |
+
background-image: linear-gradient(to right, transparent, #444 20%, #444 80%, transparent);
|
1134 |
+
display: block;
|
1135 |
+
}
|
1136 |
+
|
1137 |
+
div.slider-wrapper div.label.left::after {
|
1138 |
+
left: 28px;
|
1139 |
+
}
|
1140 |
+
|
1141 |
+
div.slider-wrapper div.label.right {
|
1142 |
+
left: 25px;
|
1143 |
+
}
|
1144 |
+
|
1145 |
+
div.slider-wrapper div.label.right::before {
|
1146 |
+
left: -15px;
|
1147 |
+
}
|
1148 |
+
|
1149 |
+
div.slider {
|
1150 |
+
position: relative;
|
1151 |
+
display: block;
|
1152 |
+
width: 8px;
|
1153 |
+
height: 100%;
|
1154 |
+
border-radius: 10px;
|
1155 |
+
box-shadow: inset 2px 2px 6px black,
|
1156 |
+
inset 0 0 10px black;
|
1157 |
+
z-index: 1;
|
1158 |
+
}
|
1159 |
+
|
1160 |
+
div.slider-element {
|
1161 |
+
position: absolute;
|
1162 |
+
width: 100px;
|
1163 |
+
left: calc(50% - 50px);
|
1164 |
+
height: 20px;
|
1165 |
+
margin-top: -10px;
|
1166 |
+
perspective: 32px;
|
1167 |
+
cursor: grab;
|
1168 |
+
z-index: 2;
|
1169 |
+
}
|
1170 |
+
|
1171 |
+
div.slider-element::before,
|
1172 |
+
div.slider-element::after {
|
1173 |
+
content: "\A";
|
1174 |
+
display: block;
|
1175 |
+
position: absolute;
|
1176 |
+
width: 50%;
|
1177 |
+
height: 100%;
|
1178 |
+
transform-origin: center;
|
1179 |
+
background-blend-mode: overlay, normal;
|
1180 |
+
box-shadow: 4px 4px 6px rgba(0,0,0,0.4),
|
1181 |
+
10px 10px 14px rgba(0,0,0,0.2);
|
1182 |
+
}
|
1183 |
+
|
1184 |
+
div.slider-element::before {
|
1185 |
+
left: 1px;
|
1186 |
+
transform: rotateY(-15deg);
|
1187 |
+
background-image:
|
1188 |
+
linear-gradient(to right, #CCC, #888),
|
1189 |
+
url("static/black-plastic.jpg");
|
1190 |
+
border-top-left-radius: 2px;
|
1191 |
+
border-bottom-left-radius: 2px;
|
1192 |
+
}
|
1193 |
+
|
1194 |
+
div.slider-element::after {
|
1195 |
+
right: 1px;
|
1196 |
+
transform: rotateY(15deg);
|
1197 |
+
background-image:
|
1198 |
+
linear-gradient(to right, #444, #333),
|
1199 |
+
url("static/black-plastic.jpg");
|
1200 |
+
border-top-right-radius: 2px;
|
1201 |
+
border-bottom-right-radius: 2px;
|
1202 |
+
}
|
1203 |
+
|
1204 |
+
.power-wrapper input {
|
1205 |
+
display: none;
|
1206 |
+
}
|
1207 |
+
|
1208 |
+
.power-button .on,
|
1209 |
+
.power-button .off {
|
1210 |
+
position: absolute;
|
1211 |
+
text-align: center;
|
1212 |
+
text-shadow: inset 1px 1px 1px black;
|
1213 |
+
width: 100%;
|
1214 |
+
transform: rotateZ(90deg);
|
1215 |
+
}
|
1216 |
+
|
1217 |
+
.power-button .on {
|
1218 |
+
color: #888;
|
1219 |
+
top: 10px;
|
1220 |
+
transition: all 0.1s;
|
1221 |
+
font-family: sans-serif;
|
1222 |
+
}
|
1223 |
+
|
1224 |
+
.power-button .off {
|
1225 |
+
color: #aaa;
|
1226 |
+
bottom: 5px;
|
1227 |
+
transition: all 0.1s;
|
1228 |
+
transform: scaleY(0.85) rotateZ(90deg);
|
1229 |
+
}
|
1230 |
+
|
1231 |
+
.power-button {
|
1232 |
+
cursor: pointer;
|
1233 |
+
background-color: #272727;
|
1234 |
+
border-radius: 5px;
|
1235 |
+
border-bottom-width: 0px;
|
1236 |
+
box-shadow: inset 8px 6px 5px -7px rgba(0, 0, 0, 1),
|
1237 |
+
inset -8px 6px 5px -7px rgba(0, 0, 0, 1),
|
1238 |
+
inset 0 -3px 2px -2px rgba(200, 200, 200, 0.5),
|
1239 |
+
0 3px 3px -2px rgba(0, 0, 0, 1),
|
1240 |
+
inset 0 -230px 60px -200px rgba(255, 255, 255, 0.2),
|
1241 |
+
inset 0 220px 40px -200px rgba(0, 0, 0, 0.3);
|
1242 |
+
display: block;
|
1243 |
+
font-size: 29px;
|
1244 |
+
height: 128px;
|
1245 |
+
position: relative;
|
1246 |
+
transition: all 0.2s;
|
1247 |
+
width: 60px;
|
1248 |
+
}
|
1249 |
+
|
1250 |
+
.power-button-background {
|
1251 |
+
background-color: black;
|
1252 |
+
background-image: linear-gradient(
|
1253 |
+
0deg,
|
1254 |
+
transparent 30%,
|
1255 |
+
transparent 65%
|
1256 |
+
),
|
1257 |
+
linear-gradient(
|
1258 |
+
0deg,
|
1259 |
+
rgba(150, 150, 150, 0) 30%,
|
1260 |
+
rgba(150, 150, 150, 0.1) 50%,
|
1261 |
+
rgba(150, 150, 150, 0) 70%
|
1262 |
+
);
|
1263 |
+
border-radius: 5px;
|
1264 |
+
box-sizing: border-box;
|
1265 |
+
height: 155px;
|
1266 |
+
padding: 4px 4px;
|
1267 |
+
transition: all 0.2s;
|
1268 |
+
width: 68px;
|
1269 |
+
box-shadow: -3px 3px 3px rgba(0,0,0,0.5);
|
1270 |
+
}
|
1271 |
+
|
1272 |
+
.power-button-container {
|
1273 |
+
background: white;
|
1274 |
+
background: linear-gradient(270deg, #444, #222);
|
1275 |
+
border-radius: 5px;
|
1276 |
+
box-shadow: 0px 0px 0px 8px rgba(0, 0, 0, 0.1),
|
1277 |
+
0px 0px 3px 1px rgba(0, 0, 0, 1),
|
1278 |
+
inset 0 8px 3px -8px rgba(255, 255, 255, 0.4);
|
1279 |
+
height: 165px;
|
1280 |
+
margin: 30px auto;
|
1281 |
+
padding: 5px;
|
1282 |
+
width: 79px;
|
1283 |
+
}
|
1284 |
+
|
1285 |
+
input:checked + .power-button-background .on,
|
1286 |
+
input:checked + .power-button-background .off {
|
1287 |
+
text-shadow: inset 1px 1px 1px black;
|
1288 |
+
}
|
1289 |
+
|
1290 |
+
input:checked + .power-button-background .on {
|
1291 |
+
color: #444;
|
1292 |
+
top: 10px;
|
1293 |
+
transform: scaleY(0.85) rotateZ(90deg);
|
1294 |
+
}
|
1295 |
+
|
1296 |
+
input:checked + .power-button-background .off {
|
1297 |
+
color: #777;
|
1298 |
+
bottom: 5px;
|
1299 |
+
transform: scaleY(1) rotateZ(90deg);
|
1300 |
+
}
|
1301 |
+
|
1302 |
+
input:checked + .power-button-background .power-button {
|
1303 |
+
background: #232323;
|
1304 |
+
background-image: linear-gradient(
|
1305 |
+
270deg,
|
1306 |
+
rgba(0, 0, 0, 0.5),
|
1307 |
+
rgba(0, 0, 0, 0)
|
1308 |
+
);
|
1309 |
+
border-radius: 5px;
|
1310 |
+
box-shadow: inset 8px -4px 5px -7px rgba(0, 0, 0, 1),
|
1311 |
+
inset -8px -4px 5px -7px rgba(0, 0, 0, 1),
|
1312 |
+
0 -3px 8px -4px rgba(250, 250, 250, 0.4),
|
1313 |
+
inset 0 3px 4px -2px rgba(10, 10, 10, 1),
|
1314 |
+
inset 0 280px 40px -200px rgba(0, 0, 0, 0.2),
|
1315 |
+
inset 0 -200px 40px -200px rgba(180, 180, 180, 0.2);
|
1316 |
+
margin-top: 20px;
|
1317 |
+
}
|
1318 |
+
|
1319 |
+
input:checked + .power-button-background {
|
1320 |
+
background-image: linear-gradient(90deg, black 30%, transparent 65%),
|
1321 |
+
linear-gradient(
|
1322 |
+
180deg,
|
1323 |
+
rgba(250, 250, 250, 0) 0%,
|
1324 |
+
rgba(250, 250, 250, 0.4) 50%,
|
1325 |
+
rgba(150, 150, 150, 0) 100%
|
1326 |
+
);
|
1327 |
+
padding: 2px 4px;
|
1328 |
+
}
|
1329 |
+
|
1330 |
+
#info {
|
1331 |
+
position: fixed;
|
1332 |
+
left: 0;
|
1333 |
+
top: 0;
|
1334 |
+
right: 0;
|
1335 |
+
bottom: 0;
|
1336 |
+
background: none;
|
1337 |
+
transition: all 0.5s;
|
1338 |
+
z-index: 99;
|
1339 |
+
display: flex;
|
1340 |
+
flex-flow: row nowrap;
|
1341 |
+
align-items: center;
|
1342 |
+
justify-content: center;
|
1343 |
+
pointer-events: none;
|
1344 |
+
}
|
1345 |
+
|
1346 |
+
#info.active {
|
1347 |
+
pointer-events: auto;
|
1348 |
+
}
|
1349 |
+
|
1350 |
+
#info #info-content {
|
1351 |
+
opacity: 0;
|
1352 |
+
transition: opacity 0.5s;
|
1353 |
+
height: calc(100% - 4em);
|
1354 |
+
overflow-y: auto;
|
1355 |
+
}
|
1356 |
+
|
1357 |
+
#info.active #info-content {
|
1358 |
+
opacity: 1;
|
1359 |
+
pointer-events: auto;
|
1360 |
+
}
|
1361 |
+
|
1362 |
+
#info.active {
|
1363 |
+
background: rgba(0, 0, 0, 0.8);
|
1364 |
+
backdrop-filter: blur(5px);
|
1365 |
+
}
|
1366 |
+
|
1367 |
+
#info-button {
|
1368 |
+
position: absolute;
|
1369 |
+
top: 0;
|
1370 |
+
right: 0;
|
1371 |
+
width: 50px;
|
1372 |
+
height: 50px;
|
1373 |
+
line-height: 50px;
|
1374 |
+
cursor: pointer;
|
1375 |
+
font-family: "Meie Script", cursive;
|
1376 |
+
color: white;
|
1377 |
+
font-size: 50px;
|
1378 |
+
border: none;
|
1379 |
+
outline: none;
|
1380 |
+
background: none;
|
1381 |
+
opacity: 0.5;
|
1382 |
+
transition: opacity 0.2s;
|
1383 |
+
pointer-events: auto;
|
1384 |
+
z-index: 11;
|
1385 |
+
}
|
1386 |
+
|
1387 |
+
#info-button:hover {
|
1388 |
+
opacity: 1;
|
1389 |
+
}
|
1390 |
+
|
1391 |
+
#info-content {
|
1392 |
+
padding: 2em 4em;
|
1393 |
+
background-color: rgba(255,255,255,0.8);
|
1394 |
+
text-align: center;
|
1395 |
+
flex-flow: column nowrap;
|
1396 |
+
align-items: center;
|
1397 |
+
justify-content: center;
|
1398 |
+
z-index: 10;
|
1399 |
+
width: 800px;
|
1400 |
+
max-width: calc(100% - 4em);
|
1401 |
+
font-family: "Roboto";
|
1402 |
+
font-weight: 300;
|
1403 |
+
font-size: 15px;
|
1404 |
+
}
|
1405 |
+
|
1406 |
+
#info-content > * {
|
1407 |
+
text-align: left;
|
1408 |
+
}
|
1409 |
+
|
1410 |
+
#info-logo {
|
1411 |
+
margin-top: 0;
|
1412 |
+
font-size: 58px;
|
1413 |
+
font-family: "Meie Script", cursive;
|
1414 |
+
font-weight: 400;
|
1415 |
+
font-style: normal;
|
1416 |
+
text-align: center;
|
1417 |
+
}
|
1418 |
+
|
1419 |
+
#info-version {
|
1420 |
+
text-align: center;
|
1421 |
+
margin-top: -67px;
|
1422 |
+
font-weight: 300;
|
1423 |
+
font-size: 14px;
|
1424 |
+
padding-bottom: 15px;
|
1425 |
+
border-bottom: 1px solid rgba(0,0,0,0.8);
|
1426 |
+
}
|
1427 |
+
|
1428 |
+
#info cite {
|
1429 |
+
white-space: pre-wrap;
|
1430 |
+
display: block;
|
1431 |
+
font-style: normal;
|
1432 |
+
font-family: "Lekton", monospace;
|
1433 |
+
word-break: break-all;
|
1434 |
+
padding: 10px;
|
1435 |
+
background-color: rgba(0,0,0,0.8);
|
1436 |
+
color: white;
|
1437 |
+
margin-bottom: 5px;
|
1438 |
+
}
|
1439 |
+
|
1440 |
+
#github {
|
1441 |
+
position: fixed;
|
1442 |
+
bottom: 0px;
|
1443 |
+
right: 5px;
|
1444 |
+
opacity: 0.5;
|
1445 |
+
transition: opacity 0.2s;
|
1446 |
+
}
|
1447 |
+
|
1448 |
+
#github:hover {
|
1449 |
+
opacity: 1.0;
|
1450 |
+
}
|
1451 |
+
|
1452 |
+
.alert-container {
|
1453 |
+
position: fixed;
|
1454 |
+
pointer-events: none;
|
1455 |
+
top: 50px;
|
1456 |
+
right: 0;
|
1457 |
+
display: flex;
|
1458 |
+
flex-flow: column nowrap;
|
1459 |
+
z-index: 10;
|
1460 |
+
}
|
1461 |
+
|
1462 |
+
@keyframes alertIn {
|
1463 |
+
0% {
|
1464 |
+
opacity: 0;
|
1465 |
+
height: 0;
|
1466 |
+
margin-bottom: 0;
|
1467 |
+
}
|
1468 |
+
100% {
|
1469 |
+
opacity: 1;
|
1470 |
+
height: 100px;
|
1471 |
+
margin-bottom: 5px;
|
1472 |
+
}
|
1473 |
+
}
|
1474 |
+
|
1475 |
+
@keyframes alertOut {
|
1476 |
+
0% {
|
1477 |
+
opacity: 1;
|
1478 |
+
height: 100px;
|
1479 |
+
margin-bottom: 5px;
|
1480 |
+
}
|
1481 |
+
100% {
|
1482 |
+
opacity: 0;
|
1483 |
+
height: 0;
|
1484 |
+
margin-bottom: 0;
|
1485 |
+
}
|
1486 |
+
}
|
1487 |
+
|
1488 |
+
.alert {
|
1489 |
+
position: relative;
|
1490 |
+
display: block;
|
1491 |
+
animation: alertIn 1s ease-out;
|
1492 |
+
animation-fill-mode: forwards;
|
1493 |
+
width: 300px;
|
1494 |
+
background-color: rgba(242,222,222,0.75);
|
1495 |
+
backdrop-filter: blur(5px);
|
1496 |
+
color: #333;
|
1497 |
+
padding: 0;
|
1498 |
+
margin-bottom: 5px;
|
1499 |
+
border-radius: 5px;
|
1500 |
+
pointer-events: auto;
|
1501 |
+
border: 1px solid rgba(222,22,22,0.9);
|
1502 |
+
overflow: hidden;
|
1503 |
+
}
|
1504 |
+
|
1505 |
+
@keyframes alertProgress {
|
1506 |
+
0% {
|
1507 |
+
width: 100%;
|
1508 |
+
}
|
1509 |
+
100% {
|
1510 |
+
width: 0;
|
1511 |
+
}
|
1512 |
+
}
|
1513 |
+
|
1514 |
+
.alert .progress-bar {
|
1515 |
+
position: absolute;
|
1516 |
+
bottom: 0;
|
1517 |
+
left: 0;
|
1518 |
+
background-color: rgba(222,22,22,0.9);
|
1519 |
+
height: 5px;
|
1520 |
+
animation-name: alertProgress;
|
1521 |
+
animation-fill-mode: forwards;
|
1522 |
+
animation-timing-function: linear;
|
1523 |
+
}
|
1524 |
+
|
1525 |
+
.alert h2 {
|
1526 |
+
font-size: 16px;
|
1527 |
+
font-weight: 400;
|
1528 |
+
padding: 6px 10px;
|
1529 |
+
margin: 0;
|
1530 |
+
background-color: rgba(222,22,22,0.9);
|
1531 |
+
color: white;
|
1532 |
+
border-top-left-radius: 5px;
|
1533 |
+
border-top-right-radius: 5px;
|
1534 |
+
}
|
1535 |
+
|
1536 |
+
.alert p {
|
1537 |
+
padding: 10px;
|
1538 |
+
margin: 0;
|
1539 |
+
font-size: 12px;
|
1540 |
+
}
|
1541 |
+
|
1542 |
+
.alert.hiding {
|
1543 |
+
animation: alertOut 1s ease-out;
|
1544 |
+
animation-fill-mode: forwards;
|
1545 |
+
}
|
1546 |
+
|
1547 |
+
.alert button {
|
1548 |
+
position: absolute;
|
1549 |
+
top: 5px;
|
1550 |
+
right: 5px;
|
1551 |
+
background: none;
|
1552 |
+
border: none;
|
1553 |
+
outline: none;
|
1554 |
+
cursor: pointer;
|
1555 |
+
font-size: 30px;
|
1556 |
+
line-height: 20px;
|
1557 |
+
color: white;
|
1558 |
+
font-weight: 400;
|
1559 |
+
padding: 0;
|
1560 |
+
margin: 0;
|
1561 |
+
opacity: 0.8;
|
1562 |
+
}
|
1563 |
+
|
1564 |
+
.alert button:hover {
|
1565 |
+
opacity: 1;
|
1566 |
+
}
|
1567 |
+
|
1568 |
+
@media (orientation: portrait) {
|
1569 |
+
main {
|
1570 |
+
height: 1200px;
|
1571 |
+
width: 900px;
|
1572 |
+
grid-gap: 25px;
|
1573 |
+
padding: 25px;
|
1574 |
+
grid-template-rows: 1fr 1fr 1fr 1fr 1fr 1fr 1fr 1fr 1fr 1fr 1fr;
|
1575 |
+
grid-template-columns: 1fr 1fr 1fr 1fr;
|
1576 |
+
}
|
1577 |
+
|
1578 |
+
section#waveform {
|
1579 |
+
grid-area: 1 / 1 / 3 / 2;
|
1580 |
+
}
|
1581 |
+
|
1582 |
+
section#voice {
|
1583 |
+
grid-area: 1 / 3 / 4 / 5;
|
1584 |
+
}
|
1585 |
+
|
1586 |
+
section#brain {
|
1587 |
+
grid-area: 3 / 1 / 7 / 3;
|
1588 |
+
}
|
1589 |
+
|
1590 |
+
section#microphone {
|
1591 |
+
grid-area: 4 / 3 / 7 / 5;
|
1592 |
+
}
|
1593 |
+
|
1594 |
+
section#transcription {
|
1595 |
+
grid-area: 7 / 1 / 12 / 5;
|
1596 |
+
}
|
1597 |
+
|
1598 |
+
section#logo {
|
1599 |
+
top: -80px;
|
1600 |
+
color: white;
|
1601 |
+
font-size: 120px;
|
1602 |
+
}
|
1603 |
+
|
1604 |
+
section#credits {
|
1605 |
+
color: white;
|
1606 |
+
font-size: 20px;
|
1607 |
+
bottom: -95px;
|
1608 |
+
}
|
1609 |
+
#transcription .tool,
|
1610 |
+
#transcription .citation {
|
1611 |
+
font-size: 22px;
|
1612 |
+
}
|
1613 |
+
|
1614 |
+
section#credits .smaller {
|
1615 |
+
font-size: 15px;
|
1616 |
+
margin-top: 20px;
|
1617 |
+
line-height: 30px;
|
1618 |
+
}
|
1619 |
+
|
1620 |
+
div.bulb-indicator > label {
|
1621 |
+
right: -73px;
|
1622 |
+
}
|
1623 |
+
}
|
1624 |
+
|
1625 |
+
@media (max-width: 1200px) {
|
1626 |
+
main {
|
1627 |
+
transform: scale(0.68);
|
1628 |
+
}
|
1629 |
+
}
|
1630 |
+
|
1631 |
+
@media (max-width: 900px) {
|
1632 |
+
main {
|
1633 |
+
transform: scale(0.4);
|
1634 |
+
}
|
1635 |
+
#transcription #content {
|
1636 |
+
font-size: 38px;
|
1637 |
+
padding: 60px 90px 90px 100px;
|
1638 |
+
}
|
1639 |
+
#transcription .tool,
|
1640 |
+
#transcription .citation {
|
1641 |
+
font-size: 38px;
|
1642 |
+
}
|
1643 |
+
textarea, input {
|
1644 |
+
font-size: 38px;
|
1645 |
+
}
|
1646 |
+
}
|
1647 |
+
|
1648 |
+
@media (max-width: 900px) and (orientation: portrait) {
|
1649 |
+
main {
|
1650 |
+
height: 1500px;
|
1651 |
+
}
|
1652 |
+
section#waveform {
|
1653 |
+
margin-right: -60px;
|
1654 |
+
}
|
1655 |
+
div#power-switch .power-wrapper {
|
1656 |
+
top: -15px;
|
1657 |
+
}
|
1658 |
+
section#credits {
|
1659 |
+
font-size: 30px;
|
1660 |
+
bottom: -180px;
|
1661 |
+
}
|
1662 |
+
section#credits .smaller {
|
1663 |
+
font-size: 28px;
|
1664 |
+
margin-top: 50px;
|
1665 |
+
line-height: 30px;
|
1666 |
+
}
|
1667 |
+
}
|
www/index.html
ADDED
@@ -0,0 +1,227 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
<!DOCTYPE html>
|
2 |
+
<html lang="en">
|
3 |
+
<head>
|
4 |
+
<meta charset="UTF-8">
|
5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
6 |
+
<title>Anachrovox</title>
|
7 |
+
<!-- Cloud assets -->
|
8 |
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
9 |
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
10 |
+
<link href="https://fonts.googleapis.com/css2?family=Roboto:ital,wght@0,100;0,300;0,400;0,500;0,700;0,900;1,100;1,300;1,400;1,500;1,700;1,900&family=Meie+Script&family=Lekton:ital,wght@0,400&display=swap" rel="stylesheet">
|
11 |
+
<script src="https://cdn.jsdelivr.net/npm/onnxruntime-web@1.19.0/dist/ort.min.js"></script>
|
12 |
+
<script src="https://cdn.jsdelivr.net/npm/taproot-client@0.1.2/dist/taproot.min.js"></script>
|
13 |
+
<script src="https://cdn.jsdelivr.net/npm/hey-buddy-onnx@0.1.2/dist/hey-buddy.min.js"></script>
|
14 |
+
<!-- Local assets -->
|
15 |
+
<link rel="stylesheet" href="index.css">
|
16 |
+
</head>
|
17 |
+
<body>
|
18 |
+
<main>
|
19 |
+
<section id="transcription">
|
20 |
+
<div id="bezel"></div>
|
21 |
+
<div class="reflection dodge"></div>
|
22 |
+
<div class="reflection"></div>
|
23 |
+
<div id="content">
|
24 |
+
<div id="welcome">Welcome to Anachrovox. Speak your command to 'Vox' or type it in below.</div>
|
25 |
+
<div id="history"></div>
|
26 |
+
<div id="input">
|
27 |
+
<textarea id="prompt" rows=3></textarea>
|
28 |
+
</div>
|
29 |
+
</div>
|
30 |
+
</section>
|
31 |
+
<section id="waveform">
|
32 |
+
<div class="circle-reflection dodge"></div>
|
33 |
+
<div class="circle-reflection"></div>
|
34 |
+
<div class="oscilloscope-grid"></div>
|
35 |
+
<canvas width=256 height=256></canvas>
|
36 |
+
<div class="circle-shading"></div>
|
37 |
+
</section>
|
38 |
+
<section id="voice">
|
39 |
+
<input
|
40 |
+
id="voice-id"
|
41 |
+
class="solari-board"
|
42 |
+
type="text"
|
43 |
+
value=""
|
44 |
+
maxlength="7"
|
45 |
+
/>
|
46 |
+
<input
|
47 |
+
id="voice-id-wheel",
|
48 |
+
class="wheel"
|
49 |
+
type="button"
|
50 |
+
/>
|
51 |
+
<input
|
52 |
+
id="volume"
|
53 |
+
class="dial"
|
54 |
+
type="number"
|
55 |
+
min="0"
|
56 |
+
max="1"
|
57 |
+
step="0.01"
|
58 |
+
value="1.0"
|
59 |
+
data-angle-start="-45"
|
60 |
+
data-angle-end="45"
|
61 |
+
data-labels='{"0":"Off", "1":"Max"}'
|
62 |
+
/>
|
63 |
+
<input
|
64 |
+
id="speed"
|
65 |
+
class="dial"
|
66 |
+
type="number"
|
67 |
+
min="0.75"
|
68 |
+
max="1.75"
|
69 |
+
step="0.01"
|
70 |
+
value="1.25"
|
71 |
+
data-angle-start="-90"
|
72 |
+
data-angle-end="90"
|
73 |
+
data-labels='{"0.75":"Slw", "1.25":"Nrm", "1.75":"Fst"}'
|
74 |
+
/>
|
75 |
+
</section>
|
76 |
+
<section id="brain">
|
77 |
+
<input
|
78 |
+
id="top-k-display"
|
79 |
+
type="number"
|
80 |
+
class="seven-segment"
|
81 |
+
min=1
|
82 |
+
max=100
|
83 |
+
value=40
|
84 |
+
/>
|
85 |
+
<input
|
86 |
+
id="top-k"
|
87 |
+
class="dial decoupled"
|
88 |
+
type="number"
|
89 |
+
min=1
|
90 |
+
max=100
|
91 |
+
step=1
|
92 |
+
value=40
|
93 |
+
/>
|
94 |
+
<input
|
95 |
+
id="min-p"
|
96 |
+
class="slider"
|
97 |
+
type="number"
|
98 |
+
min=0
|
99 |
+
max=1
|
100 |
+
step=0.01
|
101 |
+
value=0.5
|
102 |
+
data-labels=6
|
103 |
+
/>
|
104 |
+
<input
|
105 |
+
id="top-p"
|
106 |
+
class="slider"
|
107 |
+
type="number"
|
108 |
+
min=0
|
109 |
+
max=1
|
110 |
+
step=0.01
|
111 |
+
value=0.95
|
112 |
+
data-labels=6
|
113 |
+
/>
|
114 |
+
<input
|
115 |
+
id="temperature"
|
116 |
+
class="slider"
|
117 |
+
type="number"
|
118 |
+
min=0
|
119 |
+
max=1
|
120 |
+
step=0.01
|
121 |
+
value=0.20
|
122 |
+
data-labels=6
|
123 |
+
/>
|
124 |
+
</section>
|
125 |
+
<section id="microphone">
|
126 |
+
<div id="listen-button">
|
127 |
+
<button id="listen"></button>
|
128 |
+
<label>Call</label>
|
129 |
+
</div>
|
130 |
+
<div id="speaker"></div>
|
131 |
+
<div id="power-switch">
|
132 |
+
<input
|
133 |
+
id="power-switch-input"
|
134 |
+
type="checkbox"
|
135 |
+
class="power"
|
136 |
+
checked
|
137 |
+
/>
|
138 |
+
</div>
|
139 |
+
<div class="bulb-indicator" id="recording"><label>RECORD</label></div>
|
140 |
+
<div class="bulb-indicator" id="listening"><label>LISTEN</label></div>
|
141 |
+
<div class="bulb-indicator" id="power"><label>POWER</label></div>
|
142 |
+
</section>
|
143 |
+
<section id="logo">
|
144 |
+
Anachrovox
|
145 |
+
</section>
|
146 |
+
<section id="credits">
|
147 |
+
Released under the <a href="https://www.apache.org/licenses/LICENSE-2.0.txt" target="_blank">Apache License v2.0</a> in 2024 by <a href="mailto:painebenjamin@gmail.com">Benjamin Paine</a>.
|
148 |
+
<div class="smaller">Anachrovox can make mistakes. Check all outputs.</div>
|
149 |
+
</section>
|
150 |
+
</main>
|
151 |
+
<aside id="info">
|
152 |
+
<button id="info-button">i</button>
|
153 |
+
<div id="info-content">
|
154 |
+
<h1 id="info-logo">Anachrovox</h1>
|
155 |
+
<h2 id="info-version">Alpha Release v0.1.1</h2>
|
156 |
+
<h3>Instructions</h3>
|
157 |
+
<p>To issue a voice command in a hands-free fashion, start your command with one of the supported wake phrases, then issue your command naturally (i.e. you do not need to pause.) There are many supported wake phrases but they all include 'Vox' - for example, 'Hey Vox', 'Hi Vox', or just 'Vox'.</p>
|
158 |
+
<p>There are numerous ways to shortcut the speech-to-speech workflow:</p>
|
159 |
+
<ul>
|
160 |
+
<li>Turning the volume all the way down will disable the text-to-speech step, effectively creating a text-only mode.</li>
|
161 |
+
<li>Directly type your query into the text box and press enter to issue commands without needing to speak.</li>
|
162 |
+
<li>Press and hold the call button to issue a voice command without needing to use a wake phrase.</li>
|
163 |
+
</ul>
|
164 |
+
<h3>About</h3>
|
165 |
+
<p>Anachrovox is a real-time voice assistant that combines two of my other projects:</p>
|
166 |
+
<ol>
|
167 |
+
<li><a href="https://github.com/painebenjamin/taproot" href="_blank">Taproot</a>, a scalable and lightning-fast task-centric backend inference engine, enabling easy installation and deployment of models for speech recognition, natural language understanding, and text-to-speech synthesis.</li>
|
168 |
+
<li><a href="https://github.com/painebenjamin/hey-buddy" href="_blank">Hey Buddy</a>, a real-time in-browser audio wake-word detector and training library to listen for wake phrases to trigger the assistant, enabling hands-free, always-on voice interaction without the need to use the backend until specifically requested.</li>
|
169 |
+
</ol>
|
170 |
+
<p>This is an <strong>alpha</strong> release of both Anachrovox and the underlying libraries, so bugs in all aspects of operation are expected. As it uses a large language model at it's heart, you should take the same precautions as you would with any other language model, such as not using it for sensitive tasks and not trusting it's output as fact without verification.</p>
|
171 |
+
<h3>High Availability</h3>
|
172 |
+
<p>In addition to Taproot's low-overhead design, it is designed to be clustered and highly available. This means that you can run multiple instances of Anachrovox and they will automatically load-balance and fail-over between each other, ensuring that the assistant is always available and responsive. You will need to perform some networking and configuration to enable this, see the GitHub repository for more information. If you are using Anachrovox on one of the official HuggingFace spaces, this is happening automatically between them.</p>
|
173 |
+
<h3>Features</h3>
|
174 |
+
<p>The main feature of Anachrovox is real-time, hands-free speech-to-speech large language model interaction. This is achieved through the following components, all of which are open-source and available for use in your own projects:</p>
|
175 |
+
<ol>
|
176 |
+
<li>Wake-word detection using <a href="https://github.com/painebenjamin/hey-buddy" target="_blank">Hey Buddy</a></li>
|
177 |
+
<li>Speech-to-text using <a href="https://huggingface.co/distil-whisper/distil-large-v3" target="_blank">Distil-Whisper Large</a></li>
|
178 |
+
<li>Text generation using <a href="https://huggingface.co/meta-llama/Llama-3.2-3B-Instruct" target="_blank">Llama 3.2 3B Instruct</a></li>
|
179 |
+
<li>Text-to-speech using <a href="https://coqui.ai/blog/tts/open_xtts" target="_blank">XTTS2</a></li>
|
180 |
+
<li>Audio enhancement using <a href="https://github.com/Rikorose/DeepFilterNet" target="_blank">DeepFilterNet</a></li>
|
181 |
+
</ol>
|
182 |
+
<p>All backend models are ran through Taproot, which is made to be as low-overhead as possible, allowing for real-time operation on consumer hardware. These are just a small selection of the supported model set, but were chosen for their balance of speed, size and capability. Visit <a href="https://github.com/painebenjamin/anachrovox" target="_blank">the Anachrovox GitHub</a> to see how to build with different supported components and/or visit <a href="https://github.com/painebenjamin/taproot" target="_blank">the Taproot GitHub</a> to request a new supported component or learn how to build your own (and hopefully contribute it back to the community!)</p>
|
183 |
+
<p>To improve the usefulness of the assistant, the following tools are available to use. These are invoked conversationally, either when you ask directly or sometimes when the assistant thinks it can help.</p>
|
184 |
+
<ul>
|
185 |
+
<li>DuckDuckGo news headlines, blurb search, and deep-dive.</li>
|
186 |
+
<li>Wikipedia search</li>
|
187 |
+
<li>Fandom (formerly Wikia) search</li>
|
188 |
+
<li>Date and time by timezone or location</li>
|
189 |
+
<li>Current and forecasted weather by location</li>
|
190 |
+
</ul>
|
191 |
+
<h3>Citations</h3>
|
192 |
+
<cite>@misc{gandhi2023distilwhisper,
|
193 |
+
title = {Distil-Whisper: Robust Knowledge Distillation via Large-Scale Pseudo Labelling},
|
194 |
+
author = {Sanchit Gandhi and Patrick von Platen and Alexander M. Rush},
|
195 |
+
year = {2023},
|
196 |
+
eprint = {2311.00430},
|
197 |
+
archivePrefix = {arXiv},
|
198 |
+
primaryClass = {cs.CL}
|
199 |
+
}</cite>
|
200 |
+
<cite>@misc{dubey2024llama3herdmodels,
|
201 |
+
title = {The Llama 3 Herd of Models},
|
202 |
+
author = {Llama Team, AI @ Meta},
|
203 |
+
year = {2024}
|
204 |
+
eprint = {2407.21783},
|
205 |
+
archivePrefix = {arXiv},
|
206 |
+
primaryClass = {cs.AI},
|
207 |
+
url = {https://arxiv.org/abs/2407.21783}
|
208 |
+
}</cite>
|
209 |
+
<cite>@misc{coqui2023xtts,
|
210 |
+
title = {XTTS: Open Model Release Announcement}
|
211 |
+
author = {Coqui AI}
|
212 |
+
year = {2023}
|
213 |
+
url = {https://coqui.ai/blog/tts/open_xtts}
|
214 |
+
}</cite>
|
215 |
+
<cite>@inproceedings{schroeter2023deepfilternet3,
|
216 |
+
title = {{DeepFilterNet}: Perceptually Motivated Real-Time Speech Enhancement},
|
217 |
+
author = {Schröter, Hendrik and Rosenkranz, Tobias and Escalante-B., Alberto N. and Maier, Andreas},
|
218 |
+
booktitle = {INTERSPEECH},
|
219 |
+
year = {2023},
|
220 |
+
}</cite>
|
221 |
+
</div>
|
222 |
+
</aside>
|
223 |
+
<a id="github" href="https://github.com/painebenjamin/anachrovox" target="_blank" title="GitHub"><img src="static/github.svg" alt="GitHub"></a>
|
224 |
+
</body>
|
225 |
+
<script type="module" src="inputs.js"></script>
|
226 |
+
<script type="module" src="index.js"></script>
|
227 |
+
</html>
|
www/index.js
ADDED
@@ -0,0 +1,550 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
/** @module index */
|
2 |
+
import { AudioPipeVisualizer } from "./visualizer.js";
|
3 |
+
import { GrowingSentenceChunker } from "./sentence.js";
|
4 |
+
import { sendAlert } from "./alert.js";
|
5 |
+
import { hexToRgb, replaceQuotes } from "./helpers.js";
|
6 |
+
|
7 |
+
// Global configuration
|
8 |
+
const documentStyle = window.getComputedStyle(document.body);
|
9 |
+
const primaryColor = documentStyle.getPropertyValue("--color-primary");
|
10 |
+
const [pR, pG, pB] = hexToRgb(primaryColor);
|
11 |
+
const [dpR, dpG, dpB] = [
|
12 |
+
Math.max(0, pR - 96),
|
13 |
+
Math.max(0, pG - 96),
|
14 |
+
Math.max(0, pB - 96),
|
15 |
+
];
|
16 |
+
const pollingInterval = 150;
|
17 |
+
const transcriptionParameters = {};
|
18 |
+
const languageParameters = {
|
19 |
+
max_tokens: 512,
|
20 |
+
role: "anachrovox",
|
21 |
+
stream: true,
|
22 |
+
use_tools: true,
|
23 |
+
return_tool_metadata: true,
|
24 |
+
};
|
25 |
+
const speechParameters = {
|
26 |
+
enhance: true,
|
27 |
+
stream: false,
|
28 |
+
stream_chunk_size: 25,
|
29 |
+
output_format: "float"
|
30 |
+
};
|
31 |
+
const waveformParameters = {
|
32 |
+
waveformNoiseLevel: 0.025,
|
33 |
+
fftSize: 512,
|
34 |
+
fillStyle: "rgba(8,16,14,0.3)",
|
35 |
+
strokeStyle: [
|
36 |
+
`rgba(${dpR},${dpG},${dpB},0.1)`,
|
37 |
+
`rgba(${pR},${pG},${pB},0.75)`,
|
38 |
+
"rgba(255,255,255,0.6)"
|
39 |
+
],
|
40 |
+
lineWidth: [6,3,1],
|
41 |
+
};
|
42 |
+
const speakerHoleRings = [ // radius, number of holes
|
43 |
+
[18, 6],
|
44 |
+
[36, 10],
|
45 |
+
[52, 14],
|
46 |
+
[70, 18],
|
47 |
+
[88, 22],
|
48 |
+
];
|
49 |
+
const maxTypingSpeed = 200; // characters per second
|
50 |
+
const minTypingSpeed = 50;
|
51 |
+
const maxDelay = 0.5; // max length to delay from completion to wait for speech to start generating
|
52 |
+
let overseerAddress;
|
53 |
+
|
54 |
+
if (window.location.port === "3000") {
|
55 |
+
// Development (e.g. npm start)
|
56 |
+
overseerAddress = "ws://localhost:32189";
|
57 |
+
} else {
|
58 |
+
// Docker or production
|
59 |
+
overseerAddress = "overseer";
|
60 |
+
}
|
61 |
+
const sharedModelRoot = "https://huggingface.co/benjamin-paine/hey-buddy/resolve/main/pretrained";
|
62 |
+
const wakeWordModelRoot = "https://huggingface.co/benjamin-paine/anachrovox/resolve/main";
|
63 |
+
const wakeWordPrefixes = [
|
64 |
+
"hello", "hey", "hi", "so","well",
|
65 |
+
"yo", "okay", "thanks", "thank-you",
|
66 |
+
];
|
67 |
+
const heyBuddyConfiguration = {
|
68 |
+
record: true,
|
69 |
+
modelPath: [
|
70 |
+
`${wakeWordModelRoot}/vox.onnx`,
|
71 |
+
`${wakeWordModelRoot}/anachrovox.onnx`
|
72 |
+
].concat(
|
73 |
+
wakeWordPrefixes.map(
|
74 |
+
(prefix) => `${wakeWordModelRoot}/${prefix}-vox.onnx`
|
75 |
+
)
|
76 |
+
),
|
77 |
+
vadModelPath: `${sharedModelRoot}/silero-vad.onnx`,
|
78 |
+
embeddingModelPath: `${sharedModelRoot}/speech-embedding.onnx`,
|
79 |
+
spectrogramModelPath: `${sharedModelRoot}/mel-spectrogram.onnx`,
|
80 |
+
wakeWordThreshold: 0.8,
|
81 |
+
};
|
82 |
+
|
83 |
+
// Get elements from the page
|
84 |
+
const transcriptionSection = document.querySelector("#transcription #content #history");
|
85 |
+
const waveformCanvas = document.querySelector("#waveform canvas");
|
86 |
+
const promptInput = document.getElementById("prompt");
|
87 |
+
const temperature = document.getElementById("temperature");
|
88 |
+
const topP = document.getElementById("top-p");
|
89 |
+
const minP = document.getElementById("min-p");
|
90 |
+
const topK = document.getElementById("top-k");
|
91 |
+
const topKDisplay = document.getElementById("top-k-display");
|
92 |
+
const voiceId = document.getElementById("voice-id");
|
93 |
+
const voiceIdWheel = document.getElementById("voice-id-wheel");
|
94 |
+
const speed = document.getElementById("speed");
|
95 |
+
const volume = document.getElementById("volume");
|
96 |
+
const speaker = document.getElementById("speaker");
|
97 |
+
const listening = document.getElementById("listening");
|
98 |
+
const recording = document.getElementById("recording");
|
99 |
+
const powerSwitch = document.getElementById("power-switch-input");
|
100 |
+
const listenButton = document.getElementById("listen");
|
101 |
+
const powerIndicator = document.getElementById("power");
|
102 |
+
|
103 |
+
// Build speaker hole (just cosmetic)
|
104 |
+
for (let [radius, holes] of speakerHoleRings) {
|
105 |
+
for (let i = 0; i < holes; i++) {
|
106 |
+
// Calculate hole position based on radius and angle
|
107 |
+
const hole = document.createElement("div");
|
108 |
+
const angle = i * 2 * Math.PI / holes;
|
109 |
+
const x = Math.cos(angle) * radius;
|
110 |
+
const y = Math.sin(angle) * radius;
|
111 |
+
hole.style.left = `${x}px`;
|
112 |
+
hole.style.top = `${y}px`;
|
113 |
+
hole.classList.add("hole");
|
114 |
+
speaker.appendChild(hole);
|
115 |
+
}
|
116 |
+
}
|
117 |
+
|
118 |
+
// Global objects
|
119 |
+
const client = new Taproot(overseerAddress);
|
120 |
+
const audio = new AudioPipeVisualizer({...waveformParameters, canvas: waveformCanvas});
|
121 |
+
const chunker = new GrowingSentenceChunker();
|
122 |
+
const conversationHistory = [];
|
123 |
+
|
124 |
+
// Scroll to the bottom of the transcription section
|
125 |
+
const scrollToBottom = () => {
|
126 |
+
if (transcriptionSection.parentElement.scrollHeight - transcriptionSection.parentElement.scrollTop - transcriptionSection.parentElement.offsetHeight < 80) {
|
127 |
+
transcriptionSection.parentElement.scrollTop = transcriptionSection.parentElement.scrollHeight;
|
128 |
+
}
|
129 |
+
}
|
130 |
+
|
131 |
+
// Helper methods for updating the page
|
132 |
+
const pushText = (text, className) => {
|
133 |
+
text = replaceQuotes(text);
|
134 |
+
const element = document.createElement("p");
|
135 |
+
element.classList.add(className);
|
136 |
+
element.textContent = text;
|
137 |
+
transcriptionSection.appendChild(element);
|
138 |
+
scrollToBottom();
|
139 |
+
return element;
|
140 |
+
};
|
141 |
+
|
142 |
+
// Bind voice ID wheel to change voice ID
|
143 |
+
// This is a localized list of voices from xtts2.
|
144 |
+
const voiceMap = {
|
145 |
+
"Aaron": "Aaron Dreschner",
|
146 |
+
"Abraham": "Abrahan Mack",
|
147 |
+
"Adam": "Adde Michal",
|
148 |
+
"Alexis": "Alexandra Hisakawa",
|
149 |
+
"Alexis": "Alma María",
|
150 |
+
"Alison": "Alison Dietlinde",
|
151 |
+
"Amy": "Asya Anara",
|
152 |
+
"Andrew": "Andrew Chipper",
|
153 |
+
"Anna": "Ana Florence",
|
154 |
+
"Annie": "Annmarie Nele",
|
155 |
+
"Barbara": "Barbora MacLean",
|
156 |
+
"Blake": "Baldur Sanjin",
|
157 |
+
"Brenda": "Brenda Stern",
|
158 |
+
"Brian": "Badr Odhiambo",
|
159 |
+
"Carla": "Camilla Holmström",
|
160 |
+
"Cindy": "Chandra MacFarland",
|
161 |
+
"Clara": "Claribel Dervla",
|
162 |
+
"Clark": "Kumar Dahl",
|
163 |
+
"Craig": "Craig Gutsy",
|
164 |
+
"Daisy": "Daisy Studious",
|
165 |
+
"Damien": "Damien Black",
|
166 |
+
"David": "Dionisio Schuyler",
|
167 |
+
"Dennis": "Damjan Chapman",
|
168 |
+
"Ella": "Uta Obando",
|
169 |
+
"Eugene": "Eugenio Mataracı",
|
170 |
+
"Frank": "Ferran Simen",
|
171 |
+
"Gilbert": "Gilberto Mathias",
|
172 |
+
"Gina": "Gitta Nikolina",
|
173 |
+
"Grace": "Gracie Wise",
|
174 |
+
"Heidi": "Henriette Usha",
|
175 |
+
"Ian": "Ige Behringer",
|
176 |
+
"Ivan": "Ilkin Urbano",
|
177 |
+
"Kevin": "Kazuhiko Atallah",
|
178 |
+
"Lily": "Lilya Stainthorpe",
|
179 |
+
"Louis": "Luis Moray",
|
180 |
+
"Lucas": "Ludvig Milivoj",
|
181 |
+
"Lydia": "Lidiya Szekeres",
|
182 |
+
"Marcus": "Marcos Rudaski",
|
183 |
+
"Maya": "Maja Ruoho",
|
184 |
+
"Nadia": "Narelle Moon",
|
185 |
+
"Nora": "Nova Hogarth",
|
186 |
+
"Philip": "Filip Traverse",
|
187 |
+
"Raymond": "Royston Min",
|
188 |
+
"Rose": "Rosemary Okafor",
|
189 |
+
"Saul": "Suad Qasim",
|
190 |
+
"Sofia": "Sofia Hellen",
|
191 |
+
"Sophie": "Szofi Granger",
|
192 |
+
"Tammy": "Tammie Ema",
|
193 |
+
"Tanya": "Tanja Adelina",
|
194 |
+
"Tara": "Tammy Grit",
|
195 |
+
"Thomas": "Torcull Diarmuid",
|
196 |
+
"Victor": "Viktor Eka",
|
197 |
+
"Victor": "Viktor Menelaos",
|
198 |
+
"Violet": "Vjollca Johnnie",
|
199 |
+
"Warren": "Wulf Carlevaro",
|
200 |
+
"Xavier": "Xavier Hayasaka",
|
201 |
+
"Zachary": "Zacharie Aimilios",
|
202 |
+
"Zoe": "Zofija Kendrick",
|
203 |
+
};
|
204 |
+
const voiceNames = Object.keys(voiceMap);
|
205 |
+
const voiceIds = Object.values(voiceMap);
|
206 |
+
let voiceIndex = -1;
|
207 |
+
const setVoiceIndex = (newIndex) => {
|
208 |
+
if (newIndex !== voiceIndex) {
|
209 |
+
voiceId.value = voiceNames[newIndex];
|
210 |
+
voiceId.dispatchEvent(new Event("change"));
|
211 |
+
voiceIndex = newIndex;
|
212 |
+
}
|
213 |
+
};
|
214 |
+
setVoiceIndex(0);
|
215 |
+
voiceIdWheel.addEventListener("click", () => {
|
216 |
+
let newVoiceIndex = voiceIndex + parseInt(voiceIdWheel.value);
|
217 |
+
if (newVoiceIndex < 0) newVoiceIndex = voiceIds.length - 1;
|
218 |
+
setVoiceIndex(newVoiceIndex % voiceIds.length);
|
219 |
+
});
|
220 |
+
|
221 |
+
// Bind volume to update the audio volume
|
222 |
+
volume.addEventListener("change", (event) => {
|
223 |
+
audio.volume = volume.value;
|
224 |
+
});
|
225 |
+
|
226 |
+
// Bind top-k to update the display
|
227 |
+
topK.addEventListener("change", (event) => {
|
228 |
+
topKDisplay.value = Math.floor(topK.value);
|
229 |
+
topKDisplay.dispatchEvent(new Event("change"));
|
230 |
+
});
|
231 |
+
|
232 |
+
// Getter functions for parameters
|
233 |
+
const getLanguageParameters = (overrides = {}) => {
|
234 |
+
return {
|
235 |
+
...languageParameters,
|
236 |
+
history: conversationHistory,
|
237 |
+
top_k: parseInt(topK.value),
|
238 |
+
top_p: parseFloat(topP.value),
|
239 |
+
min_p: parseFloat(minP.value),
|
240 |
+
temperature: parseFloat(temperature.value),
|
241 |
+
...overrides,
|
242 |
+
};
|
243 |
+
};
|
244 |
+
const getSpeechParameters = (overrides = {}) => {
|
245 |
+
return {
|
246 |
+
...speechParameters,
|
247 |
+
speed: parseFloat(speed.value),
|
248 |
+
speaker_id: voiceMap[voiceId.value],
|
249 |
+
...overrides,
|
250 |
+
};
|
251 |
+
};
|
252 |
+
|
253 |
+
let typingElement,
|
254 |
+
typingStart,
|
255 |
+
typingCharactersPerSecond = minTypingSpeed,
|
256 |
+
typingTarget = "",
|
257 |
+
typingAudioTiming = {},
|
258 |
+
unsetWhenComplete = false,
|
259 |
+
requestNumber = 0,
|
260 |
+
interrupt = false;
|
261 |
+
|
262 |
+
// The loop for typing out the text
|
263 |
+
const typingLoop = () => {
|
264 |
+
if (typingElement !== null && typingElement !== undefined) {
|
265 |
+
const now = performance.now();
|
266 |
+
const typingIndex = Math.floor((now - typingStart) * typingCharactersPerSecond / 1000);
|
267 |
+
const targetTextLength = typingTarget.length;
|
268 |
+
|
269 |
+
let typingAudioIndex = 0;
|
270 |
+
let i = 0;
|
271 |
+
let hasAudio = Object.getOwnPropertyNames(typingAudioTiming).length > 0;
|
272 |
+
|
273 |
+
for (let [audioTime, [audioTextLength, audioDuration]] of Object.entries(typingAudioTiming)) {
|
274 |
+
audioTime = parseFloat(audioTime);
|
275 |
+
if (now >= audioTime + audioDuration) {
|
276 |
+
// Audio has finished playing
|
277 |
+
typingAudioIndex += audioTextLength;
|
278 |
+
} else if (now >= audioTime) {
|
279 |
+
// Currently playing audio
|
280 |
+
typingAudioIndex += Math.floor((now - audioTime) * audioTextLength / audioDuration);
|
281 |
+
}
|
282 |
+
i++;
|
283 |
+
}
|
284 |
+
|
285 |
+
if (!interrupt && (typingIndex < targetTextLength || ((audio.volume > 0 || hasAudio) && typingAudioIndex < targetTextLength))) {
|
286 |
+
let innerHTML = "";
|
287 |
+
if (typingAudioIndex > 0) {
|
288 |
+
innerHTML += `<span class="spoken">${typingTarget.substring(0, typingAudioIndex + 1)}</span>`;
|
289 |
+
innerHTML += `<span class="unspoken">${typingTarget.substring(typingAudioIndex + 1, typingIndex)}</span>`;
|
290 |
+
} else {
|
291 |
+
innerHTML += `<span class="unspoken">${typingTarget.substring(0, typingIndex)}</span>`;
|
292 |
+
}
|
293 |
+
if (typingIndex < targetTextLength) {
|
294 |
+
innerHTML += `<span class="cursor">|</span>`;
|
295 |
+
}
|
296 |
+
if (typingElement.innerHTML != innerHTML) {
|
297 |
+
typingElement.innerHTML = innerHTML;
|
298 |
+
scrollToBottom();
|
299 |
+
}
|
300 |
+
} else if (interrupt || unsetWhenComplete) {
|
301 |
+
let finalHTML;
|
302 |
+
if (hasAudio) {
|
303 |
+
finalHTML = `<span class="spoken">${typingTarget}</span>`;
|
304 |
+
} else {
|
305 |
+
finalHTML = `<span class="unspoken">${typingTarget}</span>`;
|
306 |
+
}
|
307 |
+
typingElement.innerHTML = finalHTML;
|
308 |
+
unsetWhenComplete = false;
|
309 |
+
interrupt = false;
|
310 |
+
typingElement = null;
|
311 |
+
typingTarget = "";
|
312 |
+
typingAudioTiming = {};
|
313 |
+
}
|
314 |
+
}
|
315 |
+
requestAnimationFrame(typingLoop);
|
316 |
+
};
|
317 |
+
requestAnimationFrame(typingLoop);
|
318 |
+
|
319 |
+
// Callback for when a sentence is completed
|
320 |
+
chunker.onChunk(async (chunk) => {
|
321 |
+
let isFirst = false;
|
322 |
+
let requestNumberAtStart = requestNumber;
|
323 |
+
if (typingElement !== null && typingElement !== undefined) {
|
324 |
+
typingTarget += replaceQuotes(chunk).replaceAll(/\n\W*/g, "\n");
|
325 |
+
} else {
|
326 |
+
isFirst = true;
|
327 |
+
typingElement = pushText("", "completion");
|
328 |
+
typingTarget = replaceQuotes(chunk).replaceAll(/\n\W*/g, "\n");
|
329 |
+
typingStart = performance.now();
|
330 |
+
typingAudioTiming = {};
|
331 |
+
}
|
332 |
+
|
333 |
+
if (audio.volume > 0 && !interrupt) {
|
334 |
+
typingCharactersPerSecond = minTypingSpeed;
|
335 |
+
let audioEndTypingIndex = typingTarget.length;
|
336 |
+
let audioResult = await client.invoke({
|
337 |
+
task: "speech-synthesis",
|
338 |
+
parameters: getSpeechParameters({text: chunk}),
|
339 |
+
});
|
340 |
+
if (interrupt || requestNumberAtStart !== requestNumber) {
|
341 |
+
return;
|
342 |
+
}
|
343 |
+
if (audio.playing) {
|
344 |
+
audio.pushSilence(0.15);
|
345 |
+
}
|
346 |
+
|
347 |
+
let audioReady = performance.now();
|
348 |
+
let audioNode = audio.push(audioResult.data);
|
349 |
+
let audioDuration = audioNode.buffer.duration * 1000;
|
350 |
+
|
351 |
+
if (isFirst) {
|
352 |
+
typingAudioTiming[audioReady] = [chunk.length, audioDuration];
|
353 |
+
} else {
|
354 |
+
// Queue the audio speed for the next sentence
|
355 |
+
let lastAudioStartTime = Math.max(...Object.keys(typingAudioTiming));
|
356 |
+
let [lastAudioLength, lastAudioDuration] = typingAudioTiming[lastAudioStartTime];
|
357 |
+
let thisAudioTiming = Math.max(lastAudioStartTime + lastAudioDuration + (isFirst ? 0 : 0.15), audioReady);
|
358 |
+
typingAudioTiming[thisAudioTiming] = [chunk.length, audioDuration];
|
359 |
+
}
|
360 |
+
} else {
|
361 |
+
typingCharactersPerSecond = maxTypingSpeed;
|
362 |
+
}
|
363 |
+
|
364 |
+
});
|
365 |
+
|
366 |
+
// Callback when transcription and completion are done
|
367 |
+
const finalizeResult = (prompt, result) => {
|
368 |
+
interrupt = false;
|
369 |
+
unsetWhenComplete = true;
|
370 |
+
chunker.push(result.result);
|
371 |
+
chunker.flush();
|
372 |
+
conversationHistory.push(prompt);
|
373 |
+
conversationHistory.push(result.result);
|
374 |
+
|
375 |
+
if (result.function) {
|
376 |
+
let usedToolContainer = document.createElement("p");
|
377 |
+
usedToolContainer.classList.add("tool");
|
378 |
+
usedToolContainer.innerText = "Used tool: ";
|
379 |
+
let usedToolFunction = document.createElement("span");
|
380 |
+
usedToolFunction.innerText = result.function.name;
|
381 |
+
usedToolFunction.title = result.function.arguments;
|
382 |
+
usedToolContainer.appendChild(usedToolFunction);
|
383 |
+
transcriptionSection.appendChild(usedToolContainer);
|
384 |
+
if (result.citations) {
|
385 |
+
for (let i = 0; i < result.citations.length; i++) {
|
386 |
+
let citation = result.citations[i];
|
387 |
+
let citationContainer = document.createElement("p");
|
388 |
+
citationContainer.classList.add("citation");
|
389 |
+
let citationLabel = citation.title
|
390 |
+
? citation.title
|
391 |
+
: citation.source
|
392 |
+
? citation.source
|
393 |
+
: "";
|
394 |
+
if (citationLabel) {
|
395 |
+
citationContainer.innerText = `${citationLabel} `;
|
396 |
+
} else {
|
397 |
+
citationContainer.innerText = "Source ";
|
398 |
+
}
|
399 |
+
let citationLink = document.createElement("a");
|
400 |
+
citationLink.href = citation.url;
|
401 |
+
citationLink.innerText = `[${i + 1}]`;
|
402 |
+
citationLink.title = citation.url;
|
403 |
+
citationLink.target = "_blank";
|
404 |
+
citationContainer.appendChild(citationLink);
|
405 |
+
transcriptionSection.appendChild(citationContainer);
|
406 |
+
}
|
407 |
+
}
|
408 |
+
}
|
409 |
+
};
|
410 |
+
|
411 |
+
// Create a function to invoke the appropriate workflow based on the current state
|
412 |
+
const invokeFromMicrophone = async (samples) => {
|
413 |
+
requestNumber++;
|
414 |
+
interrupt = true;
|
415 |
+
let prompt;
|
416 |
+
try {
|
417 |
+
const textResult = await client.invoke(
|
418 |
+
{
|
419 |
+
task: "audio-transcription",
|
420 |
+
parameters: {audio: samples},
|
421 |
+
continuation: {
|
422 |
+
task: "text-generation",
|
423 |
+
parameters: getLanguageParameters(),
|
424 |
+
result_parameters: "prompt",
|
425 |
+
}
|
426 |
+
},
|
427 |
+
{
|
428 |
+
fetchIntermediates: true,
|
429 |
+
pollingInterval: pollingInterval,
|
430 |
+
onInterimResult: (result) => {
|
431 |
+
prompt = result;
|
432 |
+
pushText(result, "transcription");
|
433 |
+
},
|
434 |
+
onIntermediateResult: (result) => {
|
435 |
+
interrupt = false;
|
436 |
+
chunker.push(result);
|
437 |
+
}
|
438 |
+
}
|
439 |
+
);
|
440 |
+
finalizeResult(prompt, textResult);
|
441 |
+
} catch (error) {
|
442 |
+
console.error(error);
|
443 |
+
sendAlert(error);
|
444 |
+
}
|
445 |
+
};
|
446 |
+
const invokeFromPrompt = async (text) => {
|
447 |
+
requestNumber++;
|
448 |
+
interrupt = true;
|
449 |
+
pushText(text, "transcription");
|
450 |
+
try {
|
451 |
+
const inferenceResult = await client.invoke(
|
452 |
+
{
|
453 |
+
task: "text-generation",
|
454 |
+
parameters: getLanguageParameters({prompt: text}),
|
455 |
+
},
|
456 |
+
{
|
457 |
+
fetchIntermediates: true,
|
458 |
+
pollingInterval: pollingInterval,
|
459 |
+
onIntermediateResult: (result) => {
|
460 |
+
interrupt = false;
|
461 |
+
chunker.push(result);
|
462 |
+
}
|
463 |
+
}
|
464 |
+
);
|
465 |
+
finalizeResult(text, inferenceResult);
|
466 |
+
} catch (error) {
|
467 |
+
console.error(error);
|
468 |
+
sendAlert(error);
|
469 |
+
}
|
470 |
+
};
|
471 |
+
|
472 |
+
// Configure power button to disable everything
|
473 |
+
powerSwitch.addEventListener("change", (event) => {
|
474 |
+
if (powerSwitch.checked) {
|
475 |
+
powerIndicator.classList.add("active");
|
476 |
+
} else {
|
477 |
+
powerIndicator.classList.remove("active");
|
478 |
+
listening.classList.remove("active");
|
479 |
+
recording.classList.remove("active");
|
480 |
+
}
|
481 |
+
});
|
482 |
+
powerSwitch.dispatchEvent(new Event("change"));
|
483 |
+
|
484 |
+
// Configure HeyBuddy for audio recording and invocation
|
485 |
+
if (!window.HeyBuddy) {
|
486 |
+
console.error("HeyBuddy not found. Please include HeyBuddy.js in your project.");
|
487 |
+
} else {
|
488 |
+
const heyBuddy = new window.HeyBuddy(heyBuddyConfiguration);
|
489 |
+
heyBuddy.onProcessed(async (result) => {
|
490 |
+
let highestWakeWord = null, highestProbability = 0;
|
491 |
+
for (let wakewordName in result.wakeWords) {
|
492 |
+
let probability = result.wakeWords[wakewordName].probability;
|
493 |
+
if (probability > highestProbability) {
|
494 |
+
highestWakeWord = wakewordName;
|
495 |
+
highestProbability = probability;
|
496 |
+
}
|
497 |
+
}
|
498 |
+
});
|
499 |
+
heyBuddy.onRecording(async (samples) => {
|
500 |
+
if (powerSwitch.checked) {
|
501 |
+
await invokeFromMicrophone(samples);
|
502 |
+
}
|
503 |
+
});
|
504 |
+
heyBuddy.onProcessed((result) => {
|
505 |
+
if (powerSwitch.checked) {
|
506 |
+
if (result.recording) {
|
507 |
+
recording.classList.add("active");
|
508 |
+
} else {
|
509 |
+
recording.classList.remove("active");
|
510 |
+
}
|
511 |
+
if (result.listening) {
|
512 |
+
listening.classList.add("active");
|
513 |
+
} else {
|
514 |
+
listening.classList.remove("active");
|
515 |
+
}
|
516 |
+
}
|
517 |
+
});
|
518 |
+
const startEvents = ["mousedown", "touchstart"];
|
519 |
+
const stopEvents = ["mouseup", "touchend", "mouseleave"];
|
520 |
+
const startListening = () => {
|
521 |
+
const interval = setInterval(() => {
|
522 |
+
heyBuddy.negatives = 0;
|
523 |
+
heyBuddy.listening = true;
|
524 |
+
heyBuddy.recording = true;
|
525 |
+
}, 10);
|
526 |
+
const onStop = () => {
|
527 |
+
clearInterval(interval);
|
528 |
+
for (let event of stopEvents) {
|
529 |
+
window.removeEventListener(event, onStop);
|
530 |
+
}
|
531 |
+
}
|
532 |
+
for (let event of stopEvents) {
|
533 |
+
window.addEventListener(event, onStop);
|
534 |
+
}
|
535 |
+
};
|
536 |
+
for (let event of startEvents) {
|
537 |
+
listenButton.addEventListener(event, startListening);
|
538 |
+
}
|
539 |
+
}
|
540 |
+
|
541 |
+
// Bind the prompt input to the workflow
|
542 |
+
promptInput.addEventListener("keypress", async (event) => {
|
543 |
+
if (event.key === "Enter") {
|
544 |
+
event.preventDefault();
|
545 |
+
const text = promptInput.value;
|
546 |
+
// Wait a tick so the invocation doesn't send the new prompt as history
|
547 |
+
promptInput.value = "";
|
548 |
+
await invokeFromPrompt(text);
|
549 |
+
}
|
550 |
+
});
|
www/inputs.js
ADDED
@@ -0,0 +1,598 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import {
|
2 |
+
isEmpty,
|
3 |
+
bindPointerDrag
|
4 |
+
} from "./helpers.js";
|
5 |
+
|
6 |
+
// power buttons
|
7 |
+
const powerButtons = document.querySelectorAll("input.power");
|
8 |
+
powerButtons.forEach((button) => {
|
9 |
+
// Set name if not set
|
10 |
+
button.name = button.name || button.id;
|
11 |
+
|
12 |
+
// Wrap input in div
|
13 |
+
const wrapper = document.createElement("div");
|
14 |
+
wrapper.classList.add("power-wrapper");
|
15 |
+
wrapper.id = `wrapper-${button.id}`;
|
16 |
+
button.parentNode.insertBefore(wrapper, button);
|
17 |
+
wrapper.appendChild(button);
|
18 |
+
|
19 |
+
// Add button elements
|
20 |
+
const powerButton = document.createElement("div");
|
21 |
+
powerButton.classList.add("power-button-background");
|
22 |
+
const label = document.createElement("label");
|
23 |
+
label.classList.add("power-button");
|
24 |
+
label.htmlFor = button.id;
|
25 |
+
const on = document.createElement("span");
|
26 |
+
on.classList.add("on");
|
27 |
+
on.innerText = "0";
|
28 |
+
const off = document.createElement("span");
|
29 |
+
off.classList.add("off");
|
30 |
+
off.innerText = "I";
|
31 |
+
label.appendChild(on);
|
32 |
+
label.appendChild(off);
|
33 |
+
powerButton.appendChild(label);
|
34 |
+
wrapper.appendChild(powerButton);
|
35 |
+
});
|
36 |
+
|
37 |
+
// dials/knobs
|
38 |
+
const dials = document.querySelectorAll("input.dial");
|
39 |
+
dials.forEach((dial) => {
|
40 |
+
// Wrap input in div
|
41 |
+
const wrapper = document.createElement("div");
|
42 |
+
const dialContainer = document.createElement("div");
|
43 |
+
const dialElement = document.createElement("div");
|
44 |
+
const specularElement = document.createElement("div");
|
45 |
+
|
46 |
+
wrapper.classList.add("dial-wrapper");
|
47 |
+
wrapper.id = `wrapper-${dial.id}`;
|
48 |
+
dialContainer.classList.add("dial");
|
49 |
+
dialElement.classList.add("dial-element");
|
50 |
+
specularElement.classList.add("dial-specular");
|
51 |
+
dial.parentNode.insertBefore(wrapper, dial);
|
52 |
+
wrapper.appendChild(dialContainer);
|
53 |
+
dialContainer.appendChild(dial);
|
54 |
+
dialContainer.appendChild(specularElement);
|
55 |
+
dialContainer.appendChild(dialElement);
|
56 |
+
|
57 |
+
// Get range values
|
58 |
+
const rangeStartValue = isEmpty(dial.min) ? 0 : parseFloat(dial.min);
|
59 |
+
const rangeStartAngle = isEmpty(dial.dataset.angleStart) ? null : parseFloat(dial.dataset.angleStart);
|
60 |
+
const rangeEndValue = isEmpty(dial.max) ? 100 : parseFloat(dial.max);
|
61 |
+
const rangeEndAngle = isEmpty(dial.dataset.angleEnd) ? null : parseInt(dial.dataset.angleEnd);
|
62 |
+
const rangeDegrees = rangeEndAngle === null || rangeStartAngle === null ? Infinity : rangeEndAngle - rangeStartAngle;
|
63 |
+
|
64 |
+
// Add labels if configured
|
65 |
+
const labels = isEmpty(dial.dataset.labels) ? null : JSON.parse(dial.dataset.labels);
|
66 |
+
if (labels !== null) {
|
67 |
+
for (let [value, text] of Object.entries(labels)) {
|
68 |
+
value = parseFloat(value);
|
69 |
+
const label = document.createElement("div");
|
70 |
+
const valueDegrees = rangeDegrees === Infinity
|
71 |
+
? (value - rangeStartValue) / (rangeEndValue - rangeStartValue) * 360
|
72 |
+
: (value - rangeStartValue) / (rangeEndValue - rangeStartValue) * rangeDegrees + rangeStartAngle;
|
73 |
+
let valueDegreesCorrected = valueDegrees;
|
74 |
+
while (valueDegreesCorrected < 0) {
|
75 |
+
valueDegreesCorrected += 360;
|
76 |
+
}
|
77 |
+
label.classList.add("value");
|
78 |
+
if (valueDegreesCorrected < 90) {
|
79 |
+
label.classList.add("top");
|
80 |
+
} else if (valueDegreesCorrected < 180) {
|
81 |
+
label.classList.add("right");
|
82 |
+
} else if (valueDegreesCorrected < 270) {
|
83 |
+
label.classList.add("bottom");
|
84 |
+
} else {
|
85 |
+
label.classList.add("left");
|
86 |
+
}
|
87 |
+
label.style.transform = `rotate(${valueDegrees}deg)`;
|
88 |
+
label.innerHTML = `<span>${text}</span>`;
|
89 |
+
dialContainer.appendChild(label);
|
90 |
+
}
|
91 |
+
}
|
92 |
+
|
93 |
+
// Bind dial input
|
94 |
+
const getCurrentRotation = () => parseInt(dialElement.style.transform.replace("rotate(", "").replace("deg)", "")) % 360;
|
95 |
+
const getCenterPoint = () => {
|
96 |
+
const rect = dialElement.getBoundingClientRect();
|
97 |
+
return {
|
98 |
+
x: rect.left + rect.width / 2,
|
99 |
+
y: rect.top + rect.height / 2
|
100 |
+
};
|
101 |
+
}
|
102 |
+
|
103 |
+
// Set initial rotation
|
104 |
+
let initialValue = isEmpty(dial.value) ? rangeStartValue : parseFloat(dial.value);
|
105 |
+
let initialAngle = rangeStartAngle === null || rangeDegrees === Infinity
|
106 |
+
? rangeStartAngle || 0
|
107 |
+
: (initialValue - rangeStartValue) / (rangeEndValue - rangeStartValue) * rangeDegrees + rangeStartAngle;
|
108 |
+
|
109 |
+
let startAngle;
|
110 |
+
let startValue;
|
111 |
+
let lastAngle;
|
112 |
+
let fullRotations = 0;
|
113 |
+
|
114 |
+
if (initialAngle !== null) {
|
115 |
+
dialElement.style.transform = `rotate(${initialAngle}deg)`;
|
116 |
+
}
|
117 |
+
|
118 |
+
// Input events
|
119 |
+
bindPointerDrag(
|
120 |
+
wrapper,
|
121 |
+
(e) => { // Start
|
122 |
+
wrapper.classList.add("active");
|
123 |
+
document.body.style.userSelect = "none";
|
124 |
+
document.body.style.cursor = "grabbing";
|
125 |
+
// Parse current rotation
|
126 |
+
startAngle = getCurrentRotation();
|
127 |
+
startValue = parseFloat(dial.value);
|
128 |
+
fullRotations = 0;
|
129 |
+
},
|
130 |
+
(e) => { // Move
|
131 |
+
const center = getCenterPoint();
|
132 |
+
const delta = {
|
133 |
+
x: e.current.x - center.x,
|
134 |
+
y: e.current.y - center.y
|
135 |
+
};
|
136 |
+
const angle = Math.atan2(delta.y, delta.x) * (180 / Math.PI) + 90;
|
137 |
+
if (angle < 0 && lastAngle > 0 || angle > 0 && lastAngle < 0) {
|
138 |
+
// Likely a rotation, check if the angles are close to 360 degrees apart
|
139 |
+
if (Math.abs(angle - lastAngle) > 345) {
|
140 |
+
fullRotations += angle > 0 ? -1 : 1;
|
141 |
+
}
|
142 |
+
}
|
143 |
+
const rotationCorrectedAngle = angle + (fullRotations * 360);
|
144 |
+
const boundedAngle = Math.min(
|
145 |
+
Math.max(
|
146 |
+
rotationCorrectedAngle,
|
147 |
+
rangeStartAngle === null
|
148 |
+
? rotationCorrectedAngle
|
149 |
+
: rangeStartAngle
|
150 |
+
),
|
151 |
+
rangeEndAngle === null
|
152 |
+
? rotationCorrectedAngle
|
153 |
+
: rangeEndAngle
|
154 |
+
);
|
155 |
+
const deltaDegrees = boundedAngle - startAngle;
|
156 |
+
if (rangeDegrees !== Infinity) {
|
157 |
+
const valueDelta = (rangeEndValue - rangeStartValue) * (deltaDegrees / rangeDegrees);
|
158 |
+
const newValue = Math.min(
|
159 |
+
Math.max(
|
160 |
+
rangeStartValue,
|
161 |
+
startValue + valueDelta
|
162 |
+
),
|
163 |
+
rangeEndValue
|
164 |
+
);
|
165 |
+
dial.value = newValue;
|
166 |
+
} else if(rangeStartValue !== null && rangeEndValue !== null) {
|
167 |
+
const valueDelta = (rangeEndValue - rangeStartValue) * (deltaDegrees / 360);
|
168 |
+
const newValue = Math.min(
|
169 |
+
Math.max(
|
170 |
+
rangeStartValue,
|
171 |
+
startValue + valueDelta
|
172 |
+
),
|
173 |
+
rangeEndValue
|
174 |
+
);
|
175 |
+
dial.value = newValue;
|
176 |
+
} else {
|
177 |
+
dial.value = boundedAngle;
|
178 |
+
}
|
179 |
+
lastAngle = angle;
|
180 |
+
dialElement.style.transform = `rotate(${boundedAngle}deg)`;
|
181 |
+
dial.dispatchEvent(new Event("change"));
|
182 |
+
},
|
183 |
+
(e) => { // End
|
184 |
+
wrapper.classList.remove("active");
|
185 |
+
document.body.style.userSelect = "";
|
186 |
+
document.body.style.cursor = "";
|
187 |
+
}
|
188 |
+
);
|
189 |
+
});
|
190 |
+
|
191 |
+
// solari boards
|
192 |
+
const solariValues = [
|
193 |
+
"0", "1", "2", "3", "4", "5", "6", "7", "8", "9",
|
194 |
+
"A", "B", "C", "D", "E", "F", "G", "H", "I", "J",
|
195 |
+
"K", "L", "M", "N", "O", "P", "Q", "R", "S", "T",
|
196 |
+
"U", "V", "W", "X", "Y", "Z", " ", "-", ".", "/"
|
197 |
+
];
|
198 |
+
const solariTransitionTime = 20;
|
199 |
+
const solariBoards = document.querySelectorAll("input.solari-board");
|
200 |
+
solariBoards.forEach((board) => {
|
201 |
+
// Grab data from input
|
202 |
+
const initialValue = board.value;
|
203 |
+
const maxLength = board.maxLength ? parseInt(board.maxLength) : initialValue.length;
|
204 |
+
let valueNumber = 0;
|
205 |
+
|
206 |
+
// Wrap input in div
|
207 |
+
const wrapper = document.createElement("div");
|
208 |
+
wrapper.classList.add("solari-board-wrapper");
|
209 |
+
wrapper.id = `wrapper-${board.id}`;
|
210 |
+
board.parentNode.insertBefore(wrapper, board);
|
211 |
+
wrapper.appendChild(board);
|
212 |
+
|
213 |
+
const boardContainer = document.createElement("div");
|
214 |
+
boardContainer.classList.add("solari-board");
|
215 |
+
wrapper.appendChild(boardContainer);
|
216 |
+
|
217 |
+
for (let i = 0; i < maxLength; i++) {
|
218 |
+
const boardElementContainer = document.createElement("div");
|
219 |
+
const boardElementTop = document.createElement("div");
|
220 |
+
const boardElementBottom = document.createElement("div");
|
221 |
+
const boardElementFlap = document.createElement("div");
|
222 |
+
boardElementContainer.classList.add("solari-board-element-container");
|
223 |
+
boardElementTop.classList.add("solari-board-element-top");
|
224 |
+
boardElementBottom.classList.add("solari-board-element-bottom");
|
225 |
+
boardElementFlap.classList.add("solari-board-element-flap");
|
226 |
+
boardElementFlap.style.display = "none";
|
227 |
+
|
228 |
+
const boardElementValue = initialValue[i] || " ";
|
229 |
+
boardElementTop.innerHTML = boardElementValue;
|
230 |
+
boardElementBottom.innerHTML = boardElementValue;
|
231 |
+
boardElementContainer.appendChild(boardElementTop);
|
232 |
+
boardElementContainer.appendChild(boardElementBottom);
|
233 |
+
boardElementContainer.appendChild(boardElementFlap);
|
234 |
+
boardContainer.appendChild(boardElementContainer);
|
235 |
+
}
|
236 |
+
|
237 |
+
const transitionElement = async (index, value) => {
|
238 |
+
const transitionNumber = valueNumber;
|
239 |
+
const element = boardContainer.children[index];
|
240 |
+
const top = element.children[0];
|
241 |
+
const bottom = element.children[1];
|
242 |
+
const flap = element.children[2];
|
243 |
+
let startValue = top.innerText;
|
244 |
+
if (startValue === "") {
|
245 |
+
startValue = " ";
|
246 |
+
}
|
247 |
+
if (value === "") {
|
248 |
+
value = " ";
|
249 |
+
}
|
250 |
+
if (startValue === value) {
|
251 |
+
return;
|
252 |
+
}
|
253 |
+
const setValue = (value) => {
|
254 |
+
return new Promise((resolve) => {
|
255 |
+
flap.innerText = startValue;
|
256 |
+
flap.classList.add("middle");
|
257 |
+
flap.style.display = "block";
|
258 |
+
top.innerText = value;
|
259 |
+
setTimeout(() => {
|
260 |
+
flap.innerText = value;
|
261 |
+
flap.classList.remove("middle");
|
262 |
+
flap.classList.add("bottom");
|
263 |
+
setTimeout(() => {
|
264 |
+
bottom.innerText = value;
|
265 |
+
flap.classList.remove("bottom");
|
266 |
+
flap.style.display = "none";
|
267 |
+
resolve();
|
268 |
+
}, solariTransitionTime);
|
269 |
+
}, solariTransitionTime);
|
270 |
+
});
|
271 |
+
};
|
272 |
+
const startValueIndex = solariValues.indexOf(startValue);
|
273 |
+
const endValueIndex = solariValues.indexOf(value);
|
274 |
+
let valueArray = [];
|
275 |
+
if (startValueIndex < endValueIndex) {
|
276 |
+
valueArray = solariValues.slice(startValueIndex, endValueIndex + 1);
|
277 |
+
} else if (endValueIndex < startValueIndex) {
|
278 |
+
// wrap around
|
279 |
+
valueArray = solariValues.slice(startValueIndex, solariValues.length).concat(solariValues.slice(0, endValueIndex + 1));
|
280 |
+
}
|
281 |
+
for (let value of valueArray) {
|
282 |
+
if (transitionNumber !== valueNumber) {
|
283 |
+
return;
|
284 |
+
}
|
285 |
+
await setValue(value);
|
286 |
+
}
|
287 |
+
};
|
288 |
+
|
289 |
+
const transitionValue = async (value) => {
|
290 |
+
valueNumber++;
|
291 |
+
const valueArray = value.toUpperCase().split("");
|
292 |
+
const promises = [];
|
293 |
+
for (let i = 0; i < maxLength; i++) {
|
294 |
+
if (i < valueArray.length) {
|
295 |
+
promises.push(transitionElement(i, valueArray[i]));
|
296 |
+
} else {
|
297 |
+
promises.push(transitionElement(i, " "));
|
298 |
+
}
|
299 |
+
}
|
300 |
+
await Promise.all(promises);
|
301 |
+
}
|
302 |
+
|
303 |
+
board.addEventListener("change", (e) => {
|
304 |
+
transitionValue(board.value);
|
305 |
+
});
|
306 |
+
});
|
307 |
+
|
308 |
+
// wheels
|
309 |
+
const wheels = document.querySelectorAll("input.wheel");
|
310 |
+
const tickTime = 100;
|
311 |
+
wheels.forEach((wheel) => {
|
312 |
+
// Wrap input in div
|
313 |
+
const wrapper = document.createElement("div");
|
314 |
+
wrapper.classList.add("wheel-wrapper");
|
315 |
+
wrapper.id = `wrapper-${wheel.id}`;
|
316 |
+
wheel.parentNode.insertBefore(wrapper, wheel);
|
317 |
+
wrapper.appendChild(wheel);
|
318 |
+
|
319 |
+
const wheelContainer = document.createElement("div");
|
320 |
+
wheelContainer.classList.add("wheel");
|
321 |
+
wrapper.appendChild(wheelContainer);
|
322 |
+
|
323 |
+
const wheelElement = document.createElement("div");
|
324 |
+
wheelElement.classList.add("wheel-element");
|
325 |
+
wheelContainer.appendChild(wheelElement);
|
326 |
+
|
327 |
+
const wheelSpecular = document.createElement("div");
|
328 |
+
wheelSpecular.classList.add("wheel-specular");
|
329 |
+
wheelContainer.appendChild(wheelSpecular);
|
330 |
+
|
331 |
+
const wheelShadow = document.createElement("div");
|
332 |
+
wheelShadow.classList.add("wheel-shadow");
|
333 |
+
wrapper.appendChild(wheelShadow);
|
334 |
+
|
335 |
+
const moveRate = 2;
|
336 |
+
const deadZone = 10;
|
337 |
+
const pixelsPerRate = wheel.dataset.pixelsPerRate ? parseInt(wheel.dataset.pixelsPerRate) : 40;
|
338 |
+
const removeClasses = () => {
|
339 |
+
wheelContainer.classList.remove("up");
|
340 |
+
wheelContainer.classList.remove("down");
|
341 |
+
};
|
342 |
+
|
343 |
+
let lastPosition, nextPosition, wheelTime;
|
344 |
+
|
345 |
+
wrapper.addEventListener("wheel", (e) => {
|
346 |
+
const thisWheelTime = new Date().getTime();
|
347 |
+
e.preventDefault();
|
348 |
+
if (wheelTime !== undefined && thisWheelTime - wheelTime < tickTime) {
|
349 |
+
return;
|
350 |
+
}
|
351 |
+
wheelTime = thisWheelTime;
|
352 |
+
if (lastPosition === undefined) {
|
353 |
+
lastPosition = {
|
354 |
+
x: e.clientX,
|
355 |
+
y: e.clientY
|
356 |
+
};
|
357 |
+
}
|
358 |
+
if (nextPosition === undefined) {
|
359 |
+
nextPosition = {...lastPosition};
|
360 |
+
}
|
361 |
+
nextPosition.y += e.deltaY > 0 ? pixelsPerRate : -pixelsPerRate;
|
362 |
+
});
|
363 |
+
|
364 |
+
const tick = () => {
|
365 |
+
if (lastPosition === undefined || nextPosition === undefined) {
|
366 |
+
setTimeout(tick, tickTime);
|
367 |
+
return;
|
368 |
+
}
|
369 |
+
const delta = nextPosition.y - lastPosition.y;
|
370 |
+
removeClasses();
|
371 |
+
if (Math.abs(delta) < deadZone) {
|
372 |
+
setTimeout(tick, tickTime);
|
373 |
+
return;
|
374 |
+
}
|
375 |
+
const deltaRate = Math.floor(Math.abs(delta) / pixelsPerRate);
|
376 |
+
if (delta > 0) {
|
377 |
+
wheelContainer.classList.add("down");
|
378 |
+
wheel.value = -deltaRate;
|
379 |
+
wheel.dispatchEvent(new MouseEvent("click"));
|
380 |
+
} else {
|
381 |
+
wheelContainer.classList.add("up");
|
382 |
+
wheel.value = deltaRate;
|
383 |
+
wheel.dispatchEvent(new MouseEvent("click"));
|
384 |
+
}
|
385 |
+
wheelElement.style.animationDuration = `${10/Math.abs(delta)}s`;
|
386 |
+
lastPosition = {
|
387 |
+
x: lastPosition.x,
|
388 |
+
y: lastPosition.y + delta / moveRate
|
389 |
+
};
|
390 |
+
setTimeout(tick, tickTime);
|
391 |
+
};
|
392 |
+
requestAnimationFrame(tick);
|
393 |
+
|
394 |
+
bindPointerDrag(
|
395 |
+
wrapper,
|
396 |
+
(e) => { // Start
|
397 |
+
if (lastPosition === undefined) {
|
398 |
+
lastPosition = e.start;
|
399 |
+
}
|
400 |
+
wrapper.style.cursor = "grabbing";
|
401 |
+
},
|
402 |
+
(e) => { // Move
|
403 |
+
if (lastPosition === undefined) {
|
404 |
+
lastPosition = e.start;
|
405 |
+
}
|
406 |
+
nextPosition = e.current;
|
407 |
+
},
|
408 |
+
(e) => { // End
|
409 |
+
wrapper.style.cursor = "";
|
410 |
+
}
|
411 |
+
);
|
412 |
+
});
|
413 |
+
|
414 |
+
// 7-segments
|
415 |
+
const segments = document.querySelectorAll("input.seven-segment");
|
416 |
+
segments.forEach((segment) => {
|
417 |
+
// Wrap input in div
|
418 |
+
const wrapper = document.createElement("div");
|
419 |
+
wrapper.classList.add("seven-segment-wrapper");
|
420 |
+
wrapper.id = `wrapper-${segment.id}`;
|
421 |
+
segment.parentNode.insertBefore(wrapper, segment);
|
422 |
+
wrapper.appendChild(segment);
|
423 |
+
|
424 |
+
const reflection = document.createElement("div");
|
425 |
+
reflection.classList.add("reflection");
|
426 |
+
wrapper.appendChild(reflection);
|
427 |
+
|
428 |
+
const reflectionDodge = document.createElement("div");
|
429 |
+
reflectionDodge.classList.add("reflection");
|
430 |
+
reflectionDodge.classList.add("dodge");
|
431 |
+
wrapper.appendChild(reflectionDodge);
|
432 |
+
|
433 |
+
const segmentContainer = document.createElement("div");
|
434 |
+
segmentContainer.classList.add("seven-segment");
|
435 |
+
wrapper.appendChild(segmentContainer);
|
436 |
+
|
437 |
+
const defaultValue = segment.value ? parseInt(segment.value) : 0;
|
438 |
+
const maxValue = segment.max ? parseInt(segment.max) : 9;
|
439 |
+
const numDigits = maxValue.toString().length;
|
440 |
+
|
441 |
+
for (let i = 0; i < numDigits; i++) {
|
442 |
+
const segmentElement = document.createElement("div");
|
443 |
+
segmentElement.classList.add("seven-segment-element");
|
444 |
+
segmentContainer.appendChild(segmentElement);
|
445 |
+
|
446 |
+
for (let j = 0; j < 7; j++) {
|
447 |
+
const segmentPart = document.createElement("div");
|
448 |
+
segmentPart.classList.add(`segment-${j}`);
|
449 |
+
segmentElement.appendChild(segmentPart);
|
450 |
+
}
|
451 |
+
}
|
452 |
+
|
453 |
+
const updateSegment = (segmentIndex, value) => {
|
454 |
+
const segmentElement = segmentContainer.children[segmentIndex];
|
455 |
+
const segmentParts = segmentElement.children;
|
456 |
+
const enabledSegments = [
|
457 |
+
[0, 1, 2, 4, 5, 6],
|
458 |
+
[2, 5],
|
459 |
+
[0, 2, 3, 4, 6],
|
460 |
+
[0, 2, 3, 5, 6],
|
461 |
+
[1, 2, 3, 5],
|
462 |
+
[0, 1, 3, 5, 6],
|
463 |
+
[0, 1, 3, 4, 5, 6],
|
464 |
+
[0, 2, 5],
|
465 |
+
[0, 1, 2, 3, 4, 5, 6],
|
466 |
+
[0, 1, 2, 3, 5, 6]
|
467 |
+
];
|
468 |
+
for (let i = 0; i < 7; i++) {
|
469 |
+
if (enabledSegments[value].includes(i)) {
|
470 |
+
segmentParts[i].classList.add("on");
|
471 |
+
} else {
|
472 |
+
segmentParts[i].classList.remove("on");
|
473 |
+
}
|
474 |
+
}
|
475 |
+
}
|
476 |
+
|
477 |
+
const updateSegments = (value) => {
|
478 |
+
const valueString = value.toString().padStart(numDigits, "0");
|
479 |
+
for (let i = 0; i < numDigits; i++) {
|
480 |
+
updateSegment(i, parseInt(valueString[i]));
|
481 |
+
}
|
482 |
+
}
|
483 |
+
|
484 |
+
updateSegments(defaultValue);
|
485 |
+
|
486 |
+
segment.addEventListener("change", (e) => {
|
487 |
+
updateSegments(parseInt(segment.value));
|
488 |
+
});
|
489 |
+
});
|
490 |
+
|
491 |
+
// sliders
|
492 |
+
const sliders = document.querySelectorAll("input.slider");
|
493 |
+
sliders.forEach((slider) => {
|
494 |
+
// Wrap input in div
|
495 |
+
const wrapper = document.createElement("div");
|
496 |
+
wrapper.classList.add("slider-wrapper");
|
497 |
+
wrapper.id = `wrapper-${slider.id}`;
|
498 |
+
slider.parentNode.insertBefore(wrapper, slider);
|
499 |
+
wrapper.appendChild(slider);
|
500 |
+
|
501 |
+
const sliderContainer = document.createElement("div");
|
502 |
+
sliderContainer.classList.add("slider");
|
503 |
+
wrapper.appendChild(sliderContainer);
|
504 |
+
|
505 |
+
const sliderElement = document.createElement("div");
|
506 |
+
sliderElement.classList.add("slider-element");
|
507 |
+
sliderContainer.appendChild(sliderElement);
|
508 |
+
|
509 |
+
const rangeStartValue = isEmpty(slider.min) ? 0 : parseFloat(slider.min);
|
510 |
+
const rangeEndValue = isEmpty(slider.max) ? 100 : parseFloat(slider.max);
|
511 |
+
const range = rangeEndValue - rangeStartValue;
|
512 |
+
const valueStep = isEmpty(slider.step) ? 1 : parseFloat(slider.step);
|
513 |
+
const numDecimal = valueStep.toString().split(".")[1] ? valueStep.toString().split(".")[1].length : 0;
|
514 |
+
const numLabels = isEmpty(slider.dataset.labels) ? 2 : parseInt(slider.dataset.labels);
|
515 |
+
|
516 |
+
for (let i = 0; i < numLabels; i++) {
|
517 |
+
const labelValue = i === 0
|
518 |
+
? rangeStartValue
|
519 |
+
: i === numLabels - 1
|
520 |
+
? rangeEndValue
|
521 |
+
: (rangeEndValue - rangeStartValue) / (numLabels - 1) * i + rangeStartValue;
|
522 |
+
const labelLeft = document.createElement("div");
|
523 |
+
const labelRight = document.createElement("div");
|
524 |
+
labelLeft.classList.add("label");
|
525 |
+
labelLeft.classList.add("left");
|
526 |
+
labelRight.classList.add("label");
|
527 |
+
labelRight.classList.add("right");
|
528 |
+
labelLeft.innerText = labelValue.toFixed(numDecimal);
|
529 |
+
labelRight.innerText = labelLeft.innerText;
|
530 |
+
labelLeft.style.top = `${100 - (i / (numLabels - 1) * 100)}%`;
|
531 |
+
labelRight.style.top = labelLeft.style.top;
|
532 |
+
sliderContainer.appendChild(labelLeft);
|
533 |
+
sliderContainer.appendChild(labelRight);
|
534 |
+
}
|
535 |
+
|
536 |
+
const updateSlider = () => {
|
537 |
+
const value = parseFloat(slider.value);
|
538 |
+
const inverseValue = rangeEndValue - value + rangeStartValue;
|
539 |
+
const percentage = inverseValue / range * 100;
|
540 |
+
sliderElement.style.top = `${percentage}%`;
|
541 |
+
};
|
542 |
+
|
543 |
+
updateSlider();
|
544 |
+
|
545 |
+
slider.addEventListener("change", (e) => {
|
546 |
+
updateSlider();
|
547 |
+
});
|
548 |
+
|
549 |
+
bindPointerDrag(
|
550 |
+
sliderElement,
|
551 |
+
(e) => { // Start
|
552 |
+
wrapper.classList.add("active");
|
553 |
+
document.body.style.userSelect = "none";
|
554 |
+
document.body.style.cursor = "grabbing";
|
555 |
+
},
|
556 |
+
(e) => { // Move
|
557 |
+
const rect = sliderContainer.getBoundingClientRect();
|
558 |
+
const percentage = (e.current.y - rect.top) / rect.height;
|
559 |
+
const inversePercentage = 1 - percentage;
|
560 |
+
const value = rangeStartValue + (rangeEndValue - rangeStartValue) * inversePercentage;
|
561 |
+
slider.value = Math.min(
|
562 |
+
Math.max(
|
563 |
+
rangeStartValue,
|
564 |
+
value
|
565 |
+
),
|
566 |
+
rangeEndValue
|
567 |
+
);
|
568 |
+
slider.dispatchEvent(new Event("change"));
|
569 |
+
},
|
570 |
+
(e) => { // End
|
571 |
+
wrapper.classList.remove("active");
|
572 |
+
document.body.style.userSelect = "";
|
573 |
+
document.body.style.cursor = "";
|
574 |
+
}
|
575 |
+
);
|
576 |
+
});
|
577 |
+
|
578 |
+
// Info button
|
579 |
+
const infoContainer = document.querySelector("#info");
|
580 |
+
const infoContent = document.querySelector("#info-content");
|
581 |
+
const infoButton = document.querySelector("#info-button");
|
582 |
+
infoButton.addEventListener("click", (e) => {
|
583 |
+
const isActive = infoContainer.classList.contains("active");
|
584 |
+
if (isActive) {
|
585 |
+
infoButton.innerText = "i";
|
586 |
+
infoContainer.classList.remove("active");
|
587 |
+
} else {
|
588 |
+
infoButton.innerText = "×";
|
589 |
+
infoContainer.classList.add("active");
|
590 |
+
}
|
591 |
+
e.stopPropagation();
|
592 |
+
});
|
593 |
+
infoContent.addEventListener("click", (e) => {
|
594 |
+
e.stopPropagation();
|
595 |
+
});
|
596 |
+
infoContainer.addEventListener("click", (e) => {
|
597 |
+
infoButton.dispatchEvent(new Event("click"));
|
598 |
+
});
|
www/sentence.js
ADDED
@@ -0,0 +1,110 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
/**
|
2 |
+
* A class that helps with chunking streaming responing
|
3 |
+
* from an LLM into whole sentences (or as close as we can get).
|
4 |
+
*/
|
5 |
+
export class SentenceChunker {
|
6 |
+
/**
|
7 |
+
* @param {Object} options
|
8 |
+
* @param {number} options.chunkLength - The maximum length of a chunk (default: 96)
|
9 |
+
* @param {boolean} options.emitParagraphs - Whether to emit paragraphs as chunks (default: true)
|
10 |
+
*/
|
11 |
+
constructor(options = {}) {
|
12 |
+
this.buffer = "";
|
13 |
+
this.chunkLength = options.chunkLength || 128;
|
14 |
+
this.emitParagraphs = options.emitParagraphs !== false;
|
15 |
+
this.callbacks = [];
|
16 |
+
}
|
17 |
+
|
18 |
+
/**
|
19 |
+
* Emit a chunk of text
|
20 |
+
* @param {string} output - The chunk of text to emit
|
21 |
+
*/
|
22 |
+
emit(output) {
|
23 |
+
this.callbacks.forEach(cb => cb(output));
|
24 |
+
}
|
25 |
+
|
26 |
+
/**
|
27 |
+
* Register a callback to be called when a chunk is emitted
|
28 |
+
* @param {Function} callback - The callback to call
|
29 |
+
*/
|
30 |
+
onChunk(callback) {
|
31 |
+
this.callbacks.push(callback);
|
32 |
+
}
|
33 |
+
|
34 |
+
/**
|
35 |
+
* Push new data into the chunker
|
36 |
+
* @param {string} data - The new data to push
|
37 |
+
*/
|
38 |
+
push(data) {
|
39 |
+
let paragraphs = data.split(/(\n+)/);
|
40 |
+
let numParagraphs = paragraphs.length;
|
41 |
+
for (let i = 0; i < numParagraphs; i++) {
|
42 |
+
let paragraph = paragraphs[i];
|
43 |
+
if (!paragraph) {
|
44 |
+
continue;
|
45 |
+
}
|
46 |
+
let sentences = paragraph.split(/(?<=[;:,.!?]\s+)|(?<=[;:,。!?])/);
|
47 |
+
let bufferLength = this.buffer.length;
|
48 |
+
for (let sentence of sentences) {
|
49 |
+
let sentenceLength = sentence.length;
|
50 |
+
if (sentenceLength === 0) {
|
51 |
+
continue;
|
52 |
+
}
|
53 |
+
if (bufferLength + sentenceLength <= this.chunkLength) {
|
54 |
+
this.buffer += sentence;
|
55 |
+
bufferLength += sentenceLength;
|
56 |
+
} else {
|
57 |
+
if (bufferLength > 0) {
|
58 |
+
this.emit(this.buffer);
|
59 |
+
}
|
60 |
+
this.buffer = sentence;
|
61 |
+
bufferLength = sentenceLength;
|
62 |
+
}
|
63 |
+
}
|
64 |
+
|
65 |
+
if (this.emitParagraphs && numParagraphs > 1 && i < numParagraphs - 1) {
|
66 |
+
this.emit(this.buffer);
|
67 |
+
this.buffer = "";
|
68 |
+
}
|
69 |
+
}
|
70 |
+
}
|
71 |
+
|
72 |
+
/**
|
73 |
+
* Flush the buffer, emitting any remaining text
|
74 |
+
*/
|
75 |
+
flush() {
|
76 |
+
if (this.buffer.length > 0) {
|
77 |
+
this.emit(this.buffer);
|
78 |
+
this.buffer = "";
|
79 |
+
}
|
80 |
+
}
|
81 |
+
}
|
82 |
+
|
83 |
+
/**
|
84 |
+
* A SentenceChunker that can handle streaming responses that grow over time
|
85 |
+
* (e.g. when the LLM is still generating a response and concatenating it to the previous response)
|
86 |
+
*/
|
87 |
+
export class GrowingSentenceChunker extends SentenceChunker {
|
88 |
+
constructor(options = {}) {
|
89 |
+
super(options);
|
90 |
+
this.partialSentence = "";
|
91 |
+
}
|
92 |
+
|
93 |
+
/**
|
94 |
+
* Push new data into the chunker
|
95 |
+
* @param {string} data - The new data to push
|
96 |
+
*/
|
97 |
+
push(data) {
|
98 |
+
const newData = data.substring(this.partialSentence.length);
|
99 |
+
this.partialSentence += newData;
|
100 |
+
super.push(newData);
|
101 |
+
}
|
102 |
+
|
103 |
+
/**
|
104 |
+
* Flush the buffer, emitting any remaining text
|
105 |
+
*/
|
106 |
+
flush() {
|
107 |
+
super.flush();
|
108 |
+
this.partialSentence = "";
|
109 |
+
}
|
110 |
+
}
|
www/static/aluminum-circle-specular.jpg
ADDED
www/static/aluminum-spiral.jpg
ADDED
www/static/aluminum.jpg
ADDED
www/static/bezel.png
ADDED
www/static/black-plastic.jpg
ADDED
www/static/button-background.png
ADDED
www/static/button-foreground.png
ADDED
www/static/bytebounce.woff
ADDED
Binary file (11.5 kB). View file
|
|
www/static/bytebounce.woff2
ADDED
Binary file (8.46 kB). View file
|
|
www/static/github.svg
ADDED
www/static/oscilloscope-grid.png
ADDED
www/static/screen-reflection-circle.jpg
ADDED
www/static/screen-reflection.jpg
ADDED
www/static/shading-circle.png
ADDED
www/static/wheel-shadow.png
ADDED
www/static/wheel.jpg
ADDED
www/static/wood.jpg
ADDED
www/visualizer.js
ADDED
@@ -0,0 +1,121 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { AudioPipe } from "./audio.js";
|
2 |
+
|
3 |
+
/**
|
4 |
+
* AudioPipeVisualizer
|
5 |
+
*
|
6 |
+
* Class to handle streaming audio data to the browser's audio context
|
7 |
+
* Also draws the audio waveform on a canvas if provided
|
8 |
+
*/
|
9 |
+
export class AudioPipeVisualizer extends AudioPipe {
|
10 |
+
/**
|
11 |
+
* Constructor
|
12 |
+
* @param {Object} options - Options object
|
13 |
+
* @param {HTMLCanvasElement} options.canvas - Canvas element to draw the waveform
|
14 |
+
* @param {Number} options.lineWidth - Line width for the waveform
|
15 |
+
* @param {Number} options.fftSize - FFT size for the waveform
|
16 |
+
* @param {String} options.fillStyle - Fill style for the waveform
|
17 |
+
* @param {String} options.strokeStyle - Stroke style for the waveform
|
18 |
+
*/
|
19 |
+
constructor(options = {}) {
|
20 |
+
super(options);
|
21 |
+
this.canvas = options.canvas || null;
|
22 |
+
this.lineWidth = options.lineWidth || 1;
|
23 |
+
this.fftSize = options.fftSize || 2048;
|
24 |
+
this.fillStyle = options.fillStyle || "#FFFFFF";
|
25 |
+
this.strokeStyle = options.strokeStyle || "#000000";
|
26 |
+
this.waveformNoiseLevel = options.waveformNoiseLevel || 0; // Only for waveform, a visual distortion for style
|
27 |
+
this.fftBuffer = new Uint8Array(this.fftSize);
|
28 |
+
this.startDrawing();
|
29 |
+
}
|
30 |
+
|
31 |
+
/**
|
32 |
+
* Get the waveform styles for the canvas.
|
33 |
+
* We let the user pass in multiple styles (colors) or widths to draw the waveform.
|
34 |
+
* This allows for stacking or glowing effects, if desired.
|
35 |
+
* @returns {Array} - Array of styles for the waveform
|
36 |
+
*/
|
37 |
+
waveformStyles() {
|
38 |
+
let strokeStyle = this.strokeStyle;
|
39 |
+
let lineWidth = this.lineWidth;
|
40 |
+
if (!Array.isArray(strokeStyle)) {
|
41 |
+
strokeStyle = [strokeStyle];
|
42 |
+
}
|
43 |
+
if (!Array.isArray(lineWidth)) {
|
44 |
+
lineWidth = [lineWidth];
|
45 |
+
}
|
46 |
+
let numStrokes = Math.max(strokeStyle.length, lineWidth.length);
|
47 |
+
let styles = [];
|
48 |
+
for (let i = 0; i < numStrokes; i++) {
|
49 |
+
styles.push({
|
50 |
+
strokeStyle: strokeStyle[Math.min(i, strokeStyle.length - 1)],
|
51 |
+
lineWidth: lineWidth[Math.min(i, lineWidth.length - 1)]
|
52 |
+
});
|
53 |
+
}
|
54 |
+
return styles;
|
55 |
+
}
|
56 |
+
|
57 |
+
push(data, sampleRate = 44100) {
|
58 |
+
let audioBuffer = super.push(data, sampleRate);
|
59 |
+
audioBuffer.connect(this.analyser);
|
60 |
+
return audioBuffer;
|
61 |
+
}
|
62 |
+
|
63 |
+
/**
|
64 |
+
* Initialize the analyzer to draw the waveform on the canvas.
|
65 |
+
*/
|
66 |
+
initialize() {
|
67 |
+
super.initialize();
|
68 |
+
// Create analyser
|
69 |
+
this.analyser = this.audioContext.createAnalyser();
|
70 |
+
this.analyser.fftSize = this.fftSize;
|
71 |
+
this.analyser.connect(this.audioContext.destination);
|
72 |
+
}
|
73 |
+
|
74 |
+
/**
|
75 |
+
* Starts drawing the waveform on the canvas.
|
76 |
+
* If the audio context is not initialized, will draw as if there was silence.
|
77 |
+
*/
|
78 |
+
startDrawing() {
|
79 |
+
const context = this.canvas.getContext("2d");
|
80 |
+
const sliceWidth = this.canvas.width / this.fftSize;
|
81 |
+
context.fillStyle = this.fillStyle;
|
82 |
+
const draw = () => {
|
83 |
+
// Update data
|
84 |
+
this.analyser !== undefined && this.analyser.getByteTimeDomainData(this.fftBuffer);
|
85 |
+
// Clear the canvas
|
86 |
+
context.fillRect(0, 0, this.canvas.width, this.canvas.height);
|
87 |
+
// Start drawing the waveform
|
88 |
+
let x = 0;
|
89 |
+
context.beginPath();
|
90 |
+
for (let i = 0; i < this.fftSize; i++) {
|
91 |
+
let v = this.initialized ? this.fftBuffer[i] / 128.0 : 1;
|
92 |
+
// Scale v's distance from 1.0 by the volume
|
93 |
+
let distance = Math.abs(v - 1.0);
|
94 |
+
v = 1.0 + (distance * this.volume * Math.sign(v - 1.0));
|
95 |
+
if (this.waveformNoiseLevel > 0) {
|
96 |
+
v += (Math.random() - 0.5) * this.waveformNoiseLevel * 2 * Math.sin(i / this.fftSize * Math.PI) * this.volume;
|
97 |
+
}
|
98 |
+
const y = v * this.canvas.height / 2;
|
99 |
+
if (i === 0) {
|
100 |
+
context.moveTo(x, y);
|
101 |
+
} else {
|
102 |
+
context.lineTo(x, y);
|
103 |
+
}
|
104 |
+
|
105 |
+
x += sliceWidth;
|
106 |
+
}
|
107 |
+
// Final line to the right
|
108 |
+
context.lineTo(this.canvas.width, this.canvas.height / 2);
|
109 |
+
// Stroke the path using all strokes
|
110 |
+
for (let style of this.waveformStyles()) {
|
111 |
+
context.strokeStyle = style.strokeStyle;
|
112 |
+
context.lineWidth = style.lineWidth;
|
113 |
+
context.stroke();
|
114 |
+
}
|
115 |
+
// Schedule next frame
|
116 |
+
requestAnimationFrame(draw);
|
117 |
+
};
|
118 |
+
// Start drawing
|
119 |
+
requestAnimationFrame(draw);
|
120 |
+
}
|
121 |
+
};
|