benjamin-paine commited on
Commit
5a0ba44
·
1 Parent(s): 94c7354

initial commit

Browse files
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: blue
5
- colorTo: red
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 = "&times;";
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
+ };