14. Breathe

This app provides timers for breath training.
../_images/sliders.png

14.1. App Theme

Click Create a new app.
Choose the Material Design theme.

14.2. Settings

In General Settings set the App Name to "Breathe" and add a logo that has ot be first added to assets.
In Settings: Dependencies, in order to get the switch and slider components from Anvil extras, add:
dependency: C6ZZPAPN4YYF5NVJ
package name: anvil-extras

The anvil extras components will now be available in the toolbox.

../_images/anvil_extras.png

14.3. Assets

The html and css are added to the assets folder on creating the app.
The other assets used in this app are shown below.
They are added by hovering over the three dots button to the right of Assets in the sidebar menu.
The png file is used as a logo.
The mp3 files are short 1 seconds long sound files for the inhale and exhale sounds.
../_images/breathe_structure.png

14.4. Forms and modules

Create the forms and modules shown above.

14.5. From1

Form1 is the main form for the app.
The design view of Form1 is shown below.
The components are links with icons set and click event handlers added.
../_images/Form1.png
The python code for Form1 is shown below.
The import of the sound module is to load voices for windows users.
 1# -------------------------
 2# Imports for Form1
 3# -------------------------
 4from ._anvil_designer import Form1Template  # The generated template for Form1
 5from anvil import *
 6import anvil.server
 7
 8# Import other forms directly by their module names
 9from Sliders import Sliders
10from Settings import Settings
11from Info import Info
12from Sounds import Sounds
13
14 # path ..
15from .. import voice_bootstrap
16
17class Form1(Form1Template):
18    def __init__(self, **properties):
19        # Set Form properties and Data Bindings.
20        self.init_components(**properties)
21        # Ensure a voice menu for windows exists immediately
22        voice_bootstrap.ensure_voice_ready()
23        # Go to Sliders ready to use
24        self.sliders_link_click()
25
26    # -------------------------
27    # Helper methods
28    # -------------------------
29    def reset_links(self):
30        self.info_link.role = ''
31        self.settings_link.role = ''
32        self.sliders_link.role = ''
33        self.sounds_link.role = ''
34
35    def _navigate_to(self, new_form_cls, link=None):
36        """Stop current form, clear panel, and add new form."""
37        # Stop any currently running form first
38        current_components = self.content_panel.get_components()
39        if current_components:
40            current = current_components[0]
41            if hasattr(current, "_hard_stop"):
42                current._hard_stop()  # stops speech/audio/animation immediately
43
44        # Clear panel and add new form
45        self.content_panel.clear()
46        self.content_panel.add_component(new_form_cls())
47
48        # Update link role if provided
49        if link:
50            link.role = 'selected'
51
52
53    # -------------------------
54    # Link click methods
55    # -------------------------
56    def info_link_click(self, **event_args):
57        self.reset_links()
58        self._navigate_to(Info, link=self.info_link)
59
60    def settings_link_click(self, **event_args):
61        self.reset_links()
62        self._navigate_to(Settings, link=self.settings_link)
63
64    def sliders_link_click(self, **event_args):
65        self.reset_links()
66        self._navigate_to(Sliders, link=self.sliders_link)
67
68    def sounds_link_click(self, **event_args):
69        self.reset_links()
70        self._navigate_to(Sounds, link=self.sounds_link)
71
72    # Optional title links
73    def Settings_title_link_click(self, **event_args):
74        self.settings_link_click()
75
76    def Info_title_link_click(self, **event_args):
77        self.info_link_click()
78
79    def Sliders_title_link_click(self, **event_args):
80        self.sliders_link_click()
81
82    def Sounds_title_link_click(self, **event_args):
83        self.sounds_link_click()

14.6. Info form

The design view of Info form is shown below.
../_images/info.png
The markdown text for the info text is shown below.
  1**Usage on iphones**
  2Use FIrefox to play phase sounds.
  3Use FIrefox to continue cycles past 5 min.
  4Change voices for speech in the system settings.
  5
  6=====================================
  7
  8**Settings (Cog icon)**
  9Choose:
 10* the number of breathing cycles
 11* the pattern of breathing: in, hold, out, hold
 12
 13**Sound Settings (Notes icon)**
 14Choose:
 15* the voice
 16* the voice rate [slight adjustments may be needed for the timing of counts for different voices on different devices)
 17* play sounds on or off
 18* speak breathing phase on or off
 19* speak counts on or off
 20
 21**Breathe (Sliders)**
 22Click the play icon to start a breathing session.
 23
 24===============================
 254 numbers represent inhale-hold-exhale-hold
 263 numbers omit the second hold
 272 numbers are inhale-exhale
 28
 29## **Breathing Patterns**
 30
 31#### **1) Box / Square Breathing** (Intermediate, Focus)
 32
 33**Purpose:** Anxiety control, ADHD focus, pre-performance calming, emotional regulation, stress management, tactical breathing, attention training.
 34Equal phases → strong cognitive control and rhythm.
 35**Patterns:**
 363–3–3–3, 4–4–4–4, 5–5–5–5, 6–6–6–6
 37
 38---
 39
 40#### **2) Equal Inhale–Exhale (No Holds)** (Beginner, Calm)
 41
 42**Purpose:** Relaxation, HRV coherence, gentle parasympathetic activation, beginner-friendly breathing.
 43Also called coherent breathing (especially 5–5 or 6–6).
 44**Patterns:**
 453–0–3–0, 4–0–4–0, 5–0–5–0, 6–0–6–0
 46
 47---
 48
 49#### **3) Equal Inhale–Exhale with Inhale Hold** (Intermediate, Mindfulness)
 50
 51**Purpose:** Breath awareness, mindfulness, attention training, mild CO₂ tolerance.
 52Short inhale holds add structure without strain.
 53**Patterns:**
 543–3–3–0, 4–4–4–0, 5–5–5–0, 6–6–6–0
 55
 56---
 57
 58#### **4) Equal Inhale–Exhale with Exhale Hold** (Intermediate, Calm)
 59
 60**Purpose:** Calming, CO₂ tolerance. Exhale hold increases vagal tone and stillness.
 61**Patterns:**
 623–0–3–3, 4–0–4–4, 5–0–5–5, 6–0–6–6
 63
 64---
 65
 66#### **5) Extended Exhale (No Holds)** (Beginner/Intermediate, Relaxation & Sleep)
 67
 68**Purpose:** Strong relaxation, anxiety reduction, down-regulation, sleep onset.
 69Exhale longer than inhale → parasympathetic dominance, effective for acute stress.
 70**Patterns:**
 712–0–6–0, 3–0–6–0, 4–0–6–0, 4–0–8–0
 72
 73---
 74
 75#### **6) Inhale-Hold Emphasis (Structured Relaxation)** (Intermediate, Breath Control)
 76
 77**Purpose:** Breath control, anxiety exposure, rhythm discipline.
 78Short holds add structure without long breath suspensions.
 79**Patterns:**
 804–2–4–0, 4–2–6–0
 81
 82---
 83
 84#### **7) Extended Exhale after Inhale Hold** (Intermediate/Advanced, Calm & Sleep)
 85
 86**Purpose:** Deep calming, insomnia support, panic reduction.
 87Slow exhale after hold strongly activates the parasympathetic system.
 88**Patterns:**
 894–4–8–0, 4–7–8–0 (Weil breathing)
 90
 91---
 92
 93#### **8) Fast Energising Breathing** (Beginner/Intermediate, Energise)
 94
 95**Purpose:** Increase alertness, counter fatigue, pre-task activation.
 96Fast, no-hold breathing raises arousal and focus by increasing respiratory rate. Best used briefly, not for relaxation.
 97**Patterns:**
 982–0–2–0, 2–0–3–0
 99
