breathe-cli 1.8__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
breathe.py ADDED
@@ -0,0 +1,700 @@
1
+ #!/usr/bin/env python3
2
+ """Breathe CLI — paced breathing for HFrEF vagal training. macOS only."""
3
+
4
+ import argparse
5
+ import os
6
+ import select
7
+ import signal
8
+ import shutil
9
+ import subprocess
10
+ import sys
11
+ import termios
12
+ import time
13
+ import tty
14
+ from dataclasses import dataclass
15
+
16
+ # ── Constants ────────────────────────────────────────────────────────
17
+
18
+ VERSION = '1.8'
19
+
20
+ PRESETS = {
21
+ 'balanced': {'duration_min': 10, 'inhale_s': 5, 'exhale_s': 5},
22
+ 'calm': {'duration_min': 15, 'inhale_s': 4, 'exhale_s': 6},
23
+ 'extended': {'duration_min': 20, 'inhale_s': 4, 'exhale_s': 6},
24
+ }
25
+
26
+ PRESET_DESCRIPTIONS = {'balanced': 'Equal ratio, neutral baseline',
27
+ 'calm': 'Exhale-weighted, parasympathetic emphasis',
28
+ 'extended': 'Full dose, Bernardi protocol'}
29
+
30
+ SOUND_INHALE = '/System/Library/Sounds/Tink.aiff'
31
+ SOUND_EXHALE = '/System/Library/Sounds/Pop.aiff'
32
+ AFPLAY = '/usr/bin/afplay'
33
+ AFPLAY_VOL = '0.3'
34
+
35
+ LOG_FILE = os.path.expanduser('~/.breathe_log.csv')
36
+ LOG_HEADER = 'date,time,preset,ratio,duration_target_s,duration_actual_s,breaths,completion_pct,status'
37
+
38
+ BAR_WIDTH = 30
39
+ FRAME_RATE_HZ = 20
40
+ FRAME_SLEEP = 1.0 / FRAME_RATE_HZ
41
+ COUNTDOWN_SECS = 3
42
+ MIN_TERM_WIDTH = 40
43
+ MIN_CYCLE_SECS = 8
44
+
45
+ ANSI_CLEAR = '\033[2J\033[H'
46
+ ANSI_HIDE_CUR = '\033[?25l'
47
+ ANSI_SHOW_CUR = '\033[?25h'
48
+ ANSI_RESET = '\033[0m'
49
+ ANSI_DIM = '\033[2m'
50
+ ANSI_CYAN = '\033[36m'
51
+ ANSI_GREEN = '\033[32m'
52
+ ANSI_CLR_LINE = '\033[K'
53
+
54
+ INHALE, EXHALE, PAUSED = 'INHALE', 'EXHALE', 'PAUSED'
55
+ PHASE_LABEL = {INHALE: 'IN', EXHALE: 'OUT'}
56
+
57
+ SAFETY_TEXT = """\
58
+ Breathe CLI \u2014 safety notes
59
+ \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
60
+
61
+ This app paces slow breathing at 6 breaths per minute for vagal tone
62
+ support. It is a habit tool, not a medical device.
63
+
64
+ STOP THE SESSION IMMEDIATELY if you experience:
65
+
66
+ \u2022 Lightheadedness or dizziness \u2014 you may be breathing too deeply.
67
+ Reduce depth, not rate. If it persists, stop.
68
+ \u2022 Palpitations \u2014 stop, note the time, mention at your next
69
+ cardiology visit.
70
+ \u2022 Tingling in hands or face \u2014 hyperventilation signal. Stop,
71
+ return to normal breathing.
72
+
73
+ This app deliberately does NOT support:
74
+ \u2022 Breath retention (kumbhaka) of any length
75
+ \u2022 Rapid breathing (kapalbhati, bhastrika, Wim Hof patterns)
76
+ \u2022 Total breath cycles shorter than 8 seconds
77
+
78
+ Press q or Ctrl+C to end any session. Exit is always immediate.
79
+
80
+ DISCLAIMER: This app is not a medical device. It does not diagnose,
81
+ treat, or prevent any condition. Consult your physician before starting
82
+ a breathing practice, especially with a cardiac or respiratory condition.
83
+ Use at your own risk. The author assumes no liability for any adverse
84
+ effects. By using this app you acknowledge and accept these terms.
85
+
86
+ For the clinical evidence behind these constraints, see README.md."""
87
+
88
+ @dataclass
89
+ class Config:
90
+ duration_s: int
91
+ inhale_s: int
92
+ exhale_s: int
93
+ preset_name: str # 'balanced', 'calm', 'extended', or 'custom'
94
+ sound_enabled: bool
95
+ quiet: bool
96
+
97
+ @property
98
+ def ratio_str(self):
99
+ return '{}-{}'.format(self.inhale_s, self.exhale_s)
100
+
101
+ @dataclass
102
+ class Result:
103
+ breaths: int = 0
104
+ elapsed: float = 0.0
105
+ completed: bool = False
106
+ aborted: bool = False
107
+
108
+ @dataclass
109
+ class Layout:
110
+ width: int
111
+ height: int
112
+ header_row: int
113
+ phase_row: int
114
+ bar_row: int
115
+ progress_row: int
116
+ footer_row: int
117
+ minimal: bool
118
+ use_colour: bool
119
+ use_unicode: bool
120
+
121
+ def supports_colour():
122
+ if os.environ.get('NO_COLOR'):
123
+ return False
124
+ return sys.stdout.isatty()
125
+
126
+ def supports_unicode():
127
+ enc = getattr(sys.stdout, 'encoding', '') or ''
128
+ return 'utf' in enc.lower()
129
+
130
+ def format_mmss(seconds):
131
+ m, s = divmod(int(seconds), 60)
132
+ return '{:02d}:{:02d}'.format(m, s)
133
+
134
+ def format_human(seconds):
135
+ m, s = divmod(int(seconds), 60)
136
+ if m > 0:
137
+ return '{} min {} s'.format(m, s)
138
+ return '{} s'.format(s)
139
+
140
+ def compute_layout():
141
+ size = shutil.get_terminal_size((80, 24))
142
+ w, h = size.columns, size.lines
143
+ minimal = w < MIN_TERM_WIDTH
144
+ mid = h // 2
145
+ return Layout(
146
+ width=w, height=h,
147
+ header_row=max(mid - 4, 1),
148
+ phase_row=max(mid - 1, 3),
149
+ bar_row=max(mid + 1, 5),
150
+ progress_row=max(mid + 3, 7),
151
+ footer_row=min(mid + 5, h),
152
+ minimal=minimal,
153
+ use_colour=supports_colour(),
154
+ use_unicode=supports_unicode(),
155
+ )
156
+
157
+ def check_audio(quiet):
158
+ """Init audio subsystem. Returns 'afplay' or 'bell'."""
159
+ if (os.path.isfile(AFPLAY) and os.access(AFPLAY, os.X_OK)
160
+ and os.path.isfile(SOUND_INHALE)
161
+ and os.path.isfile(SOUND_EXHALE)):
162
+ return 'afplay'
163
+ if not quiet:
164
+ sys.stderr.write('audio unavailable: falling back to terminal bell\n')
165
+ return 'bell'
166
+
167
+ def play_sound(phase, audio_mode):
168
+ if audio_mode == 'afplay':
169
+ path = SOUND_INHALE if phase == INHALE else SOUND_EXHALE
170
+ try:
171
+ subprocess.Popen(
172
+ [AFPLAY, '-v', AFPLAY_VOL, path],
173
+ stdout=subprocess.DEVNULL,
174
+ stderr=subprocess.DEVNULL,
175
+ )
176
+ except OSError:
177
+ pass
178
+ elif audio_mode == 'bell':
179
+ sys.stdout.write('\a')
180
+ sys.stdout.flush()
181
+
182
+ def setup_raw_tty():
183
+ if not sys.stdin.isatty():
184
+ return None
185
+ try:
186
+ old = termios.tcgetattr(sys.stdin)
187
+ tty.setcbreak(sys.stdin.fileno())
188
+ return old
189
+ except termios.error:
190
+ return None
191
+
192
+ def restore_tty(old_settings):
193
+ if old_settings is not None:
194
+ try:
195
+ termios.tcsetattr(sys.stdin, termios.TCSADRAIN, old_settings)
196
+ except termios.error:
197
+ pass
198
+
199
+ def poll_key():
200
+ if not sys.stdin.isatty():
201
+ return None
202
+ try:
203
+ r, _, _ = select.select([sys.stdin], [], [], 0)
204
+ if r:
205
+ return sys.stdin.read(1)
206
+ except (OSError, ValueError):
207
+ pass
208
+ return None
209
+
210
+ def move_to(row, col):
211
+ sys.stdout.write('\033[{};{}H'.format(row, col))
212
+
213
+ def draw_header(layout, config, remaining_s, paused, muted):
214
+ move_to(layout.header_row, 1)
215
+ sys.stdout.write(ANSI_CLR_LINE)
216
+ parts = []
217
+ if paused:
218
+ parts.append('\u2016' if layout.use_unicode else 'P')
219
+ if muted:
220
+ parts.append('\U0001f507' if layout.use_unicode else 'M')
221
+ if not parts:
222
+ parts.append('\u25cf' if layout.use_unicode else '*')
223
+ indicator = ' '.join(parts)
224
+ line = ' {} \u00b7 {} \u00b7 {} [{}]'.format(
225
+ config.preset_name, config.ratio_str,
226
+ format_mmss(remaining_s),
227
+ indicator,
228
+ )
229
+ sys.stdout.write(line)
230
+
231
+ def draw_phase(layout, phase):
232
+ move_to(layout.phase_row, 1)
233
+ sys.stdout.write(ANSI_CLR_LINE)
234
+ label = PHASE_LABEL.get(phase, phase)
235
+ if layout.use_colour:
236
+ colour = ANSI_CYAN if phase == INHALE else ANSI_GREEN
237
+ styled = colour + label + ANSI_RESET
238
+ else:
239
+ styled = label
240
+ if layout.minimal:
241
+ sys.stdout.write(' ' + styled)
242
+ else:
243
+ pad = (layout.width - len(label)) // 2
244
+ sys.stdout.write(' ' * pad + styled)
245
+
246
+ def draw_bar(layout, progress, phase):
247
+ move_to(layout.bar_row, 1)
248
+ sys.stdout.write(ANSI_CLR_LINE)
249
+ if phase == INHALE:
250
+ filled = round(progress * BAR_WIDTH)
251
+ else:
252
+ filled = round((1.0 - progress) * BAR_WIDTH)
253
+ filled = max(0, min(BAR_WIDTH, filled))
254
+ empty = BAR_WIDTH - filled
255
+ if layout.use_unicode:
256
+ bar = '\u2588' * filled + '\u2591' * empty
257
+ else:
258
+ bar = '#' * filled + '-' * empty
259
+ if layout.minimal:
260
+ sys.stdout.write(' ' + bar)
261
+ else:
262
+ pad = (layout.width - BAR_WIDTH) // 2
263
+ sys.stdout.write(' ' * pad + bar)
264
+
265
+ def draw_progress(layout, config, elapsed):
266
+ move_to(layout.progress_row, 1)
267
+ sys.stdout.write(ANSI_CLR_LINE)
268
+ cycle_s = config.inhale_s + config.exhale_s
269
+ frac = min(1.0, elapsed / config.duration_s) if config.duration_s > 0 else 1.0
270
+ filled = round(frac * BAR_WIDTH)
271
+ filled = max(0, min(BAR_WIDTH, filled))
272
+ empty = BAR_WIDTH - filled
273
+ if layout.use_unicode:
274
+ bar = '\u2501' * filled + '\u2500' * empty
275
+ else:
276
+ bar = '=' * filled + '-' * empty
277
+ if layout.use_colour:
278
+ bar = ANSI_DIM + bar + ANSI_RESET
279
+ if layout.minimal:
280
+ sys.stdout.write(' ' + bar)
281
+ else:
282
+ pad = (layout.width - BAR_WIDTH) // 2
283
+ sys.stdout.write(' ' * pad + bar)
284
+
285
+ def draw_footer(layout, paused):
286
+ move_to(layout.footer_row, 1)
287
+ sys.stdout.write(ANSI_CLR_LINE)
288
+ if paused:
289
+ text = 'space resume \u00b7 q quit'
290
+ else:
291
+ text = 'space pause \u00b7 s mute \u00b7 q quit'
292
+ if layout.use_colour:
293
+ text = ANSI_DIM + text + ANSI_RESET
294
+ sys.stdout.write(' ' + text)
295
+
296
+ def render_frame(layout, config, elapsed, remaining_s, phase, progress,
297
+ paused, muted):
298
+ draw_header(layout, config, remaining_s, paused, muted)
299
+ draw_phase(layout, phase)
300
+ draw_bar(layout, progress, phase)
301
+ draw_progress(layout, config, elapsed)
302
+ draw_footer(layout, paused)
303
+ sys.stdout.flush()
304
+
305
+ _abort = [False]
306
+
307
+ def _sigint_handler(signum, frame):
308
+ _abort[0] = True
309
+
310
+ def run_countdown(layout, config):
311
+ """Run the 3-second settle countdown. Returns False if aborted."""
312
+ for i in range(COUNTDOWN_SECS, 0, -1):
313
+ if _abort[0]:
314
+ return False
315
+ move_to(layout.phase_row, 1)
316
+ sys.stdout.write(ANSI_CLR_LINE)
317
+ label = str(i)
318
+ if layout.minimal:
319
+ sys.stdout.write(' ' + label)
320
+ else:
321
+ pad = (layout.width - len(label)) // 2
322
+ sys.stdout.write(' ' * pad + label)
323
+ draw_header(layout, config, config.duration_s, False, False)
324
+ draw_bar(layout, 0.0, INHALE)
325
+ draw_footer(layout, False)
326
+ sys.stdout.flush()
327
+ for _ in range(FRAME_RATE_HZ):
328
+ if _abort[0]:
329
+ return False
330
+ key = poll_key()
331
+ if key == 'q':
332
+ return False
333
+ time.sleep(FRAME_SLEEP)
334
+ return True
335
+
336
+ def run_session(config, result):
337
+ is_tty = sys.stdout.isatty() and sys.stdin.isatty()
338
+
339
+ # ── Non-TTY path ─────────────────────────────────────────────
340
+ if not is_tty:
341
+ if not config.quiet:
342
+ sys.stderr.write('Warning: not a TTY, running without animation.\n')
343
+ start = time.monotonic()
344
+ cycle_s = config.inhale_s + config.exhale_s
345
+ try:
346
+ time.sleep(config.duration_s)
347
+ result.completed = True
348
+ except KeyboardInterrupt:
349
+ result.aborted = True
350
+ result.elapsed = min(time.monotonic() - start, float(config.duration_s))
351
+ result.breaths = int(result.elapsed // cycle_s)
352
+ return
353
+
354
+ audio_mode = check_audio(config.quiet) if config.sound_enabled else 'none'
355
+ layout = compute_layout()
356
+ if layout.minimal and not config.quiet:
357
+ sys.stderr.write('Warning: terminal narrow, running in minimal mode.\n')
358
+
359
+ old_termios = setup_raw_tty()
360
+ old_sigint = signal.signal(signal.SIGINT, _sigint_handler)
361
+ _abort[0] = False
362
+
363
+ muted = not config.sound_enabled
364
+
365
+ try:
366
+ sys.stdout.write(ANSI_HIDE_CUR)
367
+ sys.stdout.write(ANSI_CLEAR)
368
+ sys.stdout.flush()
369
+
370
+ if not run_countdown(layout, config):
371
+ result.aborted = True
372
+ return
373
+
374
+ cycle_s = config.inhale_s + config.exhale_s
375
+ state = INHALE
376
+ phase_start_wall = time.monotonic()
377
+ breathing_base = 0.0
378
+
379
+ if not muted and audio_mode != 'none':
380
+ play_sound(INHALE, audio_mode)
381
+
382
+ while True:
383
+ now = time.monotonic()
384
+
385
+ # ── PAUSED ──────────────────────────────────────
386
+ if state == PAUSED:
387
+ if _abort[0]:
388
+ result.aborted = True
389
+ break
390
+ key = poll_key()
391
+ if key == 'q':
392
+ result.aborted = True
393
+ break
394
+ elif key == ' ':
395
+ if breathing_base >= config.duration_s:
396
+ render_frame(layout, config, config.duration_s, 0,
397
+ EXHALE, 1.0, False, muted)
398
+ time.sleep(0.4)
399
+ result.completed = True
400
+ break
401
+ state = INHALE
402
+ phase_start_wall = now
403
+ if not muted and audio_mode != 'none':
404
+ play_sound(INHALE, audio_mode)
405
+ elif key == 's':
406
+ muted = not muted
407
+ if state == PAUSED:
408
+ render_frame(layout, config, paused_elapsed,
409
+ paused_remaining, paused_phase,
410
+ paused_progress, True, muted)
411
+ time.sleep(FRAME_SLEEP)
412
+ continue
413
+ # Resume: fall through to active code
414
+
415
+ # ── INHALE / EXHALE ─────────────────────────────
416
+ if _abort[0]:
417
+ result.aborted = True
418
+ break
419
+
420
+ phase_dur = (config.inhale_s if state == INHALE
421
+ else config.exhale_s)
422
+ phase_elapsed = now - phase_start_wall
423
+ progress = phase_elapsed / phase_dur
424
+
425
+ # Phase transition
426
+ if progress >= 1.0:
427
+ if state == INHALE:
428
+ phase_start_wall += config.inhale_s
429
+ state = EXHALE
430
+ if not muted and audio_mode != 'none':
431
+ play_sound(EXHALE, audio_mode)
432
+ else:
433
+ result.breaths += 1
434
+ breathing_base = result.breaths * cycle_s
435
+ if breathing_base >= config.duration_s:
436
+ render_frame(layout, config, config.duration_s, 0,
437
+ EXHALE, 1.0, False, muted)
438
+ time.sleep(0.4)
439
+ result.completed = True
440
+ break
441
+ phase_start_wall += config.exhale_s
442
+ state = INHALE
443
+ if not muted and audio_mode != 'none':
444
+ play_sound(INHALE, audio_mode)
445
+ # Recalculate for the new phase so the render below
446
+ # shows the correct label and bar on the same frame
447
+ # the sound fires (no stale-frame flicker).
448
+ phase_dur = (config.inhale_s if state == INHALE
449
+ else config.exhale_s)
450
+ phase_elapsed = now - phase_start_wall
451
+ progress = phase_elapsed / phase_dur
452
+
453
+ # Countdown: integer seconds remaining based on actual
454
+ # session length (which is always a multiple of cycle_s).
455
+ clean_phase_s = int(phase_elapsed)
456
+ if state == INHALE:
457
+ elapsed_display = breathing_base + phase_elapsed
458
+ remaining_s = (config.duration_s - breathing_base
459
+ - clean_phase_s)
460
+ else:
461
+ elapsed_display = (breathing_base + config.inhale_s
462
+ + phase_elapsed)
463
+ remaining_s = (config.duration_s - breathing_base
464
+ - config.inhale_s - clean_phase_s)
465
+
466
+ key = poll_key()
467
+ if key == 'q':
468
+ result.aborted = True
469
+ break
470
+ elif key == ' ':
471
+ paused_phase = state
472
+ paused_progress = progress
473
+ paused_elapsed = elapsed_display
474
+ paused_remaining = remaining_s
475
+ state = PAUSED
476
+ render_frame(layout, config, paused_elapsed,
477
+ paused_remaining, paused_phase,
478
+ paused_progress, True, muted)
479
+ time.sleep(FRAME_SLEEP)
480
+ continue
481
+ elif key == 's':
482
+ muted = not muted
483
+
484
+ render_frame(layout, config, elapsed_display, remaining_s,
485
+ state, progress, False, muted)
486
+ time.sleep(FRAME_SLEEP)
487
+
488
+ result.elapsed = breathing_base
489
+
490
+ finally:
491
+ sys.stdout.write(ANSI_SHOW_CUR)
492
+ sys.stdout.write(ANSI_RESET)
493
+ move_to(layout.footer_row + 2, 1)
494
+ sys.stdout.flush()
495
+ restore_tty(old_termios)
496
+ signal.signal(signal.SIGINT, old_sigint)
497
+
498
+ def _completion(config, result):
499
+ pct = min(100, int(result.elapsed / config.duration_s * 100)) if config.duration_s > 0 else 100
500
+ status = 'completed' if result.completed else 'ended early (user)'
501
+ return pct, status
502
+
503
+ def print_summary(config, result):
504
+ label = config.preset_name if config.preset_name != 'custom' else 'custom'
505
+ target = '{} min ({}, {})'.format(config.duration_s // 60, label, config.ratio_str)
506
+ pct, status = _completion(config, result)
507
+ print('Session summary')
508
+ print('\u2500' * 15)
509
+ print('Target: {}'.format(target))
510
+ print('Completed: {} ({}%)'.format(format_human(result.elapsed), pct))
511
+ print('Breaths: {} full cycles'.format(result.breaths))
512
+ print('Status: {}'.format(status))
513
+
514
+ def log_session(config, result, session_start_time):
515
+ """Append one CSV row to ~/.breathe_log.csv. Never raises."""
516
+ try:
517
+ write_header = not os.path.isfile(LOG_FILE)
518
+ with open(LOG_FILE, 'a') as f:
519
+ if write_header:
520
+ f.write(LOG_HEADER + '\n')
521
+ pct, status = _completion(config, result)
522
+ row = '{},{},{},{},{},{},{},{},{}'.format(
523
+ time.strftime('%Y-%m-%d', session_start_time),
524
+ time.strftime('%H:%M:%S', session_start_time),
525
+ config.preset_name,
526
+ config.ratio_str,
527
+ config.duration_s,
528
+ int(result.elapsed),
529
+ result.breaths,
530
+ pct,
531
+ status,
532
+ )
533
+ f.write(row + '\n')
534
+ except OSError as e:
535
+ sys.stderr.write('Warning: could not write session log: {}\n'.format(e))
536
+
537
+ def print_log_path():
538
+ if os.path.isfile(LOG_FILE):
539
+ print(LOG_FILE)
540
+ else:
541
+ print('{} (no sessions logged yet)'.format(LOG_FILE))
542
+
543
+ def print_safety():
544
+ print(SAFETY_TEXT)
545
+
546
+ def print_presets():
547
+ print('Available presets:\n')
548
+ fmt = ' {:<10} {:>8} {:<20} {}'
549
+ print(fmt.format('Name', 'Duration', 'Ratio (in-ex)', 'Target use'))
550
+ print(fmt.format('\u2500' * 10, '\u2500' * 8, '\u2500' * 20, '\u2500' * 24))
551
+ for name, p in PRESETS.items():
552
+ bpm = 60.0 / (p['inhale_s'] + p['exhale_s'])
553
+ ratio = '{}s-{}s ({:.0f} bpm)'.format(p['inhale_s'], p['exhale_s'], bpm)
554
+ print(fmt.format(name, '{} min'.format(p['duration_min']),
555
+ ratio, PRESET_DESCRIPTIONS[name]))
556
+
557
+ def _die(msg):
558
+ sys.stderr.write('Error: ' + msg + '\n')
559
+ sys.exit(1)
560
+
561
+ def parse_ratio(ratio_str):
562
+ _fmt_err = 'Ratio must be in the form `inhale-exhale` (e.g. `5-5` or `4-6`).'
563
+ parts = ratio_str.split('-')
564
+ if len(parts) > 2:
565
+ _die('Three-number ratios imply a breath hold. '
566
+ 'This app does not support breath retention. See `breathe --safety`.')
567
+ if len(parts) != 2:
568
+ _die(_fmt_err)
569
+ try:
570
+ inhale, exhale = int(parts[0]), int(parts[1])
571
+ except ValueError:
572
+ _die(_fmt_err)
573
+ if inhale + exhale < MIN_CYCLE_SECS:
574
+ _die('Total breath cycle must be \u2265 8 seconds (no rapid breathing).')
575
+ if not (3 <= inhale <= 10):
576
+ _die('Inhale must be 3\u201310 seconds.')
577
+ if not (3 <= exhale <= 10):
578
+ _die('Exhale must be 3\u201310 seconds.')
579
+ if exhale > 2 * inhale:
580
+ _die('Exhale must not exceed twice the inhale (no clinical evidence'
581
+ ' for extreme ratios). See README.md for details.')
582
+ return inhale, exhale
583
+
584
+ def build_parser():
585
+ parser = argparse.ArgumentParser(
586
+ prog='breathe',
587
+ description='Paced breathing for HFrEF vagal training.',
588
+ epilog='Example: breathe --preset balanced',
589
+ )
590
+ parser.add_argument('--version', action='version',
591
+ version='breathe {}'.format(VERSION))
592
+ parser.add_argument('--safety', action='store_true',
593
+ help='Show safety information and exit')
594
+ parser.add_argument('--list-presets', action='store_true',
595
+ help='Show available presets and exit')
596
+ parser.add_argument('--preset', '-p', choices=list(PRESETS.keys()),
597
+ help='Use a named preset (balanced, calm, extended)')
598
+ parser.add_argument('--duration', '-d', type=int, metavar='MINUTES',
599
+ help='Session duration in minutes (1\u201360, default: 10)')
600
+ parser.add_argument('--ratio', '-r', metavar='IN-EX',
601
+ help='Breath ratio as inhale-exhale (e.g. 5-5 or 4-6)')
602
+ parser.add_argument('--no-sound', '-n', action='store_true',
603
+ help='Disable audio cues')
604
+ parser.add_argument('--quiet', '-q', action='store_true',
605
+ help='Suppress startup warnings')
606
+ parser.add_argument('--log', action='store_true',
607
+ help='Show log file path and exit')
608
+ parser.add_argument('--no-log', action='store_true',
609
+ help='Suppress session logging for this run')
610
+ return parser
611
+
612
+ def main():
613
+ if sys.version_info < (3, 7):
614
+ sys.stderr.write('Error: breathe requires Python 3.7+\n')
615
+ sys.exit(1)
616
+
617
+ parser = build_parser()
618
+ args = parser.parse_args()
619
+
620
+ if args.safety:
621
+ print_safety()
622
+ sys.exit(0)
623
+
624
+ if args.log:
625
+ print_log_path()
626
+ sys.exit(0)
627
+
628
+ if args.list_presets:
629
+ print_presets()
630
+ sys.exit(0)
631
+
632
+ # Build config from args
633
+ if args.preset:
634
+ if args.duration is not None or args.ratio is not None:
635
+ _die('--preset cannot be combined with --duration or --ratio.')
636
+ p = PRESETS[args.preset]
637
+ inhale_s, exhale_s = p['inhale_s'], p['exhale_s']
638
+ duration_min = p['duration_min']
639
+ preset_name = args.preset
640
+ elif args.duration is not None or args.ratio is not None:
641
+ inhale_s, exhale_s = 5, 5
642
+ duration_min = 10
643
+ preset_name = 'custom'
644
+ if args.ratio:
645
+ inhale_s, exhale_s = parse_ratio(args.ratio)
646
+ if args.duration is not None:
647
+ duration_min = args.duration
648
+ else:
649
+ # No args: auto-select preset by time of day
650
+ hour = time.localtime().tm_hour
651
+ if hour < 12:
652
+ preset_name = 'balanced'
653
+ elif hour < 17:
654
+ preset_name = 'extended'
655
+ else:
656
+ preset_name = 'calm'
657
+ p = PRESETS[preset_name]
658
+ inhale_s, exhale_s = p['inhale_s'], p['exhale_s']
659
+ duration_min = p['duration_min']
660
+
661
+ if not (1 <= duration_min <= 60):
662
+ _die('Duration must be 1\u201360 minutes.')
663
+
664
+ # Round duration up to a whole number of breath cycles so that
665
+ # the countdown, progress bar, and session end are all in sync.
666
+ cycle_s = inhale_s + exhale_s
667
+ duration_s = -(-duration_min * 60 // cycle_s) * cycle_s
668
+
669
+ config = Config(
670
+ duration_s=duration_s,
671
+ inhale_s=inhale_s,
672
+ exhale_s=exhale_s,
673
+ preset_name=preset_name,
674
+ sound_enabled=not args.no_sound,
675
+ quiet=args.quiet,
676
+ )
677
+
678
+ result = Result()
679
+ exc_info = None
680
+ session_start_time = time.localtime()
681
+
682
+ try:
683
+ run_session(config, result)
684
+ except KeyboardInterrupt:
685
+ result.aborted = True
686
+ except Exception:
687
+ exc_info = sys.exc_info()
688
+
689
+ print_summary(config, result)
690
+
691
+ if not args.no_log:
692
+ log_session(config, result, session_start_time)
693
+
694
+ if exc_info is not None:
695
+ import traceback
696
+ traceback.print_exception(*exc_info)
697
+ sys.exit(1)
698
+
699
+ if __name__ == '__main__':
700
+ main()
@@ -0,0 +1,263 @@
1
+ Metadata-Version: 2.4
2
+ Name: breathe-cli
3
+ Version: 1.8
4
+ Summary: Paced resonance breathing for vagal tone training. Single-file terminal app, no dependencies.
5
+ Author-email: Marek Kowalczyk <mko1971@gmail.com>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/marekkowalczyk/breathe-cli
8
+ Project-URL: Documentation, https://marekkowalczyk.github.io/breathe-cli/
9
+ Project-URL: Issues, https://github.com/marekkowalczyk/breathe-cli/issues
10
+ Keywords: breathing,resonance-breathing,hrv,heart-rate-variability,vagal-tone,biofeedback,cli,terminal,health
11
+ Classifier: Development Status :: 5 - Production/Stable
12
+ Classifier: Environment :: Console
13
+ Classifier: Intended Audience :: End Users/Desktop
14
+ Classifier: Intended Audience :: Healthcare Industry
15
+ Classifier: Operating System :: MacOS
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Topic :: Scientific/Engineering :: Medical Science Apps.
18
+ Requires-Python: >=3.7
19
+ Description-Content-Type: text/markdown
20
+ License-File: LICENSE
21
+ Dynamic: license-file
22
+
23
+ # Breathe CLI
24
+
25
+ A terminal app that paces resonance breathing for vagal tone training. macOS only, single file, no dependencies.
26
+
27
+ ```
28
+ $ breathe
29
+
30
+ calm · 4-6 · 14:32 [●]
31
+
32
+ INHALE
33
+
34
+ ██████████████░░░░░░░░░░░░░░░░
35
+
36
+ space pause · s mute · q quit
37
+ ```
38
+
39
+ ## Why this exists
40
+
41
+ Resonance breathing — slow, paced breathing at around 6 breaths per minute — is one of the few non-pharmacological interventions shown to improve cardiac vagal tone. The mechanism is straightforward: slow breathing amplifies respiratory sinus arrhythmia (RSA), the natural heart-rate variation linked to the breath cycle. Stronger RSA means stronger vagal outflow, which in turn improves baroreceptor sensitivity and shifts autonomic balance away from sympathetic dominance.
42
+
43
+ This matters most for people with heart failure with reduced ejection fraction (HFrEF), where sympathetic overdrive is both a symptom and an accelerant of disease progression. Bernardi et al. (1998) demonstrated that slow breathing at 6 bpm improves oxygen saturation and exercise tolerance in CHF patients, with effects visible after a single session. A follow-up study (Bernardi et al. 2002) showed that slow breathing also increases arterial baroreflex sensitivity in CHF — a marker strongly associated with prognosis.
44
+
45
+ This app is a habit tool that makes daily practice frictionless: open terminal, run `breathe`, follow the bar. It is not a medical device.
46
+
47
+ ### The science in brief
48
+
49
+ **Why 6 breaths per minute?** The cardiovascular system has a resonance frequency — typically between 4.5 and 6.5 bpm in adults — at which heart rate oscillations are maximally amplified (Vaschillo et al. 2006). Breathing at or near this frequency produces the largest RSA swings, which drive the strongest vagal training stimulus. Individual resonance frequency varies and can only be identified precisely with HRV biofeedback hardware. Without it, 6 bpm is the best population-level default: it sits at the centre of the typical range and matches the rate used in the CHF clinical trials (Bernardi et al. 1998, 2002).
50
+
51
+ **Why a longer exhale in the `calm` and `extended` presets?** Cardiac vagal efferent activity is gated to the respiratory cycle — vagal outflow is stronger during expiration than inspiration. A longer exhale (4s in, 6s out) extends the phase of peak vagal drive within each breath, biasing the autonomic balance further toward parasympathetic tone (Russo et al. 2017, Lehrer & Gevirtz 2014). The total cycle is still 10 seconds (6 bpm). The `balanced` preset uses equal timing (5-5) as a neutral baseline; the `calm` and `extended` presets use the exhale-weighted ratio for parasympathetic emphasis.
52
+
53
+ **Why these safety constraints?** See the [Design choices](#design-choices) section below. Each constraint maps to a specific physiological risk that is elevated in cardiac patients.
54
+
55
+ ### Finding your resonance frequency
56
+
57
+ The presets use 6 bpm because it works well for most people and matches the clinical trial protocols. But individual resonance frequency varies — typically between 4.5 and 6.5 bpm — and breathing at *your* resonance frequency produces a stronger vagal training stimulus than breathing at the population average (Vaschillo et al. 2006).
58
+
59
+ If you have HRV biofeedback hardware, you can find your personal optimum. If you don't, the 6 bpm default is a good choice — consistent daily practice matters more than nailing the exact frequency.
60
+
61
+ #### What you need
62
+
63
+ - A chest-strap heart rate monitor (e.g. Polar H10, Garmin HRM-Pro). Wrist-based optical sensors are not accurate enough for beat-to-beat HRV.
64
+ - Software that displays real-time R-R intervals or HRV metrics: [Kubios](https://www.kubios.com), [Elite HRV](https://elitehrv.com), [HRV4Training](https://www.hrv4training.com), or a dedicated biofeedback system.
65
+
66
+ #### Protocol
67
+
68
+ Run this test sitting upright in a quiet room, at the same time of day you normally practice. The whole procedure takes about 30 minutes.
69
+
70
+ 1. **Baseline** (2 min). Breathe normally. Let your heart rate settle. Start your HRV recording.
71
+ 2. **Test rate 1: 6.0 bpm** (3 min). Run `breathe --ratio 5-5 -d 3 --no-log`. Follow the pacer. At the end, note the average RMSSD (or, if your software shows a live heart rate trace, note how wide the oscillations are — peak-to-trough in bpm).
72
+ 3. **Rest** (1–2 min). Breathe normally.
73
+ 4. **Test rate 2: 5.5 bpm** (3 min). Run `breathe --ratio 5-6 -d 3 --no-log`. Note the same metric.
74
+ 5. **Rest** (1–2 min).
75
+ 6. **Test rate 3: 5.0 bpm** (3 min). Run `breathe --ratio 6-6 -d 3 --no-log`. Note the same metric.
76
+ 7. **Rest** (1–2 min).
77
+ 8. **Test rate 4: 4.6 bpm** (3 min). Run `breathe --ratio 6-7 -d 3 --no-log`. Note the same metric.
78
+
79
+ **Interpreting results:** The rate that produces the highest RMSSD, the highest LF power in the HRV spectrum, or the visibly widest heart rate oscillations is your resonance frequency. If two adjacent rates are close, pick the slower one — it's more comfortable for long sessions.
80
+
81
+ **Limitations:** Phase durations are whole seconds, so only certain BPMs are representable: 4.6, 5.0, 5.5, 6.0, 6.7, 7.5 bpm. Your true resonance might fall between two testable rates. Pick the closest one. The difference in training effect between 5.0 and 5.5 bpm is small.
82
+
83
+ #### Using your frequency
84
+
85
+ Once you know your frequency, use `--ratio` to match it:
86
+
87
+ ```bash
88
+ breathe --ratio 6-7 # 13s cycle = 4.6 bpm
89
+ breathe --ratio 6-6 # 12s cycle = 5.0 bpm
90
+ breathe --ratio 5-6 # 11s cycle = 5.5 bpm
91
+ breathe --ratio 5-5 # 10s cycle = 6.0 bpm (default)
92
+ ```
93
+
94
+ You can also add exhale emphasis at your resonance frequency:
95
+
96
+ ```bash
97
+ breathe --ratio 5-7 # 12s cycle = 5.0 bpm, exhale-weighted
98
+ breathe --ratio 4-7 # 11s cycle = 5.5 bpm, exhale-weighted
99
+ breathe --ratio 4-8 # 12s cycle = 5.0 bpm, strong exhale emphasis
100
+ ```
101
+
102
+ ### References
103
+
104
+ - Bernardi L, Spadacini G, Bellwon J, et al. ["Effect of breathing rate on oxygen saturation and exercise performance in chronic heart failure."](https://doi.org/10.1016/S0140-6736(97)10341-5) *Lancet*. 1998;351(9112):1308-1311.
105
+ - Bernardi L, Porta C, Spicuzza L, et al. ["Slow breathing increases arterial baroreflex sensitivity in patients with chronic heart failure."](https://doi.org/10.1161/hc0202.103311) *Circulation*. 2002;105(2):143-145.
106
+ - Bernardi L, Sleight P, Bandinelli G, et al. ["Effect of rosary prayer and yoga mantras on autonomic cardiovascular rhythms."](https://doi.org/10.1136/bmj.323.7327.1446) *BMJ*. 2001;323:1446.
107
+ - Vaschillo EG, Vaschillo B, Lehrer PM. ["Characteristics of resonance in heart rate variability stimulated by biofeedback."](https://doi.org/10.1007/s10484-006-9009-3) *Appl Psychophysiol Biofeedback*. 2006;31(2):129-142.
108
+ - Lehrer PM, Gevirtz R. ["Heart rate variability biofeedback: how and why does it work?"](https://doi.org/10.3389/fpsyg.2014.00756) *Front Psychol*. 2014;5:756.
109
+ - Russo MA, Santarelli DM, O'Rourke D. ["The physiological effects of slow breathing in the healthy human."](https://doi.org/10.1183/20734735.009817) *Breathe*. 2017;13(4):298-309.
110
+
111
+ ## Design choices
112
+
113
+ This app is deliberately constrained. Several common breathing-app features are excluded for safety and focus:
114
+
115
+ **No breath retention.** Breath holds (kumbhaka) raise intrathoracic pressure via a Valsalva-like mechanism and can trigger vasovagal syncope or arrhythmia in cardiac patients. The Bernardi protocols use continuous breathing with no hold phases. The app rejects three-number ratios like `4-7-8` with an explicit safety error.
116
+
117
+ **No rapid breathing.** Patterns faster than 7.5 bpm (cycles shorter than 8 seconds) move toward hyperventilation territory, reducing arterial CO2 and mobilising catecholamines — the opposite of the vagal intent (Russo et al. 2017). The app enforces a minimum cycle length of 8 seconds.
118
+
119
+ **No breath holds between phases.** There is no pause between inhale and exhale. The breath is continuous, matching the protocol in Bernardi et al. (1998, 2002).
120
+
121
+ **Immediate exit, always.** Pressing `q` or `Ctrl+C` ends the session within one frame. The terminal is always restored — cursor, colours, input mode — even if the app crashes. The `finally` block that does this is the most important code in the file.
122
+
123
+ **No dependencies.** Single Python file, stdlib only. Nothing to install, nothing to break. Runs on the Python that ships with macOS.
124
+
125
+ **No curses.** Direct ANSI escape codes only. The curses library has edge cases with non-default terminals on macOS Mojave.
126
+
127
+ ## Requirements
128
+
129
+ - macOS (uses `/usr/bin/afplay` for audio cues)
130
+ - Python 3.7+
131
+
132
+ ## Installation
133
+
134
+ ```bash
135
+ # Clone or download breathe.py, then:
136
+ chmod +x breathe.py
137
+
138
+ # Option A: run directly
139
+ ./breathe.py
140
+
141
+ # Option B: symlink into your PATH
142
+ ln -s "$(pwd)/breathe.py" /usr/local/bin/breathe
143
+ breathe
144
+ ```
145
+
146
+ ## Usage
147
+
148
+ ### No arguments — time-of-day auto-select
149
+
150
+ ```bash
151
+ breathe
152
+ ```
153
+
154
+ With no arguments, the app picks a preset based on the time of day:
155
+
156
+ | Time of day | Preset | Duration | Ratio | BPM |
157
+ |--------------|-------------|----------|-------|-----|
158
+ | Before noon | `balanced` | 10 min | 5s-5s | 6 |
159
+ | 12:00–16:59 | `extended` | 20 min | 4s-6s | 6 |
160
+ | 17:00+ | `calm` | 15 min | 4s-6s | 6 |
161
+
162
+ All presets target 6 breaths per minute. The `balanced` preset uses equal inhale/exhale (5-5) as a neutral baseline. The `calm` and `extended` presets use a longer exhale (4-6), which emphasises vagal activation during the expiratory phase. The time-of-day auto-select picks `calm` in the evening as a default — but you can use any preset at any time.
163
+
164
+ ### Presets
165
+
166
+ ```bash
167
+ breathe --preset balanced # 10 min, 5s-5s
168
+ breathe --preset calm # 15 min, 4s-6s
169
+ breathe --preset extended # 20 min, 4s-6s (full Bernardi protocol dose)
170
+ breathe --list-presets # show the table
171
+ ```
172
+
173
+ ### Custom sessions
174
+
175
+ ```bash
176
+ breathe --duration 5 # 5 minutes, default 5-5 ratio
177
+ breathe --ratio 4-6 # default 10 minutes, 4-6 ratio
178
+ breathe --duration 12 --ratio 4-6 # 12 minutes, 4-6 ratio
179
+ ```
180
+
181
+ Duration: 1–60 minutes (rounded up to complete breath cycles). Ratio: inhale and exhale each 3–10 seconds, total cycle >= 8 seconds, exhale at most 2x inhale.
182
+
183
+ ### Flags
184
+
185
+ | Flag | Short | Description |
186
+ |-------------------|-------|--------------------------------------------|
187
+ | `--preset NAME` | `-p` | Use a named preset |
188
+ | `--duration MIN` | `-d` | Session length in minutes (1–60) |
189
+ | `--ratio IN-EX` | `-r` | Breath ratio, e.g. `5-5` or `4-6` |
190
+ | `--no-sound` | `-n` | Disable audio cues |
191
+ | `--quiet` | `-q` | Suppress startup warnings |
192
+ | `--no-log` | | Don't log this session |
193
+ | `--log` | | Print log file path and exit |
194
+ | `--safety` | | Print safety information and exit |
195
+ | `--list-presets` | | Print preset table and exit |
196
+ | `--version` | | Print version and exit |
197
+
198
+ ### Runtime keys
199
+
200
+ During a session:
201
+
202
+ | Key | Action |
203
+ |-----------|-------------------------------------------------------------------|
204
+ | `space` | Pause / resume. Resume restarts from the beginning of INHALE. |
205
+ | `s` | Toggle sound mute. |
206
+ | `q` | Quit immediately. Terminal is restored. |
207
+ | `Ctrl+C` | Same as `q`. |
208
+
209
+ ### The display
210
+
211
+ ```
212
+ balanced · 5-5 · 09:12 [●] <- preset, ratio, countdown, status
213
+
214
+ INHALE <- current phase (cyan) or EXHALE (green)
215
+
216
+ ████████████████░░░░░░░░░░░░░░ <- breath bar (fills on inhale, drains on exhale)
217
+
218
+ space pause · s mute · q quit <- available controls
219
+ ```
220
+
221
+ The status indicator shows `●` during breathing, `‖` when paused, and `🔇` when muted.
222
+
223
+ The countdown timer tracks completed breathing time only. If you pause for 30 seconds during a 1-minute session, the session takes ~90 seconds of wall-clock time to complete — the timer doesn't advance while paused.
224
+
225
+ ## Session logging
226
+
227
+ Each session appends a row to `~/.breathe_log.csv`:
228
+
229
+ ```
230
+ date,time,preset,ratio,duration_target_s,duration_actual_s,breaths,completion_pct,status
231
+ 2026-05-30,07:15:02,balanced,5-5,600,600,60,100,completed
232
+ 2026-05-30,19:30:14,calm,4-6,900,420,42,46,ended early (user)
233
+ ```
234
+
235
+ Use `--no-log` to skip logging for a session. Use `--log` to see the log file path.
236
+
237
+ ## Testing
238
+
239
+ Automated tests cover logic and arithmetic (formatting, ratio parsing, safety rejections, preset invariants, countdown calculation):
240
+
241
+ ```bash
242
+ python3 -m unittest test_breathe -v
243
+ ```
244
+
245
+ TUI behaviour (rendering, animation, terminal restoration) is covered by 25 manual acceptance tests in `dev/breathe-cli-spec.md`.
246
+
247
+ ## Safety
248
+
249
+ Run `breathe --safety` for the full safety screen. The short version:
250
+
251
+ **Stop immediately** if you experience lightheadedness, palpitations, or tingling in your hands or face.
252
+
253
+ This app deliberately does not support breath retention, rapid breathing, or any pattern not grounded in the slow-breathing clinical literature. These constraints are enforced in the code and cannot be overridden. See [The science in brief](#the-science-in-brief) and [Design choices](#design-choices) for the clinical rationale.
254
+
255
+ ## Disclaimer
256
+
257
+ This app is not a medical device. It does not diagnose, treat, cure, or prevent any disease or condition. Always consult your physician before starting a breathing practice, especially if you have a cardiac or respiratory condition. Use entirely at your own risk. The author assumes no liability for any adverse effects arising from the use of this software. By using this app you acknowledge that you understand and accept these terms.
258
+
259
+ ## License
260
+
261
+ MIT License. See [LICENSE](LICENSE) for the full text.
262
+
263
+ Created by [Marek Kowalczyk](https://orcid.org/0009-0008-3874-6736).
@@ -0,0 +1,7 @@
1
+ breathe.py,sha256=623dX5GgfYkCadSmYlimW1sZlQ4L742zKrk0YO4uhIQ,24343
2
+ breathe_cli-1.8.dist-info/licenses/LICENSE,sha256=lKouHgKx3I4JtjzalKNSwEM0U25d6ca7Y6AP7PY5d2U,1072
3
+ breathe_cli-1.8.dist-info/METADATA,sha256=Upc6vuNhQ9wT31UKLc3VTHPCvdLbeTB_DJLEbIfCH04,15259
4
+ breathe_cli-1.8.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
5
+ breathe_cli-1.8.dist-info/entry_points.txt,sha256=z5O39aNCyZBvVsa94eezZbHlSugZfKNZB_HxcA8ND7Y,41
6
+ breathe_cli-1.8.dist-info/top_level.txt,sha256=iqVRyeRyaqNT9iVMMh_c_avGu__JgNXMlYNEeVDVGKA,8
7
+ breathe_cli-1.8.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ breathe = breathe:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Marek Kowalczyk
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ breathe