Skip to content

Commit d5d84ba

Browse files
committed
Add box breathing demo.
1 parent 75e17e0 commit d5d84ba

File tree

3 files changed

+307
-0
lines changed

3 files changed

+307
-0
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ with.
1010

1111
## My JavaScript Demos - I Love JavaScript!
1212

13+
* [Box Breathing Exercise With SpeechSynthesis And Alpine.js](https://bennadel.github.io/JavaScript-Demos/demos/box-breathing-alpine)
1314
* [Using CSS Gap To Control Margins In Website Copy](https://bennadel.github.io/JavaScript-Demos/demos/margins-via-gap-css)
1415
* [Formatting Dates In The Local Timezone With Alpine.js](https://bennadel.github.io/JavaScript-Demos/demos/local-date-formatter-alpine3)
1516
* [CSV To CTE Transformer In Angular 18](https://bennadel.github.io/JavaScript-Demos/demos/csv-to-cte-angular18/dist)
Lines changed: 275 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,275 @@
1+
<!doctype html>
2+
<html lang="en">
3+
<head>
4+
<title>
5+
Box Breathing Exercise With SpeechSynthesis And Alpine.js
6+
</title>
7+
<link rel="stylesheet" type="text/css" href="./main.css" />
8+
<script type="text/javascript" src="../../vendor/alpine/3.13.5/alpine.3.13.5.min.js" defer></script>
9+
</head>
10+
<body>
11+
12+
<h1>
13+
Box Breathing Exercise With SpeechSynthesis And Alpine.js
14+
</h1>
15+
16+
<section x-data="Demo" :hidden="( ! voices.length )">
17+
18+
<div class="form">
19+
<select x-model.number="selectedVoiceIndex">
20+
<template x-for="( voice, index ) in voices" :key="index">
21+
<option
22+
:value="index"
23+
x-text="voice.name">
24+
</option>
25+
</template>
26+
</select>
27+
28+
<button @click="start()">
29+
Start
30+
</button>
31+
<button @click="stop()">
32+
Stop
33+
</button>
34+
</div>
35+
36+
<p class="text" :hidden="( ! text )">
37+
[<span x-text="iteration"></span>]:
38+
<span x-text="text"></span>
39+
</p>
40+
41+
</section>
42+
43+
<script type="text/javascript">
44+
45+
function Demo() {
46+
47+
// Box breathing consists of four phases: in, hold, out, hold. Each phase
48+
// lasts 4-seconds; and each term below will be spoken at a 1-second interval.
49+
var phases = [
50+
[ "In", "two", "three", "four" ],
51+
[ "Hold", "two", "three", "four" ],
52+
[ "Out", "two", "three", "four" ],
53+
[ "Hold", "two", "three", "four" ]
54+
];
55+
56+
// Flatten the phases into a single set of states. This will make it easier to
57+
// process; and, allows us to materialize some state that otherwise would be
58+
// more challenging to calculate on the fly (ex, the text to output).
59+
var states = phases.flatMap(
60+
( phase ) => {
61+
62+
return phase.map(
63+
( term, i ) => {
64+
65+
// As we proceed across the terms in each phase, the text will
66+
// be the aggregation of the previous text already rendered in
67+
// the same phase.
68+
var text = phase
69+
.slice( 0, ( i + 1 ) )
70+
.join( " ..." )
71+
;
72+
73+
// Note: the voice for the utterance will be set just prior to
74+
// each vocalization. This way it will always reflect what's
75+
// currently in the select menu.
76+
return {
77+
term: term,
78+
text: text,
79+
utterance: new SpeechSynthesisUtterance( term ),
80+
duration: 1000
81+
};
82+
83+
}
84+
);
85+
86+
}
87+
);
88+
89+
// Once the timer is started, this queue will hold the states to be processed.
90+
// And the timer will hold the delay between each utterance.
91+
var queue = [];
92+
var timer = null;
93+
94+
// Short-hand reference.
95+
var synth = window.speechSynthesis;
96+
97+
// The tricky thing with Alpine.js is that the object returned from the
98+
// component becomes the hook for reactivity. Alpine.js creates a Proxy that
99+
// wraps the given data and updates the DOM when the values are mutated. This
100+
// makes it a bit challenging to create a separation between public and
101+
// private properties / methods. In this case, I have to include the private
102+
// methods on the return value so that they can access the appropriate `this`
103+
// reference for subsequent reactivity. To help enforce the "private" nature
104+
// of the methods, I'm aliasing them with a "_" prefix.
105+
return {
106+
// Public reactive properties.
107+
voices: synth.getVoices(),
108+
selectedVoiceIndex: -1,
109+
text: "",
110+
iteration: 0,
111+
112+
// Public methods.
113+
init: $init,
114+
start: start,
115+
stop: stop,
116+
117+
// Private methods.
118+
_processQueue: processQueue,
119+
_setVoices: setVoices
120+
};
121+
122+
// ---
123+
// PUBLIC METHODS.
124+
// ---
125+
126+
/**
127+
* I initialize the Alpine component.
128+
*/
129+
function $init() {
130+
131+
// Voices aren't available on page ready. Instead, we have to bind to the
132+
// voiceschanged event and then setup the view-model once they become
133+
// available on the SpeechSynthesis API.
134+
synth.addEventListener(
135+
"voiceschanged",
136+
( event ) => {
137+
138+
this._setVoices();
139+
140+
}
141+
);
142+
143+
}
144+
145+
/**
146+
* I start the vocalization of the guided box breathing.
147+
*/
148+
function start() {
149+
150+
if ( ! this.voices.length ) {
151+
152+
console.warn( "No voices have been loaded yet." );
153+
return;
154+
155+
}
156+
157+
queue = states.slice();
158+
this.iteration = 1;
159+
this.text = "";
160+
this._processQueue();
161+
162+
}
163+
164+
/**
165+
* I stop the vocalization of the guided box breathing.
166+
*/
167+
function stop() {
168+
169+
clearInterval( timer );
170+
this.iteration = 0;
171+
this.text = "";
172+
173+
}
174+
175+
// ---
176+
// PRIVATE METHODS.
177+
// ---
178+
179+
/**
180+
* I process the queue, vocalizing the next state. This method will call itself
181+
* recursively (via setTimeout).
182+
*/
183+
function processQueue() {
184+
185+
// Reset queue when a new iteration is being started.
186+
if ( ! queue.length ) {
187+
188+
queue = states.slice();
189+
this.iteration++;
190+
191+
}
192+
193+
var state = queue.shift();
194+
// Update the utterance to always use the voice that's currently selected.
195+
// This way, the user can change the voice during vocalization to find one
196+
// that is the most comfortable.
197+
state.utterance.voice = this.voices[ this.selectedVoiceIndex ];
198+
state.utterance.pitch = 0;
199+
state.utterance.rate = 0.7;
200+
201+
synth.speak( state.utterance );
202+
this.text = state.text;
203+
204+
timer = setTimeout(
205+
() => {
206+
207+
this._processQueue();
208+
209+
},
210+
state.duration
211+
);
212+
213+
}
214+
215+
/**
216+
* I set the voices based on the current synth state.
217+
*/
218+
function setVoices() {
219+
220+
// There are TONS of voices, but only a handful of them seem to create a
221+
// reasonable experience. This is probably very specific to each browser
222+
// or computer; but, I'm going to filter-down to the ones I like.
223+
this.voices = synth.getVoices().filter(
224+
( voice ) => {
225+
226+
switch ( voice.name.toLowerCase() ) {
227+
case "alex":
228+
case "alva":
229+
case "damayanti":
230+
case "daniel":
231+
case "fiona":
232+
case "fred":
233+
case "karen":
234+
case "mei-jia":
235+
case "melina":
236+
case "moira":
237+
case "rishi":
238+
case "samantha":
239+
case "tessa":
240+
case "veena":
241+
case "victoria":
242+
case "yuri":
243+
return true;
244+
break;
245+
}
246+
247+
return false;
248+
249+
}
250+
);
251+
252+
// Default to the most pleasing if it exists.
253+
this.selectedVoiceIndex = this.voices.findIndex(
254+
( voice ) => {
255+
256+
return ( voice.name === "Tessa" );
257+
258+
}
259+
);
260+
261+
// If the preferred voice doesn't exist, just use the first one.
262+
if ( this.selectedVoiceIndex === -1 ) {
263+
264+
this.selectedVoiceIndex = 0;
265+
266+
}
267+
268+
}
269+
270+
}
271+
272+
</script>
273+
274+
</body>
275+
</html>
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
2+
body {
3+
font-family: monospace ;
4+
font-size: 18px ;
5+
line-height: 1.4 ;
6+
}
7+
8+
button,
9+
select {
10+
font-family: inherit ;
11+
font-size: inherit ;
12+
line-height: inherit ;
13+
}
14+
15+
button {
16+
padding: 0.5rem 2ch ;
17+
}
18+
select {
19+
padding: 0.5rem 1.5ch ;
20+
}
21+
22+
.form {
23+
display: flex ;
24+
gap: 10px ;
25+
}
26+
27+
.text {
28+
color: #666666 ;
29+
font-size: 20px ;
30+
/* text-transform: capitalize ;*/
31+
}

0 commit comments

Comments
 (0)