midi-cli 0.1.0__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.
- midi_cli/__init__.py +0 -0
- midi_cli/app.py +179 -0
- midi_cli/audio.py +278 -0
- midi_cli/config.py +133 -0
- midi_cli/loop.py +30 -0
- midi_cli/midi.py +154 -0
- midi_cli/sampler.py +221 -0
- midi_cli/state.py +121 -0
- midi_cli/ui.py +260 -0
- midi_cli-0.1.0.dist-info/METADATA +47 -0
- midi_cli-0.1.0.dist-info/RECORD +14 -0
- midi_cli-0.1.0.dist-info/WHEEL +4 -0
- midi_cli-0.1.0.dist-info/entry_points.txt +2 -0
- midi_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
midi_cli/ui.py
ADDED
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import sys
|
|
3
|
+
import time
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
|
|
7
|
+
import midi_cli.state as st
|
|
8
|
+
from midi_cli.sampler import format_pitch_info
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def render_waveform(note):
|
|
12
|
+
"""Render a centered ASCII waveform of the sample with trim region highlighted."""
|
|
13
|
+
audio = st.samples.get(note)
|
|
14
|
+
if audio is None:
|
|
15
|
+
return []
|
|
16
|
+
cols = os.get_terminal_size().columns - 2 # leave margin
|
|
17
|
+
cols = max(10, cols)
|
|
18
|
+
n = len(audio)
|
|
19
|
+
rows = st.TRIM_WAVEFORM_HEIGHT
|
|
20
|
+
half = rows / 2 # rows above/below center (float for smooth mapping)
|
|
21
|
+
blocks = " \u2581\u2582\u2583\u2584\u2585\u2586\u2587\u2588"
|
|
22
|
+
blocks_rev = " \u2594\u2594\u2580\u2580\u2580\u2580\u2588\u2588"
|
|
23
|
+
|
|
24
|
+
# Compute peak amplitude per column bin
|
|
25
|
+
peaks = np.zeros(cols)
|
|
26
|
+
for c in range(cols):
|
|
27
|
+
start = c * n // cols
|
|
28
|
+
end = (c + 1) * n // cols
|
|
29
|
+
if start < end:
|
|
30
|
+
peaks[c] = np.max(np.abs(audio[start:end]))
|
|
31
|
+
|
|
32
|
+
max_peak = np.max(peaks) if np.max(peaks) > 0 else 1.0
|
|
33
|
+
|
|
34
|
+
# Compute which columns are inside the trim region
|
|
35
|
+
trim_start_col = st.trim_start * cols // n if n > 0 else 0
|
|
36
|
+
trim_end_col = (st.trim_end * cols + n - 1) // n if n > 0 else cols # ceiling
|
|
37
|
+
|
|
38
|
+
# Active marker column
|
|
39
|
+
if st.trim_step == 0:
|
|
40
|
+
marker_col = trim_start_col
|
|
41
|
+
else:
|
|
42
|
+
marker_col = min(trim_end_col - 1, cols - 1)
|
|
43
|
+
|
|
44
|
+
# Playback position column (only during audio, not silence padding)
|
|
45
|
+
region_len = st.trim_end - st.trim_start
|
|
46
|
+
if region_len > 0 and st.trim_looping and st.trim_loop_pos < region_len:
|
|
47
|
+
play_offset = st.trim_loop_pos
|
|
48
|
+
play_col = trim_start_col + play_offset * (trim_end_col - trim_start_col) // region_len
|
|
49
|
+
play_col = max(trim_start_col, min(play_col, trim_end_col - 1))
|
|
50
|
+
else:
|
|
51
|
+
play_col = -1
|
|
52
|
+
|
|
53
|
+
WHITE = "\033[97m"
|
|
54
|
+
|
|
55
|
+
lines = []
|
|
56
|
+
for row in range(rows):
|
|
57
|
+
# Center is between row half-1 and half
|
|
58
|
+
# Upper half: rows 0..half-1 (top row = max amplitude)
|
|
59
|
+
# Lower half: rows half..rows-1 (bottom row = max amplitude)
|
|
60
|
+
dist_from_center = abs(row - half + 0.5) / half # 0 at center, 1 at edges
|
|
61
|
+
threshold_low = dist_from_center
|
|
62
|
+
threshold_high = dist_from_center + 1.0 / half
|
|
63
|
+
|
|
64
|
+
is_upper = row < half
|
|
65
|
+
line = " "
|
|
66
|
+
for c in range(cols):
|
|
67
|
+
level = peaks[c] / max_peak
|
|
68
|
+
if level <= threshold_low:
|
|
69
|
+
char = " "
|
|
70
|
+
else:
|
|
71
|
+
frac = min(1.0, (level - threshold_low) / (threshold_high - threshold_low))
|
|
72
|
+
idx = int(frac * (len(blocks) - 1))
|
|
73
|
+
if is_upper:
|
|
74
|
+
char = blocks[idx]
|
|
75
|
+
else:
|
|
76
|
+
# Bottom half: use reversed blocks so fill grows from top of cell
|
|
77
|
+
char = blocks_rev[idx]
|
|
78
|
+
|
|
79
|
+
in_region = trim_start_col <= c < trim_end_col
|
|
80
|
+
is_boundary = c == trim_start_col or c == trim_end_col - 1
|
|
81
|
+
if c == play_col:
|
|
82
|
+
head = char if char.strip() else "\u2502"
|
|
83
|
+
line += f"{st.RED}{st.BOLD}{head}{st.RST}"
|
|
84
|
+
elif is_boundary:
|
|
85
|
+
bar = char if char.strip() else "\u2502"
|
|
86
|
+
if c == marker_col:
|
|
87
|
+
line += f"{st.ORANGE}{st.BOLD}{bar}{st.RST}"
|
|
88
|
+
else:
|
|
89
|
+
line += f"{st.CYAN}{bar}{st.RST}"
|
|
90
|
+
elif in_region:
|
|
91
|
+
line += f"{st.CYAN}{char}{st.RST}"
|
|
92
|
+
else:
|
|
93
|
+
line += f"{st.DIM}{char}{st.RST}"
|
|
94
|
+
lines.append(line)
|
|
95
|
+
return lines
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def reset_trim_state():
|
|
99
|
+
"""Reset all trim mode state back to selection."""
|
|
100
|
+
st.trim_selected_note = None
|
|
101
|
+
st.trim_step = 0
|
|
102
|
+
st.trim_start = 0
|
|
103
|
+
st.trim_end = 0
|
|
104
|
+
st.trim_loop_pos = 0
|
|
105
|
+
st.trim_looping = False
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def reset_copy_state():
|
|
109
|
+
"""Reset all copy mode state back to selection."""
|
|
110
|
+
st.copy_source_note = None
|
|
111
|
+
st.copy_dest_note = None
|
|
112
|
+
st.copy_confirm_pending = False
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def draw_ui(initialized):
|
|
116
|
+
"""Render the TUI in place using ANSI escape codes."""
|
|
117
|
+
with st.voices_lock:
|
|
118
|
+
ui_rec = st.ui_recording
|
|
119
|
+
out = ""
|
|
120
|
+
total_lines = st.NUM_UI_LINES # base 4 lines
|
|
121
|
+
|
|
122
|
+
# Move cursor back up to overwrite previous frame
|
|
123
|
+
if initialized:
|
|
124
|
+
out += f"\033[{st.prev_ui_lines}A"
|
|
125
|
+
|
|
126
|
+
# Line 0: app name + mode selector (compact, left-aligned)
|
|
127
|
+
line0 = f" {st.BOLD}midi-cli{st.RST} "
|
|
128
|
+
for i, m in enumerate(st.MODES):
|
|
129
|
+
if i > 0:
|
|
130
|
+
line0 += f"{st.DIM} \u2502 {st.RST}"
|
|
131
|
+
if m == st.mode:
|
|
132
|
+
line0 += f"{st.BOLD}{m}{st.RST}"
|
|
133
|
+
else:
|
|
134
|
+
line0 += f"{st.DIM}{m}{st.RST}"
|
|
135
|
+
out += f"{st.CLEAR_LINE}\r{line0}\r\n"
|
|
136
|
+
|
|
137
|
+
# Line 1: note/rec indicator + sustain + sample count (or pitch/trim info)
|
|
138
|
+
line1 = ""
|
|
139
|
+
if st.mode == "trim":
|
|
140
|
+
if st.trim_selected_note is not None:
|
|
141
|
+
name = st.midi_note_name(st.trim_selected_note)
|
|
142
|
+
start_ms = st.trim_start / st.SAMPLE_RATE * 1000
|
|
143
|
+
end_ms = st.trim_end / st.SAMPLE_RATE * 1000
|
|
144
|
+
total_ms = len(st.samples[st.trim_selected_note]) / st.SAMPLE_RATE * 1000
|
|
145
|
+
if st.trim_step == 0:
|
|
146
|
+
line1 += f" {st.GREEN}{st.BOLD}\u2666{st.RST} {st.BOLD}{name} start={start_ms:.0f}ms{st.RST} end={end_ms:.0f}ms {st.DIM}{total_ms:.0f}ms total{st.RST}"
|
|
147
|
+
else:
|
|
148
|
+
line1 += f" {st.GREEN}{st.BOLD}\u2666{st.RST} {st.BOLD}{name}{st.RST} start={start_ms:.0f}ms {st.BOLD}end={end_ms:.0f}ms{st.RST} {st.DIM}{total_ms:.0f}ms total{st.RST}"
|
|
149
|
+
else:
|
|
150
|
+
line1 += f" {st.DIM}play a key to select{st.RST}"
|
|
151
|
+
elif st.mode == "pitch":
|
|
152
|
+
if st.pitch_selected_note is not None:
|
|
153
|
+
offset = st.pitch_offsets.get(st.pitch_selected_note, {"semitones": 0, "cents": 0})
|
|
154
|
+
info = format_pitch_info(st.pitch_selected_note, offset)
|
|
155
|
+
line1 += f" {st.GREEN}{st.BOLD}\u2666{st.RST} {st.BOLD}{info}{st.RST}"
|
|
156
|
+
else:
|
|
157
|
+
line1 += f" {st.DIM}play a key to select{st.RST}"
|
|
158
|
+
elif st.mode == "chromatic":
|
|
159
|
+
if st.chromatic_selecting:
|
|
160
|
+
line1 += f" {st.DIM}play a key to select{st.RST}"
|
|
161
|
+
elif st.chromatic_computing and st.chromatic_root_note is not None:
|
|
162
|
+
line1 += f" {st.YELLOW}{st.BOLD}computing {st.midi_note_name(st.chromatic_root_note)}...{st.RST}"
|
|
163
|
+
elif st.chromatic_root_note is not None:
|
|
164
|
+
root_name = st.midi_note_name(st.chromatic_root_note)
|
|
165
|
+
range_start = st.midi_note_name(max(0, st.chromatic_root_note - 12))
|
|
166
|
+
range_end = st.midi_note_name(min(127, st.chromatic_root_note + 12))
|
|
167
|
+
line1 += f" {st.GREEN}{st.BOLD}\u2666{st.RST} {st.BOLD}{root_name} {range_start}\u2013{range_end}{st.RST}"
|
|
168
|
+
else:
|
|
169
|
+
line1 += f" {st.DIM}play a key to select{st.RST}"
|
|
170
|
+
elif st.mode == "copy":
|
|
171
|
+
if st.copy_confirm_pending and st.copy_source_note is not None and st.copy_dest_note is not None:
|
|
172
|
+
src_name = st.midi_note_name(st.copy_source_note)
|
|
173
|
+
dst_name = st.midi_note_name(st.copy_dest_note)
|
|
174
|
+
line1 += f" {st.YELLOW}{st.BOLD}overwrite {dst_name}? {src_name} \u2192 {dst_name}{st.RST}"
|
|
175
|
+
elif st.copy_source_note is not None:
|
|
176
|
+
src_name = st.midi_note_name(st.copy_source_note)
|
|
177
|
+
line1 += f" {st.GREEN}{st.BOLD}\u2666{st.RST} {st.BOLD}{src_name}{st.RST} {st.DIM}play a key to paste{st.RST}"
|
|
178
|
+
else:
|
|
179
|
+
line1 += f" {st.DIM}play a key to copy{st.RST}"
|
|
180
|
+
elif st.mode == "loop":
|
|
181
|
+
state = st.loop_state
|
|
182
|
+
if state == "recording":
|
|
183
|
+
line1 += f" {st.RED}{st.BOLD}[REC]{st.RST}"
|
|
184
|
+
elif state == "playing":
|
|
185
|
+
buf_secs = len(st.loop_buffer) / st.SAMPLE_RATE if st.loop_buffer is not None else 0
|
|
186
|
+
line1 += f" {st.GREEN}{st.BOLD}[PLAY]{st.RST} {st.DIM}{buf_secs:.2f}s{st.RST}"
|
|
187
|
+
elif state == "overdub":
|
|
188
|
+
buf_secs = len(st.loop_buffer) / st.SAMPLE_RATE if st.loop_buffer is not None else 0
|
|
189
|
+
line1 += f" {st.YELLOW}{st.BOLD}[DUB]{st.RST} {st.DIM}{buf_secs:.2f}s{st.RST}"
|
|
190
|
+
else:
|
|
191
|
+
line1 += f" {st.DIM}idle{st.RST}"
|
|
192
|
+
elif ui_rec:
|
|
193
|
+
elapsed = time.time() - ui_rec["start_time"]
|
|
194
|
+
line1 += f" {st.RED}{st.BOLD}\u25cf REC {ui_rec['note']} {elapsed:.1f}s{st.RST}"
|
|
195
|
+
elif st.ui_active_note:
|
|
196
|
+
line1 += f" {st.GREEN}{st.BOLD}\u25cf{st.RST} {st.BOLD}{st.ui_active_note:<4}{st.RST}"
|
|
197
|
+
else:
|
|
198
|
+
line1 += f" {st.DIM} {st.RST} " # blank note area
|
|
199
|
+
|
|
200
|
+
line1 += f" {st.DIM}sustain {st.RST}"
|
|
201
|
+
if st.ui_sustain:
|
|
202
|
+
line1 += f"{st.YELLOW}{st.BOLD}\u25cf{st.RST}"
|
|
203
|
+
else:
|
|
204
|
+
line1 += f"{st.DIM}\u25cb{st.RST}"
|
|
205
|
+
line1 += f" {st.DIM}{st.CYAN}{len(st.samples)} samples{st.RST}"
|
|
206
|
+
out += f"{st.CLEAR_LINE}\r{line1}\r\n"
|
|
207
|
+
|
|
208
|
+
# Line 2: MIDI controller name or keyboard mode hint
|
|
209
|
+
if st.keyboard_mock_mode:
|
|
210
|
+
line2 = f" {st.DIM}keyboard mode [1-0] play [` ] sustain{st.RST}"
|
|
211
|
+
else:
|
|
212
|
+
line2 = f" {st.DIM}{st.ui_midi_port}{st.RST}" if st.ui_midi_port else ""
|
|
213
|
+
out += f"{st.CLEAR_LINE}\r{line2}\r\n"
|
|
214
|
+
|
|
215
|
+
# Line 3: help keys
|
|
216
|
+
if st.mode == "trim":
|
|
217
|
+
if st.trim_selected_note is None:
|
|
218
|
+
line3 = f" {st.DIM}[Tab] mode [play] select [q] quit{st.RST}"
|
|
219
|
+
else:
|
|
220
|
+
line3 = f" {st.DIM}[\u2190\u2192] fine [\u2191\u2193] coarse [enter] set [esc] cancel [q] quit{st.RST}"
|
|
221
|
+
elif st.mode == "pitch":
|
|
222
|
+
line3 = f" {st.DIM}[Tab] mode [\u2191\u2193] semitone [\u2190\u2192] cents [q] quit{st.RST}"
|
|
223
|
+
elif st.mode == "chromatic":
|
|
224
|
+
if st.chromatic_selecting:
|
|
225
|
+
line3 = f" {st.DIM}[Tab] mode [play] select [q] quit{st.RST}"
|
|
226
|
+
else:
|
|
227
|
+
line3 = f" {st.DIM}[Tab] mode [r] reselect [q] quit{st.RST}"
|
|
228
|
+
elif st.mode == "copy":
|
|
229
|
+
if st.copy_confirm_pending:
|
|
230
|
+
line3 = f" {st.DIM}[enter] confirm [esc] cancel [q] quit{st.RST}"
|
|
231
|
+
elif st.copy_source_note is not None:
|
|
232
|
+
line3 = f" {st.DIM}[Tab] mode [play] paste [esc] cancel [q] quit{st.RST}"
|
|
233
|
+
else:
|
|
234
|
+
line3 = f" {st.DIM}[Tab] mode [play] select [q] quit{st.RST}"
|
|
235
|
+
elif st.mode == "loop":
|
|
236
|
+
line3 = f" {st.DIM}[sustain/l] record/play/dub [esc] clear [play] sample [q] quit{st.RST}"
|
|
237
|
+
else:
|
|
238
|
+
line3 = f" {st.DIM}[Tab] mode [q] quit{st.RST}"
|
|
239
|
+
out += f"{st.CLEAR_LINE}\r{line3}\r\n"
|
|
240
|
+
|
|
241
|
+
# Waveform (trim mode only, when a note is selected)
|
|
242
|
+
if st.mode == "trim" and st.trim_selected_note is not None:
|
|
243
|
+
out += f"{st.CLEAR_LINE}\r\n" # blank separator
|
|
244
|
+
total_lines += 1
|
|
245
|
+
waveform_lines = render_waveform(st.trim_selected_note)
|
|
246
|
+
for wl in waveform_lines:
|
|
247
|
+
out += f"{st.CLEAR_LINE}\r{wl}\r\n"
|
|
248
|
+
total_lines += 1
|
|
249
|
+
|
|
250
|
+
# Clear any stale lines from previous frame
|
|
251
|
+
if st.prev_ui_lines > total_lines:
|
|
252
|
+
for _ in range(st.prev_ui_lines - total_lines):
|
|
253
|
+
out += f"{st.CLEAR_LINE}\r\n"
|
|
254
|
+
# Move cursor back up to end of current content
|
|
255
|
+
out += f"\033[{st.prev_ui_lines - total_lines}A"
|
|
256
|
+
|
|
257
|
+
st.prev_ui_lines = total_lines
|
|
258
|
+
|
|
259
|
+
sys.stdout.write(out)
|
|
260
|
+
sys.stdout.flush()
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: midi-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A MIDI sampler and looper in your terminal
|
|
5
|
+
Project-URL: Homepage, https://github.com/mnbpdx/midi-cli
|
|
6
|
+
Project-URL: Repository, https://github.com/mnbpdx/midi-cli
|
|
7
|
+
License: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Classifier: Environment :: Console
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Operating System :: MacOS
|
|
12
|
+
Classifier: Topic :: Multimedia :: Sound/Audio
|
|
13
|
+
Classifier: Topic :: Multimedia :: Sound/Audio :: MIDI
|
|
14
|
+
Requires-Python: >=3.11
|
|
15
|
+
Requires-Dist: numpy
|
|
16
|
+
Requires-Dist: pyrubberband
|
|
17
|
+
Requires-Dist: python-rtmidi
|
|
18
|
+
Requires-Dist: sounddevice
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
|
|
21
|
+
# midi-cli
|
|
22
|
+
|
|
23
|
+
A little midi player in your terminal, tested in zsh on macOS.
|
|
24
|
+
|
|
25
|
+
## Install
|
|
26
|
+
|
|
27
|
+
```sh
|
|
28
|
+
pipx install midi-cli
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Or with pip:
|
|
32
|
+
|
|
33
|
+
```sh
|
|
34
|
+
pip install midi-cli
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
### For development
|
|
38
|
+
|
|
39
|
+
```sh
|
|
40
|
+
uv pip install -e .
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Usage
|
|
44
|
+
|
|
45
|
+
```sh
|
|
46
|
+
midi-cli
|
|
47
|
+
```
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
midi_cli/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
midi_cli/app.py,sha256=seCrzlfW6hxKeAE5E7m9Lsg4m2JwFrg82DXh42CQIZI,8956
|
|
3
|
+
midi_cli/audio.py,sha256=yKQU_PCq0gGDFY9kNuuK0f3d_Ff3B8igGz0xiOfoqpE,10838
|
|
4
|
+
midi_cli/config.py,sha256=buJv6lwtkVAVREDYCMUv9fv0ojUE8AoYX-g-JH_gj2k,4206
|
|
5
|
+
midi_cli/loop.py,sha256=lAMGn5EapzPENAUsx9Y69rAG8e3Gsp6ik9DEh79dN0A,896
|
|
6
|
+
midi_cli/midi.py,sha256=8EUjr1xkg2jk2xDEwhy3ZzbGGoWSZ_7UDHvemOfNZJI,5655
|
|
7
|
+
midi_cli/sampler.py,sha256=dD6XAqGoPx7OcqrdnbmoYhGxpA5sBJ4MZbQC3FNCAdw,7560
|
|
8
|
+
midi_cli/state.py,sha256=ytvSiKT6yDURzepEG_yg4cwL_aqa1Ko7EvPgEoe0Jfg,4476
|
|
9
|
+
midi_cli/ui.py,sha256=ZwaTDpZEWOily5kMX7ZDGOoGLeVQi3ci158sNiyXVxM,11008
|
|
10
|
+
midi_cli-0.1.0.dist-info/METADATA,sha256=A-6lNM7bP1aCKWQmnmD0g0v9rWw9zqbK9MR50SizYmI,901
|
|
11
|
+
midi_cli-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
12
|
+
midi_cli-0.1.0.dist-info/entry_points.txt,sha256=x16I5cXtaVPDBTt2uuxYxYdRzMcQj-j67DekuhJTywQ,47
|
|
13
|
+
midi_cli-0.1.0.dist-info/licenses/LICENSE,sha256=lNsGRfBlQKeTX_f0ztlh9K1dDzgdMXTIOumoIhlbB_4,1063
|
|
14
|
+
midi_cli-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 mnbpdx
|
|
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.
|