100---
101
102#### **9) Two-Stage Inhale (Physiological Sigh)** (Intermediate, Rapid Calm)
103
104**Purpose:** Rapid calming, anxiety interruption, nervous system reset.
105Double inhale followed by a long exhale improves lung inflation and strongly activates calming pathways. Most effective in a few cycles.
106**Patterns:**
1072–1–6–0, 3–1–6–0
108
109---
110
111#### **10) Long Exhale-Hold Tolerance** (Advanced, Breath Control & Calm)
112
113**Purpose:** CO₂ tolerance, deep parasympathetic conditioning, breath control.
114Exhale pause trains calm responses to air hunger and deepens regulation. Best for experienced users.
115**Patterns:**
1164–0–4–4, 4–0–6–6, 4–0–6–8
117
118===============================
119
120# **Types of Breathing**
121There are different modes of breathing, each with a slightly different process for inspiration and expiration.
122
123#### **1) Quiet breathing** (Eupnea):
124
125 occurs at rest without thought. The diaphragm and external intercostals contract.
126Normal rates for adults are typically between 12 and 20 breaths per minute.
127
128#### **2) Diaphragmatic breathing** (belly breathing):
129
130the diaphragm contracts as the belly moves out, keeping the chest still. As the diaphragm relaxes, air passively leaves the lungs.
131
132#### **3)Costal breathing** (shallow chest breathing):
133occurs via contraction of the intercostal muscles. As the intercostal muscles relax, air passively leaves the lungs.
134
135#### **4) Hyperpnea** (forced breathing):
136 inspiration and expiration both occur due to contraction of the diaphragm, intercostal muscles, and other accessory muscles. During forced inspiration, muscles of the neck, including the scalenes, contract and lift the thoracic wall, increasing lung volume. During forced expiration, accessory muscles of the abdomen, including the obliques, contract, forcing abdominal organs upward against the diaphragm. This helps to push the diaphragm further into the thorax, pushing more air out. In addition, accessory muscles (primarily the internal intercostals) help to compress the rib cage, which also reduces the volume of the thoracic cavity.
137
138===============================
139
140For **Active Cycle of Breathing Technique ACBT**
141See:
142[https://bronchiectasis.com.au/physiotherapy/techniques/the-active-cycle-of-breathing-technique](https://bronchiectasis.com.au/physiotherapy/techniques/the-active-cycle-of-breathing-technique)
143See:
144[https://www.youtube.com/watch?v=4mXBcA6AI_4](https://www.youtube.com/watch?v=4mXBcA6AI_4)
145[https://www.youtube.com/watch?v=XvorhwGZGm8](https://www.youtube.com/watch?v=XvorhwGZGm8)
146
147For **Autogenic Drainage AD**
148See:
149[https://bronchiectasis.com.au/physiotherapy/techniques/autogenic-drainage](https://bronchiectasis.com.au/physiotherapy/techniques/autogenic-drainage)
150See:
151[https://vimeo.com/148583067](https://vimeo.com/148583067)

14.7. Sounds form

Use the sounds form to set speech voice on windows. On iphones, go so system settings to set voice.
Set the voice rate to suit your device.
Breathing phase sounds can only work on windows devices and Firefox browser on iphone or ipad.
Choose whether to speak the inhale and exhale prompts and the breathing counts.
The design view of Sounds form is shown below.
Use an XY panel to place the labels, dropdown and switches precisely.
The switches have custom css to modify their appearance from the anvil extras default.
../_images/sounds.png
The python code for Sounds is shown below.
  1from ._anvil_designer import SoundsTemplate
  2from anvil import *
  3import anvil.js
  4import time
  5
  6from ... import breathing_settings
  7
  8
  9class Sounds(SoundsTemplate):
 10    def __init__(self, **properties):
 11        self.init_components(**properties)
 12        # before voice manager gets voices since rate used for test speaking
 13        self.speech_rate = breathing_settings.get_voice_rate()
 14        self.set_speech_rate()
 15        # initialize voice then update it in voice manager
 16        self.selected_voice = None
 17        self.voices = []
 18        self.voice_manager = Sounds.VoiceManager()
 19        self.load_voices()
 20        # speech and sounds
 21        self.set_play_phase_sounds_bool()
 22        self.set_speak_phases_bool()
 23        self.set_speak_counts_bool()
 24        # so developer can check text in user agent
 25        self.ua_text.text = self._get_ua_text()
 26        # Audio map for testing
 27        self._audio_test_map = {
 28            "high": "./_/theme/high.mp3",
 29            "low": "./_/theme/low.mp3",
 30            "hold": "./_/theme/hold.mp3",
 31            "silence": "./_/theme/silence.mp3"
 32        }
 33        # clear errors
 34        self.audio_debug_label.text = ""
 35        self._apply_platform_voice_rules()
 36
 37    def _apply_platform_voice_rules(self):
 38        if self._is_ios():
 39            # Disable voice selection on iOS
 40            self.voice_dropdown.enabled = False
 41
 42            # Optional UX hint
 43            self.voice_dropdown.tooltip = (
 44                "On iPhone and iPad, the voice follows your system settings."
 45            )
 46        else:
 47            self.voice_dropdown.enabled = True
 48
 49
 50    # -------------------------
 51    # settings module speach rate
 52    # -------------------------
 53
 54
 55    def _is_ios(self):
 56        """
 57        Detect iOS devices (iPhone / iPad / iPod).
 58        Works across Safari, Chrome, Firefox iOS.
 59        """
 60        win = anvil.js.window
 61        ua = win.navigator.userAgent
 62
 63        return (
 64            "iPhone" in ua
 65            or "iPad" in ua
 66            or "iPod" in ua
 67            # iPadOS 13+ reports as Mac
 68            or ("Macintosh" in ua and win.navigator.maxTouchPoints > 1)
 69        )
 70
 71
 72    # -------------------------
 73    # settings module speach rate
 74    # -------------------------
 75
 76    def set_speech_rate(self):
 77        # print(self.speech_rate)
 78        self.voice_rate.selected_value = str(self.speech_rate)
 79
 80    def voice_rate_change(self, **event_args):
 81        breathing_settings.set_voice_rate(self.voice_rate.selected_value)
 82        self.speech_rate = self.voice_rate.selected_value
 83
 84    # -------------------------
 85    # settings module play_phase_sounds
 86    # -------------------------
 87
 88    def set_play_phase_sounds_bool(self):
 89        # checked is property for enabled or not
 90        self.play_phase_sounds.checked = breathing_settings.get_play_phase_sounds_bool()
 91
 92    def play_phase_sounds_change(self, **event_args):
 93        play_phase_sounds_bool = self.play_phase_sounds.checked
 94        breathing_settings.set_play_phase_sounds_bool(play_phase_sounds_bool)
 95
 96
 97    # -------------------------
 98    # settings module speak_phases
 99    # -------------------------
100
101    def set_speak_phases_bool(self):
102        # checked is property for enabled or not
103        self.speak_phases.checked = breathing_settings.get_speak_phases_bool()
104
105    def speak_phases_change(self, **event_args):
106        speak_phases_bool = self.speak_phases.checked
107        breathing_settings.set_speak_phases_bool(speak_phases_bool)
108
109
110    # -------------------------
111    # settings module speach rate
112    # -------------------------
113
114    def set_speak_counts_bool(self):
115        # checked is property for enabled or not
116        self.speak_counts.checked = breathing_settings.get_speak_counts_bool()
117
118    def speak_counts_change(self, **event_args):
119        speak_counts_bool = self.speak_counts.checked
120        breathing_settings.set_speak_counts_bool(speak_counts_bool)
121
122    # -------------------------
123    # PLATFORM
124    # -------------------------
125
126    def _get_ua_text(self):
127        win = anvil.js.window
128        ua = win.navigator.userAgent
129        return ua
130
131    # =======================================================
132    # Voice handling for windows
133    # =======================================================
134    def load_voices(self):
135        """
136        Loads voices asynchronously; waits for iOS/Chrome/Firefox to populate. alhtouh vocies do not work on phone
137        Only after voices are loaded can the dropdown and speaking functions be used reliably.
138        """
139        win = anvil.js.window
140        synth = win.speechSynthesis
141
142        def got_voices(voices):
143            # Keep only English voices
144            en_voices = [(v.name, v.lang, v) for v in voices if v.lang.startswith("en")]
145            if not en_voices:
146                # fallback if none available
147                en_voices = [(v.name, v.lang, v) for v in voices]
148            self.voices = en_voices
149
150            if self._is_ios():
151                # Populate dropdown
152                self.voice_dropdown.items = ["default for system"]
153            else:
154                # Populate dropdown
155                self.voice_dropdown.items = [(f"{name} - {lang}", obj) for name, lang, obj in en_voices]
156
157            # Restore stored voice or fallback
158            stored_voice = breathing_settings.get_voice()
159            if stored_voice in [v[2] for v in en_voices]:
160                self.selected_voice = stored_voice
161            else:
162                self.selected_voice = en_voices[0][2]
163                breathing_settings.set_voice(self.selected_voice)
164
165            # Enable test button now that voices are ready
166            self.speak_test.enabled = True
167
168        # iOS may not have voices immediately
169        voices_ready = synth.getVoices()
170        if voices_ready and len(voices_ready) > 0:
171            got_voices(voices_ready)
172        else:
173            # Listen for voiceschanged event
174            def on_voices_changed(event):
175                synth.removeEventListener("voiceschanged", on_voices_changed)
176                got_voices(synth.getVoices())
177
178            synth.addEventListener("voiceschanged", on_voices_changed)
179
180
181    # ------------------------------------------------------
182    # ------------------------------------------------------
183
184    def voices_dropdown_change(self, **event_args):
185        self.selected_voice = self.voice_dropdown.selected_value
186        breathing_settings.set_voice(self.selected_voice)
187        # self._speak_sample()
188
189    def speak_test_click(self, **event_args):
190        self._speak_sample()
191
192    def _speak_sample(self):
193        """
194        Speak a test phrase
195        Using selected voice for desktop, or system voice on iphone
196        """
197        # if not self.selected_voice:
198        #     return
199        win = anvil.js.window
200        synth = win.speechSynthesis
201        synth.cancel()  # stop any previous speech
202        utter = win.SpeechSynthesisUtterance()
203        utter.text = "Inhale, hold, exhale, 3, 2, 1"
204        # voice has no effect on iphone
205        utter.voice = self.selected_voice
206        utter.rate = float(self.speech_rate)
207        utter.pitch = 1.0
208        synth.speak(utter)
209
210
211    class VoiceManager:
212        """Handles async loading of SpeechSynthesis voices."""
213        def __init__(self):
214            self.voices = None
215            self._callbacks = []
216            self._initialized = False
217
218        def _load_voices(self):
219            if self._initialized:
220                return
221            self._initialized = True
222
223            win = anvil.js.window
224            synth = win.speechSynthesis
225
226            def deliver():
227                # List of (name, lang, object)
228                self.voices = [(v.name, v.lang, v) for v in synth.getVoices()]
229                for cb in self._callbacks:
230                    cb(self.voices)
231                self._callbacks = []
232
233            # Already available
234            if synth.getVoices() and len(synth.getVoices()) > 0:
235                deliver()
236                return
237
238            # Otherwise wait for voiceschanged
239            def on_voices_changed(event):
240                synth.removeEventListener("voiceschanged", on_voices_changed)
241                deliver()
242
243            synth.addEventListener("voiceschanged", on_voices_changed)
244
245        def get_voices(self, callback=None):
246            self._load_voices()
247
248            if self.voices is not None:
249                if callback:
250                    callback(self.voices)
251                return self.voices
252
253            if callback:
254                self._callbacks.append(callback)
255            return None
256
257
258    def _log_audio_error(self, msg):
259        """Log errors to console and optionally to a label"""
260        print(msg)
261        if hasattr(self, "audio_debug_label"):
262            self.audio_debug_label.text += msg + "\n"
263
264    # -------------------------
265    # SOUNDS TEST BUTTON
266    # -------------------------
267    def sounds_test_click(self, **event_args):
268        """Test inhale, exhale, hold, and silence sounds sequentially."""
269        win = anvil.js.window
270        # Map of sounds; use the correct file paths
271        sounds = {
272            "inhale": "./_/theme/high.mp3",
273            "exhale": "./_/theme/low.mp3",
274            "hold":   "./_/theme/hold.mp3",
275            "silence": "./_/theme/silence.mp3"
276        }
277
278        # Clear debug label
279        if hasattr(self, "audio_debug_label"):
280            self.audio_debug_label.text = ""
281
282        # Recursive function to play the next sound
283        def play_next(keys, index=0):
284            if index >= len(keys):
285                return  # all done
286
287            key = keys[index]
288            src = sounds[key]
289
290            audio = win.Audio(src)
291            audio.volume = 1
292            audio.currentTime = 0
293
294            # Log errors
295            audio.onerror = lambda evt=None: self._log_audio_error(f"Failed to play {key}")
296
297            # When the sound ends, play the next one
298            audio.onended = lambda evt=None: play_next(keys, index + 1)
299
300            try:
301                p = audio.play()
302                if p:
303                    p.catch(lambda e: self._log_audio_error(f"Play blocked ({key}): {e}"))
304            except Exception as e:
305                self._log_audio_error(f"Exception ({key}): {e}")
306
307        # Start the sequence
308        play_next(list(sounds.keys()))

14.8. Settings form

Use the settings form to set the number of breathing cycles and the durations of the inhale, hold and exhale phases.
The design view of Settings form is shown below.
Use an XY panel to place the labels, dropdown and buttons precisely.
The buttons and the dropdown have click event handlers as shown in the python below.
The buttons have custom roles and css to modify their appearance.
../_images/settings.png
The python code for Settings is shown below.
  1from ._anvil_designer import SettingsTemplate
  2from anvil import *
  3
  4
  5from ... import breathing_settings
  6
  7class Settings(SettingsTemplate):
  8    def __init__(self, **properties):
  9        self.init_components(**properties)
 10        self.set_cycles()
 11
 12    def set_cycles(self):
 13        self.cycles.selected_value = str(breathing_settings.get_cycles())
 14
 15    def cycles_change(self, **event_args):
 16        cycles_val = int(self.cycles.selected_value)
 17        breathing_settings.set_cycles(cycles_val)
 18
 19    # =================================
 20    # 3
 21    # =================================
 22
 23    def relax_3_3_click(self, **event_args):
 24        breathing_settings.set_breath_pattern(3, 0, 3, 0)
 25
 26    def relax_3_0_3_3_click(self, **event_args):
 27        breathing_settings.set_breath_pattern(3, 0, 3, 3)
 28
 29    def relax_3_3_3_click(self, **event_args):
 30        breathing_settings.set_breath_pattern(3, 3, 3, 0)
 31
 32    def box3_click(self, **event_args):
 33        breathing_settings.set_breath_pattern(3, 3, 3, 3)
 34
 35
 36    # =================================
 37    # 4
 38    # =================================
 39
 40    def relax_4_4_click(self, **event_args):
 41        breathing_settings.set_breath_pattern(4, 0, 4, 0)
 42
 43    def relax_4_0_4_4_click(self, **event_args):
 44        breathing_settings.set_breath_pattern(4, 0, 4, 4)
 45
 46    def relax_4_4_4_click(self, **event_args):
 47        breathing_settings.set_breath_pattern(4, 4, 4, 0)
 48
 49    def box4_click(self, **event_args):
 50        breathing_settings.set_breath_pattern(4, 4, 4, 4)
 51
 52    # =================================
 53    # 5
 54    # =================================
 55
 56    def relax_5_5_click(self, **event_args):
 57        breathing_settings.set_breath_pattern(5, 0, 5, 0)
 58
 59    def relax_5_0_5_5_click(self, **event_args):
 60        breathing_settings.set_breath_pattern(5, 0, 5, 5)
 61
 62    def relax_5_5_5_click(self, **event_args):
 63        breathing_settings.set_breath_pattern(5, 5, 5, 0)
 64
 65    def box5_click(self, **event_args):
 66        breathing_settings.set_breath_pattern(5, 5, 5, 5)
 67
 68    # =================================
 69    # 6
 70    # =================================
 71
 72    def relax_6_6_click(self, **event_args):
 73        breathing_settings.set_breath_pattern(6, 0, 6, 0)
 74
 75    def relax_6_0_6_6_click(self, **event_args):
 76        breathing_settings.set_breath_pattern(6, 0, 6, 6)
 77
 78    def relax_6_6_6_click(self, **event_args):
 79        breathing_settings.set_breath_pattern(6, 6, 6, 0)
 80
 81    def box6_click(self, **event_args):
 82        breathing_settings.set_breath_pattern(6, 6, 6, 6)
 83
 84    # =================================
 85
 86    def box8_click(self, **event_args):
 87        breathing_settings.set_breath_pattern(8, 8, 8, 8)
 88
 89    def box10_click(self, **event_args):
 90        breathing_settings.set_breath_pattern(10, 10, 10, 10)
 91
 92    # =================================
 93
 94    def energize_2_2_click(self, **event_args):
 95        breathing_settings.set_breath_pattern(2, 0, 2, 0)
 96
 97    def energize_2_3_click(self, **event_args):
 98        breathing_settings.set_breath_pattern(2, 0, 3, 0)
 99
100    def relax_2_0_2_2_click(self, **event_args):
101        breathing_settings.set_breath_pattern(2, 0, 2, 2)
102
103    def relax_2_2_2_click(self, **event_args):
104        breathing_settings.set_breath_pattern(2, 2, 2, 0)
105
106    def box2_click(self, **event_args):
107        breathing_settings.set_breath_pattern(2, 2, 2, 2)
108
109    # =================================
110
111    def relax_2_6_click(self, **event_args):
112        breathing_settings.set_breath_pattern(2, 0, 6, 0)
113
114    def relax_3_6_click(self, **event_args):
115        breathing_settings.set_breath_pattern(3, 0, 6, 0)
116
117    def relax_4_6_click(self, **event_args):
118        breathing_settings.set_breath_pattern(4, 0, 6, 0)
119
120    def relax_4_8_click(self, **event_args):
121        breathing_settings.set_breath_pattern(4, 0, 8, 0)
122
123
124    def relax_4_2_4_click(self, **event_args):
125        breathing_settings.set_breath_pattern(4, 2, 4, 0)
126
127    def relax_4_2_6_click(self, **event_args):
128        breathing_settings.set_breath_pattern(4, 2, 6, 0)
129
130    def relax_4_2_8_click(self, **event_args):
131        breathing_settings.set_breath_pattern(4, 2, 8, 0)
132
133    def relax_4_2_4_2_click(self, **event_args):
134        breathing_settings.set_breath_pattern(4, 2, 4, 2)
135
136    def relax_4_2_6_2_click(self, **event_args):
137        breathing_settings.set_breath_pattern(4, 2, 6, 2)
138
139    def relax_4_2_8_2_click(self, **event_args):
140        breathing_settings.set_breath_pattern(4, 2, 8, 2)
141
142
143    def relax_4_4_8_click(self, **event_args):
144        breathing_settings.set_breath_pattern(4, 4, 8, 0)
145
146    def relax_4_7_8_click(self, **event_args):
147        breathing_settings.set_breath_pattern(4, 7, 8, 0)
148
149    def relax_4_0_6_6_click(self, **event_args):
150        breathing_settings.set_breath_pattern(4, 0, 6, 6)
151
152    def relax_4_0_6_8_click(self, **event_args):
153        breathing_settings.set_breath_pattern(4, 0, 6, 8)
154

14.9. Sliders form

Use the sliders form to set the manually adjust the the durations of the inhale, hold and exhale phases.
Use the sliders form to start, pause and stop the breathing slider animations.
The design view of Sliders form is shown below.
Use an XY panel to place the labels, dropdown and buttons precisely.
The buttons have custom roles and css to modify their appearance.
../_images/sliders.png
The python code for Sliders is shown below.
  1
  2# ============================================================
  3# AUDIO PLAYBACK DESIGN (IMPORTANT – READ BEFORE CHANGES)
  4# ============================================================
  5#
  6# This app intentionally uses a "fire-and-forget" audio model
  7# for short MP3 cues, combined with SpeechSynthesis for spoken
  8# guidance. The design reflects REAL, tested limitations of
  9# iOS WebKit-based browsers.
 10#
 11# ------------------------------------------------------------
 12# CRITICAL PLATFORM REALITY (iOS)
 13# ------------------------------------------------------------
 14#
 15# On iOS Safari and iOS Chrome:
 16#
 17# 1. SpeechSynthesis and HTMLAudioElement (MP3) are
 18#    MUTUALLY EXCLUSIVE per page session.
 19#
 20#    - If SpeechSynthesis is used first:
 21#        * Speech works reliably
 22#        * ALL subsequent MP3 playback FAILS silently
 23#
 24#    - If an MP3 is played first:
 25#        * The first MP3 may play
 26#        * SpeechSynthesis NEVER works
 27#        * Further MP3 playback is unreliable or blocked
 28#
 29#    There is NO recovery, unlock, delay, or workaround.
 30#
 31# 2. HTMLAudioElement cannot play a SEQUENCE of MP3s
 32#    unless EACH play is directly user-gesture driven.
 33#
 34#    - First MP3 triggered by a click: works
 35#    - Timer- or loop-driven MP3 playback: blocked
 36#
 37#    This applies even if SpeechSynthesis is disabled.
 38#
 39# ------------------------------------------------------------
 40# iOS FIREFOX NOTE
 41# ------------------------------------------------------------
 42#
 43# Firefox on iOS uses WebKit but exhibits DIFFERENT,
 44# less restrictive behaviour for short MP3 playback.
 45#
 46# • Sequential MP3 playback may work when Speech is OFF
 47# • Behaviour is not guaranteed and may change
 48# • Firefox is therefore treated as a "best-effort" case
 49#
 50# The app intentionally does NOT rely on Firefox-specific
 51# behaviour for correctness.
 52#
 53# On iOS, Firefox’s container is unusually permissive: it can keep a web page’s JavaScript and media playing for 30+ minutes without going to sleep, as long as there’s some audio activity (even small MP3s).
 54# Key points
 55# Safari/Chrome/WebKit tabs: suspend after ~5 minutes of inactivity, no way around it.
 56# Firefox for iOS: sandboxed container allows much longer runtime, observed 30+ min with audio playback.
 57# Reason: Firefox’s container manages the page lifecycle differently; it delays iOS’s suspension of inactive tabs while media is playing.
 58# No other browsers currently match this behavior on iOS; all others are essentially Safari under the hood.
 59# Caveat: This isn’t guaranteed — low battery, screen lock, or iOS updates could still pause the page.
 60# Firefox is the only practical iOS browser where long breathing cycles (20–30+ min) can run reliably, and MP3 playback helps maintain the active state.
 61
 62# ------------------------------------------------------------
 63# DESIGN CONSEQUENCES (INTENTIONAL)
 64# ------------------------------------------------------------
 65#
 66# • SpeechSynthesis is the ONLY reliable automated audio
 67#   system on iOS for timed / looping behaviour.
 68#
 69# • MP3 phase sounds are therefore:
 70#     - ENABLED on desktop browsers
 71#     - ENABLED on Android
 72#     - ENABLED on iPhone Firefox (when Speech is OFF)
 73#     - DISABLED on iOS when Speech is active
 74#
 75# This behaviour is intentional and unavoidable.
 76#
 77# ------------------------------------------------------------
 78# MP3 PLAYBACK MODEL (WHERE ALLOWED)
 79# ------------------------------------------------------------
 80#
 81# 1. _audio_map stores FILE PATHS ONLY (strings)
 82#    ------------------------------------------------
 83#    We do NOT store Audio() objects.
 84#
 85#    Each playback creates a FRESH Audio instance:
 86#        audio = window.Audio(path)
 87#        audio.play()
 88#
 89#     Reason:
 90#     - Reusing Audio objects causes dropped or blocked playback
 91#       due to overlapping play/pause/end state transitions,
 92#       especially on iOS WebKit.
 93
 94#
 95# 2. Fire-and-forget playback
 96#    ------------------------------------------------
 97#    Audio objects are NOT stored, paused, or reused.
 98#    They are allowed to finish naturally and be garbage collected.
 99#
100# 3. No global audio stop / pause
101#    ------------------------------------------------
102#    Because Audio instances are short-lived and not tracked,
103#    we intentionally DO NOT attempt to stop or pause them.
104#
105#    _stop_all_audio() is intentionally empty.
106#
107#    This avoids iOS WebKit bugs and timing races.
108#
109# ------------------------------------------------------------
110# SPEECH MODEL
111# ------------------------------------------------------------
112#
113# • SpeechSynthesis is managed independently
114# • speechSynthesis.cancel() is safe and immediate
115# • Speech is the PRIMARY audio channel on iOS
116#
117# ------------------------------------------------------------
118# DO NOT REFACTOR WITHOUT RE-TESTING ON REAL iOS DEVICES
119# ------------------------------------------------------------
120#
121# Desktop browser behaviour is NOT representative of iOS.
122# If audio or speech behaviour changes, test on:
123#   - iOS Safari
124#   - iOS Chrome
125#   - iOS Firefox
126#
127# This design is intentional, tested, and platform-constrained.
128# ============================================================
129
130
131
132from ._anvil_designer import SlidersTemplate
133from anvil import *
134import anvil.server
135import time
136import anvil.js
137
138from ... import breathing_settings
139
140
141def error_handler(err):
142    # Silent error handling for iOS speech quirks
143    return
144
145
146set_default_error_handling(error_handler)
147
148
149class Sliders(SlidersTemplate):
150    # -------------------------
151    # INIT
152    # -------------------------
153    def __init__(self, **properties):
154        self.init_components(**properties)
155        self.timer_sleep = 0.095     # at 0.1 seems to be about 10% too slow
156        self.run = False
157        self.paused = False
158        self.set_sliders_from_settings()
159        self.get_runner_settings()
160        self.set_audio_settings()
161        # Cache for full speech strings (cue + numbers)
162        self._speech_cache = {}
163
164    # -------------------------
165    # SETTINGS
166    # -------------------------
167
168    def set_audio_settings(self):
169        """
170        Store only file paths.
171        We intentionally DO NOT store Audio objects here.
172        Each play creates a fresh Audio() instance to avoid iOS Safari playback bugs.
173        """
174        self._audio_map = {
175            "inhale": "./_/theme/high.mp3",
176            "exhale": "./_/theme/low.mp3",
177            "hold": "./_/theme/hold.mp3",
178            "silence": "./_/theme/silence.mp3",
179        }
180
181    def set_sliders_from_settings(self):
182        # set values
183        self.breath_settings = breathing_settings.get_breath_pattern()
184        self.inhale_slider.value = self.breath_settings["inhale"]
185        self.hold1_slider.value = self.breath_settings["hold1"]
186        self.exhale_slider.value = self.breath_settings["exhale"]
187        self.hold2_slider.value = self.breath_settings["hold2"]
188        # set colours
189        self.slider_color_settings = breathing_settings.get_slider_color_settings()
190        self.inhale_slider.color = self.slider_color_settings["inhale"]
191        self.hold1_slider.color = self.slider_color_settings["hold1"]
192        self.exhale_slider.color = self.slider_color_settings["exhale"]
193        self.hold2_slider.color = self.slider_color_settings["hold2"]
194
195    def get_runner_settings(self):
196        self.cycles_to_go.text = breathing_settings.get_cycles()
197        self.play_phase_sounds = breathing_settings.get_play_phase_sounds_bool()
198        self.speak_phases = breathing_settings.get_speak_phases_bool()
199        self.speak_counts = breathing_settings.get_speak_counts_bool()
200        self.filler = breathing_settings.get_voice_filler()
201
202    # -------------------------
203    # speak text
204    # -------------------------
205
206    def _speak(self, text):
207        if not text:
208            return
209        win = anvil.js.window
210        synth = win.speechSynthesis
211        # synth.cancel()  # prevents queue drift (important!)
212        utt = win.SpeechSynthesisUtterance(str(text))
213        voice = breathing_settings.get_voice()
214        if voice:
215            utt.voice = voice
216        utt.rate = breathing_settings.get_voice_rate()
217        synth.speak(utt)
218
219    # -------------------------
220    # play mp3 reliably
221    # -------------------------
222
223    def _play_audio(self, cue_word="silence"):
224        win = anvil.js.window
225        src = self._audio_map.get(cue_word, self._audio_map["silence"])
226
227        # for testing
228        # self._log_audio(f"[AUDIO] Attempt play: {cue_word} -> {src}")
229
230        audio = win.Audio(src)
231        audio.currentTime = 0
232        # audio.volume = 1
233
234        # for testing
235        # audio.onplay = lambda evt=None: self._log_audio(f"[AUDIO] onplay: {cue_word}")
236        # audio.onended = lambda evt=None: self._log_audio(f"[AUDIO] ended: {cue_word}")
237        # audio.onerror = lambda evt=None: self._log_audio(f"[AUDIO] onerror: {cue_word}")
238
239        try:
240            p = audio.play()
241            if p:
242                pass
243                # p.then(lambda _: self._log_audio(f"[AUDIO] play() promise resolved: {cue_word}")) \
244                #     .catch(lambda e: self._log_audio(f"[AUDIO] play() promise rejected: {cue_word} -> {e}"))
245        except Exception as e:
246            pass
247            # self._log_audio(f"[AUDIO] Exception calling play(): {cue_word} -> {e}")
248
249
250    # -------------------------
251    # _speech_cache and run phase
252    # -------------------------
253
254    # not yet implemented so cache will grow with changes to patterns etc
255    def refresh_settings_cache(self):
256        self.filler = breathing_settings.get_voice_filler()
257        self.speak_phases = breathing_settings.get_sounds()
258        self.speak_counts = breathing_settings.get_counts()
259        self._speech_cache.clear()
260
261
262    def _get_phase_speech(self, phase, seconds):
263        """
264        Return cached speech string for a phase.
265        - phase: the slider component to animate, 'inhale', 'hold', 'exhale', 'hold', .
266        - seconds: duration of the phase in seconds
267        Only four entries cached for each play, depends on settings:
268        self.speak_phases, self.speak_counts, self.filler
269        # Cache only full phrase strings (not utterances).
270        # We do NOT cache SpeechSynthesisUtterance objects because
271        # voice/rate can change dynamically.
272        """
273        # key depends on settings that affect text
274        # Cache key includes all settings that affect spoken output
275        # so cache remains valid when user toggles options
276        key = (phase, seconds, self.speak_phases, self.speak_counts, self.filler)
277        if key in self._speech_cache:
278            return self._speech_cache[key]
279
280        parts = []
281        if self.speak_phases and phase:
282            parts.append(phase)
283
284        if seconds > 1 and self.speak_counts:
285            if self.speak_phases:
286                numbers = range(int(seconds-1), 0, -1)
287            else:
288                numbers = range(int(seconds), 0, -1)
289            # numbers = range(int(seconds-1), 0, -1)
290            parts.append(self.filler.join(str(i) for i in numbers))
291
292        full_text = " ".join(parts)
293        self._speech_cache[key] = full_text
294        #testing, comment out
295        # print(full_text)
296        return full_text
297
298    # -------------------------
299    # RUN PHASE
300    # -------------------------
301    def _run_phase(self, slider, seconds, slider_name, cue_word):
302        if not self.run or seconds == 0:
303            return
304        # for testing
305        # self._log_audio(f"[PHASE] {cue_word} for {seconds}s")
306
307        # Play audio
308        if self.play_phase_sounds:
309            self._play_audio(cue_word)
310        else:
311            self._play_audio("silence")
312
313        # Speak
314        full_speech = self._get_phase_speech(cue_word, seconds)
315        if full_speech:
316            self._speak(full_speech)
317
318        # Animate slider
319        original_color = slider.color
320        slider.color = breathing_settings.get_slider_animate_color(slider_name)
321        slider.handle_size = 16
322        slider.value = seconds
323
324        ticks = int(seconds * 10)
325        for i in range(ticks, -1, -1):
326            if not self.run:
327                break
328            while self.paused and self.run:
329                time.sleep(0.05)
330            slider.value = round(i / 10, 1)
331            time.sleep(self.timer_sleep)
332
333        slider.color = original_color
334        slider.handle_size = 32
335        slider.value = seconds
336
337    # -------------------------
338    # BREATH PHASES
339    # -------------------------
340    def inhale(self, val=4):
341        self._run_phase(self.inhale_slider, val, "inhale", "inhale")
342
343    def hold1(self, val=0):
344        self._run_phase(self.hold1_slider, val, "hold1", "hold")
345
346    def exhale(self, val=4):
347        self._run_phase(self.exhale_slider, val, "exhale", "exhale")
348
349    def hold2(self, val=0):
350        self._run_phase(self.hold2_slider, val, "hold2", "hold")
351
352    # -------------------------
353    # CONTROL BUTTONS
354    # -------------------------
355
356    def start_breathing_click(self, **event_args):
357        # for testing
358        # self._log_audio_clear()
359        self.run = True
360        # Snapshot current slider values and number of cycles
361        inhale_val = self.inhale_slider.value
362        hold1_val = self.hold1_slider.value
363        exhale_val = self.exhale_slider.value
364        hold2_val = self.hold2_slider.value
365        reps = int(self.cycles_to_go.text)
366
367        # Speak "Get ready" at the start
368        self._speak("Get ready.")
369        time.sleep(3)
370
371        # Main breathing loop
372        for i in range(reps):
373            if not self.run:
374                break
375            self.inhale(inhale_val)   # inhale phase with audio + slider
376            self.hold1(hold1_val)     # first hold
377            self.exhale(exhale_val)   # exhale
378            self.hold2(hold2_val)     # second hold
379            # Update cycles remaining in UI
380            self.cycles_to_go.text = reps - (i + 1)
381
382        # Reset cycles display at end
383        self.cycles_to_go.text = breathing_settings.get_cycles()
384
385
386
387    # ------------------------------------------------------
388    # _log_audio for testing
389    # ------------------------------------------------------
390
391    def _log_audio_clear(self):
392        if hasattr(self, "audio_debug_label"):
393            self.audio_debug_label.text = ""
394
395    def _log_audio(self, msg):
396        print(msg)
397        if hasattr(self, "audio_debug_label"):
398            self.audio_debug_label.text += msg + "\n"
399
400    def _log_audio_error(self, msg):
401        print(msg)
402        if hasattr(self, "audio_debug_label"):
403            self.audio_debug_label.text += msg + "\n"
404
405    # ------------------------------------------------------
406    # pause and stop
407    # ------------------------------------------------------
408
409    def pause_toggle_click(self, **event_args):
410        if not self.run:
411            return
412        if self.paused:
413            self._resume_all()
414        else:
415            self._pause_all()
416
417    def _pause_all(self):
418        self.paused = True
419        # Stop speech immediately
420        self._stop_speech()
421        # Stop all audio immediately
422        # self._stop_all_audio()
423
424    def _resume_all(self):
425        self.paused = False
426
427    # def _pause_all_audio(self):
428    #     # # LEGACY — not used.
429    #     # _audio_map stores file paths, not Audio objects.
430    #     # Pausing audio is intentionally unsupported.
431    #     for audio in self._audio_map.values():
432    #         audio.pause()
433
434    def stop_click(self, **event_args):
435        self.run = False
436        self.paused = False
437        # Stop speech immediately
438        self._stop_speech()
439        # Stop all audio immediately
440        # self._stop_all_audio()
441        # reset sliders
442        self._reset_all_sliders()
443
444    def _stop_speech(self):
445        win = anvil.js.window
446        synth = win.speechSynthesis
447        synth.cancel()   # IMMEDIATE stop + clears queue
448
449    def _stop_all_audio(self):
450        # Let short sounds finish naturally
451        # Intentionally empty.
452        # Audio is played via fresh, short-lived Audio() instances.
453        # We cannot (and should not) stop past instances.
454        # This avoids iOS Safari playback bugs.
455        pass
456
457    def _reset_all_sliders(self):
458        for slider_name in ("inhale", "hold1", "exhale", "hold2"):
459            slider = getattr(self, f"{slider_name}_slider")
460            slider.value = slider.value
461            slider.color = breathing_settings.get_slider_color_settings()[slider_name]
462
463    # ------------------------------------------------------
464    # leave form
465    # ------------------------------------------------------
466
467    def form_hide(self, **event_args):
468        """Called automatically when this form is removed from the page"""
469        self._hard_stop()
470
471    def _hard_stop(self):
472        self.run = False
473        self.paused = False
474        self._stop_speech()
475        # self._stop_all_audio()
476        self._reset_all_sliders()
477
478    # ------------------------------------------------------
479    # sliders
480    # ------------------------------------------------------
481
482
483    def inhale_slider_change(self, handle, **event_args):
484        """This method is called when the slider has finished sliding"""
485        pass
486
487    def inhale_slider_slide(self, handle, **event_args):
488        """This method is called when the slider is sliding or dragging"""
489        pass
490
491    def hold1_slider_change(self, handle, **event_args):
492        """This method is called when the slider has finished sliding"""
493        pass
494
495    def hold1_slider_slide(self, handle, **event_args):
496        """This method is called when the slider is sliding or dragging"""
497        pass
498
499    def exhale_slider_change(self, handle, **event_args):
500        """This method is called when the slider has finished sliding"""
501        pass
502
503    def exhale_slider_slide(self, handle, **event_args):
504        """This method is called when the slider is sliding or dragging"""
505        pass
506
507    def hold2_slider_change(self, handle, **event_args):
508        """This method is called when the slider has finished sliding"""
509        pass
510
511    def hold2_slider_slide(self, handle, **event_args):
512        """This method is called when the slider is sliding or dragging"""
513        pass
514
515
516
517
518
519
520
521
522

14.10. breathing_settings module

Use breathing_settings module to store and retrieve the breathing settings for the app.
The python code for breathing_settings is shown below.
  1# This is a module.
  2# You can define variables and functions here, and use them from any form.
  3# This helps the sliders form get setings from settings form and voices form
  4
  5# Internal storage for start for set_breath_patterns and settings
  6
  7import anvil.js
  8
  9def get_default_ua_speech_rate():
 10    win = anvil.js.window
 11    ua = win.navigator.userAgent.lower()
 12    # 2dp only from 0.20 to 1.40
 13    if "ipad" in ua:
 14        return "0.65"
 15    elif "macintosh" in ua:
 16        return "0.65"
 17    elif "iphone" in ua:
 18        return "0.65"
 19    elif "windows" in ua:
 20        return "0.70"
 21    elif "android" in ua:
 22        return "0.35"
 23    return "1.00"
 24
 25# ===============================================================
 26
 27_settings = {
 28    "inhale": 4,
 29    "hold1": 4,
 30    "exhale": 4,
 31    "hold2": 4,
 32    "cycles": 6,
 33    "play_phase_sounds": False,
 34    "speak_phases": True,
 35    "speak_counts": True,
 36}
 37
 38voice_rate = get_default_ua_speech_rate()
 39# print(voice_rate)
 40
 41_voice_settings = {
 42    "voice": None,
 43    "voice_rate": voice_rate,
 44    "voice_filler": " ,  ",
 45}
 46
 47# not used yet
 48_slider_theme_colors_settings = {
 49    "energy": '#F39C12',
 50    "calm": '#57b0eb',
 51    "sleep": '#ae2be4',
 52    "focus": '#2fcc35',
 53}
 54
 55_slider_color_settings = {
 56    "inhale": '#33cc33',
 57    "hold1": '#cccccc',
 58    "exhale": '#3399ff',
 59    "hold2": '#cccccc',
 60}
 61
 62_slider_animate_color_settings = {
 63    "inhale": '#99ff99',
 64    "hold1": '#e5e5e5',
 65    "exhale": '#cce6ff',
 66    "hold2": '#e5e5e5',
 67}
 68
 69# -------------------------
 70# _slider_color_settings
 71# -------------------------
 72
 73def get_slider_color_settings():
 74    return _slider_color_settings
 75
 76def get_slider_color(phase):
 77    return _slider_color_settings[phase]
 78
 79def get_slider_animate_color_settings():
 80    return _slider_animate_color_settings
 81
 82def get_slider_animate_color(phase):
 83    return _slider_animate_color_settings[phase]
 84
 85# -------------------------
 86# Breath pattern
 87# -------------------------
 88
 89def set_breath_pattern(inhale=4, hold1=4, exhale=4, hold2=4):
 90    _settings.update({
 91        "inhale": inhale,
 92        "hold1": hold1,
 93        "exhale": exhale,
 94        "hold2": hold2
 95    })
 96
 97def get_breath_pattern():
 98    # RETURN DICTIONARY
 99    return {
100        "inhale": _settings["inhale"],
101        "hold1": _settings["hold1"],
102        "exhale": _settings["exhale"],
103        "hold2": _settings["hold2"]
104    }
105
106# -------------------------
107# Cycles
108# -------------------------
109def set_cycles(cycles):
110    _settings["cycles"] = cycles
111
112def get_cycles():
113    return _settings["cycles"]
114
115# -------------------------
116# play_phase_sounds
117# -------------------------
118
119def set_play_phase_sounds_bool(bool):
120    _settings["play_phase_sounds"] = bool
121
122def get_play_phase_sounds_bool():
123    return _settings["play_phase_sounds"]
124
125# -------------------------
126# speak_phases
127# -------------------------
128
129def set_speak_phases_bool(bool):
130    _settings["speak_phases"] = bool
131
132def get_speak_phases_bool():
133    return _settings["speak_phases"]
134
135# -------------------------
136# Counts
137# -------------------------
138
139def set_speak_counts_bool(bool):
140    _settings["speak_counts"] = bool
141    # print(bool)
142
143def get_speak_counts_bool():
144    return _settings["speak_counts"]
145
146# -------------------------
147# Voice
148# -------------------------
149def set_voice(value):
150    _voice_settings["voice"] = value
151
152def get_voice():
153    return _voice_settings["voice"]
154
155# -------------------------
156# Voice rate
157# -------------------------
158def set_voice_rate(value):
159    _voice_settings["voice_rate"] = value
160
161def get_voice_rate():
162    return _voice_settings["voice_rate"]
163
164# -------------------------
165# Voice filler
166# -------------------------
167def set_voice_filler(self):
168    #  " ,  " is about it, nothing extra pads speech in silence
169    win = anvil.js.window
170    ua = win.navigator.userAgent.lower()
171    if "ipad" in ua:
172        value = " ,  "
173    elif "macintosh" in ua:
174        value = " ,  "
175    elif "iphone" in ua:
176        value = " ,  "
177    elif "windows" in ua:
178        value = " ,  "
179    elif "android" in ua:
180        value = " ,  "
181    else:
182        value = " ,  "
183    _voice_settings["voice_filler"] = value
184
185
186def get_voice_filler():
187    return _voice_settings["voice_filler"]

14.11. voice_bootstrap module

Use voice_bootstrap module to set the voice and rate for speech.
The python code for voice_bootstrap is shown below.
 1# voice_bootstrap.py
 2# Loads voices asynchronously
 3# Picks the first English voice for windows
 4# Stores it in breathing_settings
 5# Runs automatically
 6
 7import anvil.js
 8from . import breathing_settings
 9
10_initialized = False
11_callbacks = []
12
13def ensure_voice_ready(callback=None):
14    """
15    Ensures a voice is selected and stored.
16    Calls callback(voice) when ready.
17    """
18    global _initialized
19
20    voice = breathing_settings.get_voice()
21    if voice:
22        if callback:
23            callback(voice)
24        return
25
26    if callback:
27        _callbacks.append(callback)
28
29    if _initialized:
30        return
31
32    _initialized = True
33
34    win = anvil.js.window
35    synth = win.speechSynthesis
36
37    def select_default():
38        voices = synth.getVoices()
39        if not voices:
40            return
41
42        # Prefer English
43        en = [v for v in voices if v.lang.lower().startswith("en")]
44        chosen = en[0] if en else voices[0]    # 0  for first voice
45
46        breathing_settings.set_voice(chosen)
47
48        for cb in _callbacks:
49            cb(chosen)
50        _callbacks.clear()
51
52    # Voices already loaded
53    if synth.getVoices():
54        select_default()
55        return
56
57    # Wait for async load
58    def on_voices_changed(event):
59        synth.removeEventListener("voiceschanged", on_voices_changed)
60        select_default()
61
62    synth.addEventListener("voiceschanged", on_voices_changed)
63

14.12. Theme: Roles

To customize button colours efficiently add roles via the Theme Editor.
The names of new roles appear in the role dropdowns for components to easily assign them for custom css.
An example is shown below.
../_images/add_roles.png

14.13. Custom css

Custom css for the roles is added into a theme css file in the assets folder.
Custom css for the sliders and switches from anvil extras is also added here.
  1/* -------------------------------
  2GMC added  slider colour and handle
  3---------------------------------*/
  4/* Slim, round handle */
  5.noUi-horizontal .noUi-handle {
  6    width: 10px !important;      /* thin */
  7    /* height: 26px !important; */
  8    right: -5px !important;      /* half of width */
  9    /* top: -4px !important; */
 10
 11    border-radius: 999px !important;  /* fully round */
 12    border: 1px solid #90CAF9 !important;
 13
 14    background: linear-gradient(
 15        to bottom,
 16        #E3F2FD,
 17        #BBDEFB
 18    ) !important;
 19
 20    box-shadow:
 21        0 2px 4px rgba(0,0,0,0.25) !important;
 22}
 23
 24/* -------------------------------
 25GMC added  switch  width
 26might only keep the important, but left rest their in case want to modify more features
 27---------------------------------*/
 28
 29.ae-switch label .ae-switch-lever {
 30    content: "";
 31    display: inline-block;
 32    position: relative;
 33    width: 56px !important;
 34    height: 20px !important;
 35    background-color: rgba(0,0,0,0.38);
 36    border-radius: 15px;
 37    margin-right: 10px;
 38    -webkit-transition: background 0.3s ease;
 39    transition: background 0.3s ease;
 40    vertical-align: middle;
 41    margin: 0 16px;
 42}
 43
 44.ae-switch label input[type=checkbox]:checked+.ae-switch-lever:after, .ae-switch label input[type=checkbox]:checked+.ae-switch-lever:before {
 45    left: 48px !important;
 46}
 47
 48.ae-switch label .ae-switch-lever:after, .ae-switch label .ae-switch-lever:before {
 49    content: "";
 50    position: absolute;
 51    display: inline-block;
 52    width: 30px !important;
 53    height: 30px !important;
 54    border-radius: 50%;
 55    left: 0;
 56    top: -5px !important;
 57    -webkit-transition: left 0.3s ease, background 0.3s ease, -webkit-box-shadow 0.1s ease, -webkit-transform 0.1s ease;
 58    transition: left 0.3s ease, background 0.3s ease, -webkit-box-shadow 0.1s ease, -webkit-transform 0.1s ease;
 59    transition: left 0.3s ease, background 0.3s ease, box-shadow 0.1s ease, transform 0.1s ease;
 60    transition: left 0.3s ease, background 0.3s ease, box-shadow 0.1s ease, transform 0.1s ease, -webkit-box-shadow 0.1s ease, -webkit-transform 0.1s ease;
 61}
 62/* -------------------------------
 631) Calm / Relaxation (Blue)
 64---------------------------------*/
 65.anvil-role-calm-btn > .btn {
 66    background-color: #57b0eb;
 67    color: #ffffff;
 68
 69    font-size: 14px;
 70    padding: 6px 1px !important;
 71    min-height: 0 !important;
 72    line-height: 1.2;
 73    text-transform: none;
 74}
 75
 76.anvil-role-calm-deep-btn > .btn {
 77    background-color: #1b8eda;
 78    color: #ffffff;
 79
 80
 81    font-size: 14px;
 82    padding: 6px 1px !important;
 83    min-height: 0 !important;
 84    line-height: 1.2;
 85    text-transform: none;
 86}
 87
 88/* -------------------------------
 892) Focus / Attention / Control (Green)
 90---------------------------------*/
 91.anvil-role-focus-btn > .btn {
 92    background-color: #2fcc35;
 93    color: #ffffff;
 94
 95    font-size: 14px;
 96    padding: 6px 1px !important;
 97    min-height: 0 !important;
 98    line-height: 1.2;
 99    text-transform: none;
100}
101
102.anvil-role-focus-light-btn > .btn {
103    background-color: #56d85b;
104    color: #ffffff;
105
106    font-size: 14px;
107    padding: 6px 1px !important;
108    min-height: 0 !important;
109    line-height: 1.2;
110    text-transform: none;
111}
112
113/* -------------------------------
1143) Sleep / Deep Calm / Therapeutic (Purple)
115---------------------------------*/
116.anvil-role-sleep-btn > .btn {
117    background-color: #bf58ea;
118    color: #ffffff;
119
120    font-size: 14px;
121    padding: 6px 1px !important;
122    min-height: 0 !important;
123    line-height: 1.2;
124    text-transform: none;
125}
126
127.anvil-role-sleep-therapeutic-btn > .btn {
128    background-color:  #ae2be4;
129    color: #ffffff;
130    font-size: 14px;
131    padding: 6px 1px !important;
132    min-height: 0 !important;
133    line-height: 1.2;
134    text-transform: none;
135}
136
137/* -------------------------------
1384) Energise / Rapid Alertness (Bright Orange)
139---------------------------------*/
140.anvil-role-energise-btn > .btn {
141    background-color: #F39C12; /* bright orange */
142    color: #ffffff;             /* white text */
143
144    font-size: 14px;
145    padding: 6px 1px !important;
146    min-height: 0 !important;
147    line-height: 1.2;
148    text-transform: none;
149}
150
151.anvil-role-calm-btn > .btn:hover,
152.anvil-role-calm-deep-btn > .btn:hover,
153.anvil-role-focus-btn > .btn:hover,
154.anvil-role-focus-light-btn > .btn:hover,
155.anvil-role-sleep-btn > .btn:hover,
156.anvil-role-sleep-therapeutic-btn > .btn:hover,
157.anvil-role-energise-btn > .btn:hover {
158    filter: brightness(1.35);
159    color: #0000ff;
160}
161
162
163
164/*
165see https://anvil.works/articles/using-css#applying-css-via-python
166adding styles for btn role in title bar
167*/
168
169.anvil-role-title-btn .btn {
170    padding: 0px 0px;
171    margins: 0px 0px;
172}