infinicode 2.8.95 → 2.8.97
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.
|
@@ -160,3 +160,21 @@ Required result: `playback_started ok`.
|
|
|
160
160
|
whether motion creates a conversation, not whether the camera worker runs.
|
|
161
161
|
- `auto`, `default`, and `first` resolve to `/dev/robopark-camera` when present,
|
|
162
162
|
otherwise `/dev/video0`; those literal strings are never passed to OpenCV.
|
|
163
|
+
|
|
164
|
+
## Audio Ownership Contract (2.8.96+)
|
|
165
|
+
|
|
166
|
+
- Fleet microphone selection is resolved by stable USB product label on every
|
|
167
|
+
inventory refresh. The current stack maps microphone to `hw:3,0` and speaker
|
|
168
|
+
to `hw:2,0`.
|
|
169
|
+
- A LiveKit microphone track is healthy only after the robot captures and
|
|
170
|
+
publishes real PCM. Publishing an empty track is not readiness.
|
|
171
|
+
- Linux capture probes `plughw` at 48 kHz, falls back to 44.1 kHz with
|
|
172
|
+
resampling, and releases the microphone lock after every failed attempt.
|
|
173
|
+
- Pipeline failures include the selected device and exact ALSA startup error.
|
|
174
|
+
A flat browser meter means missing PCM, not a quiet room.
|
|
175
|
+
- Remote `speaker-test` requests support `mode=mic_groundtruth`, which captures
|
|
176
|
+
through the same `arecord -D plughw:X,Y -f S16_LE -r 48000 -c 1` command
|
|
177
|
+
proven onsite and returns bytes, RMS, peak, and nonzero sample counts.
|
|
178
|
+
- Python 3.13 removed `audioop`. Version 2.8.97 replaces microphone gain, peak
|
|
179
|
+
measurement, and fallback resampling with internal PCM16 helpers; production
|
|
180
|
+
capture must not import `audioop`.
|
package/package.json
CHANGED
|
@@ -97,7 +97,7 @@ def _resolve_inventory_audio(items: list, selected: object, preferred: str) -> s
|
|
|
97
97
|
return str(match.get("name")) if match and match.get("name") else selected_text
|
|
98
98
|
|
|
99
99
|
|
|
100
|
-
def _normalize_livekit_url(url: Optional[str]) -> Optional[str]:
|
|
100
|
+
def _normalize_livekit_url(url: Optional[str]) -> Optional[str]:
|
|
101
101
|
"""Avoid Windows localhost IPv6/IPv4 ambiguity for local LiveKit."""
|
|
102
102
|
if not url:
|
|
103
103
|
return url
|
|
@@ -105,9 +105,54 @@ def _normalize_livekit_url(url: Optional[str]) -> Optional[str]:
|
|
|
105
105
|
prefix = f"{scheme}://localhost"
|
|
106
106
|
if url.startswith(prefix):
|
|
107
107
|
return f"{scheme}://127.0.0.1" + url[len(prefix):]
|
|
108
|
-
return url
|
|
109
|
-
|
|
110
|
-
|
|
108
|
+
return url
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _pcm16_scale_and_peak(data: bytes, gain: float) -> tuple[bytes, int]:
|
|
112
|
+
"""Apply gain and measure peak without audioop (removed in Python 3.13)."""
|
|
113
|
+
from array import array
|
|
114
|
+
|
|
115
|
+
samples = array("h")
|
|
116
|
+
samples.frombytes(data[:len(data) - (len(data) % 2)])
|
|
117
|
+
if sys.byteorder != "little":
|
|
118
|
+
samples.byteswap()
|
|
119
|
+
peak = 0
|
|
120
|
+
for index, value in enumerate(samples):
|
|
121
|
+
scaled = max(-32768, min(32767, int(value * gain))) if gain != 1.0 else value
|
|
122
|
+
samples[index] = scaled
|
|
123
|
+
peak = max(peak, abs(scaled))
|
|
124
|
+
if sys.byteorder != "little":
|
|
125
|
+
samples.byteswap()
|
|
126
|
+
return samples.tobytes(), peak
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _pcm16_resample_mono(data: bytes, source_rate: int, target_rate: int) -> bytes:
|
|
130
|
+
"""Linearly resample a PCM16 mono chunk using only the standard library."""
|
|
131
|
+
from array import array
|
|
132
|
+
|
|
133
|
+
if source_rate == target_rate or len(data) < 4:
|
|
134
|
+
return data
|
|
135
|
+
source = array("h")
|
|
136
|
+
source.frombytes(data[:len(data) - (len(data) % 2)])
|
|
137
|
+
if sys.byteorder != "little":
|
|
138
|
+
source.byteswap()
|
|
139
|
+
target_count = max(1, round(len(source) * target_rate / source_rate))
|
|
140
|
+
target = array("h", [0]) * target_count
|
|
141
|
+
scale = source_rate / target_rate
|
|
142
|
+
last = len(source) - 1
|
|
143
|
+
for index in range(target_count):
|
|
144
|
+
position = min(last, index * scale)
|
|
145
|
+
left = int(position)
|
|
146
|
+
right = min(last, left + 1)
|
|
147
|
+
fraction = position - left
|
|
148
|
+
target[index] = max(-32768, min(32767, round(
|
|
149
|
+
source[left] + (source[right] - source[left]) * fraction
|
|
150
|
+
)))
|
|
151
|
+
if sys.byteorder != "little":
|
|
152
|
+
target.byteswap()
|
|
153
|
+
return target.tobytes()
|
|
154
|
+
|
|
155
|
+
|
|
111
156
|
def _play_audio_effect(selected_output: str | None, effect: str) -> None:
|
|
112
157
|
"""Play a short local cue without involving the voice pipeline."""
|
|
113
158
|
sample_rate = 48000
|
|
@@ -1238,6 +1283,7 @@ class LiveKitPublisher:
|
|
|
1238
1283
|
self._capture: Optional["VideoCapture"] = None
|
|
1239
1284
|
self._mic_capture: Optional["AudioCapture"] = None
|
|
1240
1285
|
self._mic_streaming = asyncio.Event()
|
|
1286
|
+
self._mic_error: Optional[str] = None
|
|
1241
1287
|
# Motion sampling state belongs to the publisher instance. Keeping it
|
|
1242
1288
|
# initialized here prevents shutdown/reopen paths from raising while
|
|
1243
1289
|
# the camera is being handed between preview and voice sessions.
|
|
@@ -1335,10 +1381,16 @@ class LiveKitPublisher:
|
|
|
1335
1381
|
try:
|
|
1336
1382
|
await asyncio.wait_for(self._mic_streaming.wait(), timeout=5.0)
|
|
1337
1383
|
except asyncio.TimeoutError:
|
|
1384
|
+
detail = self._mic_error or f"no PCM received from {self.agent.audio_capture_device}"
|
|
1338
1385
|
await self.agent._report_pipeline(
|
|
1339
|
-
"microphone_published", "blocked",
|
|
1386
|
+
"microphone_published", "blocked",
|
|
1387
|
+
f"Microphone track published but PCM capture did not start: {detail}",
|
|
1388
|
+
{"device": self.agent.audio_capture_device, "error": detail},
|
|
1389
|
+
)
|
|
1390
|
+
logger.warning(
|
|
1391
|
+
"microphone PCM is not ready (%s); keeping the session alive while capture retries",
|
|
1392
|
+
detail,
|
|
1340
1393
|
)
|
|
1341
|
-
logger.warning("microphone PCM is not ready; keeping the session alive while capture retries")
|
|
1342
1394
|
|
|
1343
1395
|
if self._capture and self.video_source:
|
|
1344
1396
|
self._tasks.append(asyncio.create_task(self._video_loop()))
|
|
@@ -1688,6 +1740,7 @@ class LiveKitPublisher:
|
|
|
1688
1740
|
except asyncio.CancelledError:
|
|
1689
1741
|
raise
|
|
1690
1742
|
except Exception as e:
|
|
1743
|
+
self._mic_error = str(e)
|
|
1691
1744
|
logger.warning(
|
|
1692
1745
|
f"audio_loop: capture failed on {self.agent.audio_capture_device}: {e}; retrying"
|
|
1693
1746
|
)
|
|
@@ -1709,7 +1762,8 @@ class LiveKitPublisher:
|
|
|
1709
1762
|
logger.warning("audio_loop: create_audio_capture returned None, mic will not publish")
|
|
1710
1763
|
return
|
|
1711
1764
|
self._mic_capture = mic
|
|
1712
|
-
|
|
1765
|
+
self._mic_error = None
|
|
1766
|
+
logger.info(f"audio_loop: mic capture started ({type(mic).__name__})")
|
|
1713
1767
|
# Read in bigger batches (100ms) instead of one 20ms frame per
|
|
1714
1768
|
# asyncio.to_thread() dispatch. Each dispatch/poll cycle has a fixed
|
|
1715
1769
|
# overhead (~30ms observed on this machine) that dominates when the
|
|
@@ -1724,7 +1778,6 @@ class LiveKitPublisher:
|
|
|
1724
1778
|
samples_per_frame = int(48000 * frame_ms / 1000)
|
|
1725
1779
|
samples_per_batch = int(48000 * BATCH_MS / 1000)
|
|
1726
1780
|
bytes_per_frame = samples_per_frame * 2 # int16 mono
|
|
1727
|
-
import audioop
|
|
1728
1781
|
mic_gain = max(1.0, min(float(os.getenv("ROBOPARK_MIC_GAIN", "4.0")), 12.0))
|
|
1729
1782
|
_diag_peak = 0
|
|
1730
1783
|
_diag_count = 0
|
|
@@ -1742,8 +1795,7 @@ class LiveKitPublisher:
|
|
|
1742
1795
|
_pub_start = time.monotonic()
|
|
1743
1796
|
for off in range(0, len(data) - bytes_per_frame + 1, bytes_per_frame):
|
|
1744
1797
|
chunk = data[off:off + bytes_per_frame]
|
|
1745
|
-
|
|
1746
|
-
chunk = audioop.mul(chunk, 2, mic_gain)
|
|
1798
|
+
chunk, p = _pcm16_scale_and_peak(chunk, mic_gain)
|
|
1747
1799
|
frame = self.rtc.AudioFrame(
|
|
1748
1800
|
data=chunk, sample_rate=48000, num_channels=1, samples_per_channel=samples_per_frame,
|
|
1749
1801
|
)
|
|
@@ -1754,8 +1806,7 @@ class LiveKitPublisher:
|
|
|
1754
1806
|
"microphone_published", "ok",
|
|
1755
1807
|
"Microphone track published with live PCM frames",
|
|
1756
1808
|
)
|
|
1757
|
-
|
|
1758
|
-
_diag_peak = max(_diag_peak, p)
|
|
1809
|
+
_diag_peak = max(_diag_peak, p)
|
|
1759
1810
|
_diag_count += 1
|
|
1760
1811
|
_diag_read_ms += (_t1 - _t0) * 1000
|
|
1761
1812
|
_diag_publish_ms += (time.monotonic() - _pub_start) * 1000
|
|
@@ -1949,46 +2000,85 @@ class AlsaAudioCapture(AudioCapture):
|
|
|
1949
2000
|
"""Capture Linux PCM through the same ALSA path used by onsite tests."""
|
|
1950
2001
|
|
|
1951
2002
|
def __init__(self, device: str):
|
|
2003
|
+
import os
|
|
1952
2004
|
import re
|
|
2005
|
+
import select
|
|
1953
2006
|
import subprocess
|
|
1954
2007
|
from media_lock import media_lock
|
|
1955
2008
|
|
|
1956
2009
|
match = re.search(r"\b(hw:\d+,\d+)\b", device)
|
|
1957
2010
|
if not match:
|
|
1958
2011
|
raise ValueError(f"no ALSA hardware address in {device!r}")
|
|
1959
|
-
alsa_device = f"plug{match.group(1)}"
|
|
1960
2012
|
self.buffer = bytearray()
|
|
1961
|
-
self.media_guard = media_lock("microphone", timeout=5
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
|
|
1965
|
-
|
|
1966
|
-
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
|
|
1970
|
-
|
|
1971
|
-
|
|
1972
|
-
|
|
1973
|
-
|
|
1974
|
-
|
|
1975
|
-
|
|
1976
|
-
|
|
2013
|
+
self.media_guard = media_lock("microphone", timeout=1.5).acquire()
|
|
2014
|
+
self.process = None
|
|
2015
|
+
self.source_rate = 48000
|
|
2016
|
+
errors = []
|
|
2017
|
+
# The fleet USB microphone normally accepts 48 kHz through ALSA's
|
|
2018
|
+
# plug layer. Some firmware revisions expose only native 44.1 kHz;
|
|
2019
|
+
# accept that rate and resample below rather than publishing silence.
|
|
2020
|
+
for source_rate in (48000, 44100):
|
|
2021
|
+
alsa_device = f"plug{match.group(1)}"
|
|
2022
|
+
process = None
|
|
2023
|
+
try:
|
|
2024
|
+
process = subprocess.Popen(
|
|
2025
|
+
[
|
|
2026
|
+
"arecord", "-q", "-D", alsa_device, "-t", "raw",
|
|
2027
|
+
"-f", "S16_LE", "-r", str(source_rate), "-c", "1",
|
|
2028
|
+
"--period-size", str(max(256, source_rate // 50)),
|
|
2029
|
+
],
|
|
2030
|
+
stdout=subprocess.PIPE,
|
|
2031
|
+
stderr=subprocess.PIPE,
|
|
2032
|
+
bufsize=0,
|
|
2033
|
+
)
|
|
2034
|
+
if process.stdout is None:
|
|
2035
|
+
raise RuntimeError("arecord did not provide a PCM stream")
|
|
2036
|
+
ready, _, _ = select.select([process.stdout], [], [], 2.0)
|
|
2037
|
+
if not ready:
|
|
2038
|
+
if process.poll() is None:
|
|
2039
|
+
raise TimeoutError("arecord produced no PCM within 2 seconds")
|
|
2040
|
+
detail = process.stderr.read().decode("utf-8", errors="replace").strip() if process.stderr else ""
|
|
2041
|
+
raise OSError(detail or f"arecord exited {process.returncode}")
|
|
2042
|
+
first = os.read(process.stdout.fileno(), max(2048, source_rate // 25 * 2))
|
|
2043
|
+
if not first:
|
|
2044
|
+
detail = process.stderr.read().decode("utf-8", errors="replace").strip() if process.stderr else ""
|
|
2045
|
+
raise OSError(detail or "arecord returned an empty PCM frame")
|
|
2046
|
+
self.process = process
|
|
2047
|
+
self.source_rate = source_rate
|
|
2048
|
+
self.alsa_device = alsa_device
|
|
2049
|
+
if source_rate != 48000:
|
|
2050
|
+
first = _pcm16_resample_mono(first, source_rate, 48000)
|
|
2051
|
+
self.buffer.extend(first)
|
|
2052
|
+
break
|
|
2053
|
+
except Exception as exc:
|
|
2054
|
+
errors.append(f"{source_rate}Hz: {exc}")
|
|
2055
|
+
if process is not None:
|
|
2056
|
+
if process.poll() is None:
|
|
2057
|
+
process.terminate()
|
|
2058
|
+
try:
|
|
2059
|
+
process.wait(timeout=1)
|
|
2060
|
+
except subprocess.TimeoutExpired:
|
|
2061
|
+
process.kill()
|
|
2062
|
+
process.wait(timeout=1)
|
|
2063
|
+
if self.process is None:
|
|
1977
2064
|
self.media_guard.release()
|
|
1978
|
-
raise
|
|
1979
|
-
self.alsa_device = alsa_device
|
|
2065
|
+
raise OSError(f"ALSA capture failed on plug{match.group(1)} ({'; '.join(errors)})")
|
|
1980
2066
|
|
|
1981
2067
|
def read(self, samples_per_frame: int):
|
|
2068
|
+
import os
|
|
1982
2069
|
from livekit import rtc
|
|
1983
2070
|
|
|
1984
2071
|
bytes_needed = samples_per_frame * 2
|
|
2072
|
+
source_bytes_needed = max(2, int(samples_per_frame * self.source_rate / 48000) * 2)
|
|
1985
2073
|
while len(self.buffer) < bytes_needed:
|
|
1986
|
-
chunk = self.process.stdout.
|
|
2074
|
+
chunk = os.read(self.process.stdout.fileno(), source_bytes_needed)
|
|
1987
2075
|
if not chunk:
|
|
1988
2076
|
detail = ""
|
|
1989
2077
|
if self.process.stderr is not None:
|
|
1990
2078
|
detail = self.process.stderr.read().decode("utf-8", errors="replace").strip()
|
|
1991
2079
|
raise OSError(f"ALSA capture stopped on {self.alsa_device}: {detail or 'no PCM data'}")
|
|
2080
|
+
if self.source_rate != 48000:
|
|
2081
|
+
chunk = _pcm16_resample_mono(chunk, self.source_rate, 48000)
|
|
1992
2082
|
self.buffer.extend(chunk)
|
|
1993
2083
|
data = bytes(self.buffer[:bytes_needed])
|
|
1994
2084
|
del self.buffer[:bytes_needed]
|
|
@@ -626,15 +626,92 @@ def _linux_aplay_pcm16(raw: bytes, selected_output: str, sample_rate: int,
|
|
|
626
626
|
return False, f"{alsa_device}: {last_error}"
|
|
627
627
|
|
|
628
628
|
|
|
629
|
+
def _linux_mic_groundtruth(selected_input: str, duration: float = 4.0) -> dict:
|
|
630
|
+
"""Capture the fleet USB mic through the exact onsite-proven ALSA path."""
|
|
631
|
+
import re
|
|
632
|
+
|
|
633
|
+
match = re.search(r"hw:(\d+),(\d+)", str(selected_input), re.IGNORECASE)
|
|
634
|
+
if not match:
|
|
635
|
+
return {"ok": False, "error": "selected input has no ALSA hw address"}
|
|
636
|
+
alsa_device = f"plughw:{match.group(1)},{match.group(2)}"
|
|
637
|
+
seconds = max(1, min(10, int(round(duration))))
|
|
638
|
+
command = [
|
|
639
|
+
"arecord", "-q", "-D", alsa_device, "-t", "raw", "-f", "S16_LE",
|
|
640
|
+
"-r", "48000", "-c", "1", "-d", str(seconds),
|
|
641
|
+
]
|
|
642
|
+
started = time.monotonic()
|
|
643
|
+
try:
|
|
644
|
+
from media_lock import media_lock
|
|
645
|
+
with media_lock("microphone", timeout=8.0):
|
|
646
|
+
completed = subprocess.run(
|
|
647
|
+
command,
|
|
648
|
+
stdout=subprocess.PIPE,
|
|
649
|
+
stderr=subprocess.PIPE,
|
|
650
|
+
timeout=seconds + 4.0,
|
|
651
|
+
check=False,
|
|
652
|
+
)
|
|
653
|
+
except Exception as exc:
|
|
654
|
+
return {
|
|
655
|
+
"ok": False,
|
|
656
|
+
"mode": "mic_groundtruth",
|
|
657
|
+
"error": f"{type(exc).__name__}: {exc}",
|
|
658
|
+
"input_device": alsa_device,
|
|
659
|
+
"duration_ms": int((time.monotonic() - started) * 1000),
|
|
660
|
+
}
|
|
661
|
+
raw = completed.stdout or b""
|
|
662
|
+
stderr = completed.stderr.decode("utf-8", errors="replace").strip()
|
|
663
|
+
if completed.returncode != 0 or len(raw) < 2:
|
|
664
|
+
return {
|
|
665
|
+
"ok": False,
|
|
666
|
+
"mode": "mic_groundtruth",
|
|
667
|
+
"error": stderr or f"arecord exited {completed.returncode}",
|
|
668
|
+
"exit_code": completed.returncode,
|
|
669
|
+
"bytes": len(raw),
|
|
670
|
+
"input_device": alsa_device,
|
|
671
|
+
"command": " ".join(command),
|
|
672
|
+
"duration_ms": int((time.monotonic() - started) * 1000),
|
|
673
|
+
}
|
|
674
|
+
sample_count = len(raw) // 2
|
|
675
|
+
peak = 0
|
|
676
|
+
square_sum = 0
|
|
677
|
+
nonzero = 0
|
|
678
|
+
for (value,) in struct.iter_unpack("<h", raw[:sample_count * 2]):
|
|
679
|
+
magnitude = abs(value)
|
|
680
|
+
peak = max(peak, magnitude)
|
|
681
|
+
square_sum += value * value
|
|
682
|
+
if value:
|
|
683
|
+
nonzero += 1
|
|
684
|
+
rms = (square_sum / sample_count) ** 0.5
|
|
685
|
+
peak_db = round(20.0 * math.log10(max(1, peak) / 32767.0), 1)
|
|
686
|
+
rms_db = round(20.0 * math.log10(max(1.0, rms) / 32767.0), 1)
|
|
687
|
+
return {
|
|
688
|
+
"ok": True,
|
|
689
|
+
"pass": nonzero > 0 and peak > 0,
|
|
690
|
+
"mode": "mic_groundtruth",
|
|
691
|
+
"exit_code": completed.returncode,
|
|
692
|
+
"bytes": len(raw),
|
|
693
|
+
"samples": sample_count,
|
|
694
|
+
"nonzero_samples": nonzero,
|
|
695
|
+
"nonzero_percent": round(nonzero * 100.0 / sample_count, 3),
|
|
696
|
+
"recorded_peak": peak,
|
|
697
|
+
"recorded_peak_db": peak_db,
|
|
698
|
+
"recorded_rms": round(rms, 2),
|
|
699
|
+
"recorded_rms_db": rms_db,
|
|
700
|
+
"sample_rate": 48000,
|
|
701
|
+
"channels": 1,
|
|
702
|
+
"input_device": alsa_device,
|
|
703
|
+
"command": " ".join(command),
|
|
704
|
+
"stderr": stderr,
|
|
705
|
+
"duration_ms": int((time.monotonic() - started) * 1000),
|
|
706
|
+
"diagnostic": "ground truth captured through the onsite-proven ALSA arecord path",
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
|
|
629
710
|
def _speaker_roundtrip_test(params: dict) -> dict:
|
|
630
711
|
"""Play a test tone + record the mic simultaneously; return metrics.
|
|
631
712
|
|
|
632
713
|
params may include {frequency, duration, amplitude, output, input,
|
|
633
714
|
threshold_db}. Anything missing falls back to the module constants."""
|
|
634
|
-
try:
|
|
635
|
-
import pyaudio
|
|
636
|
-
except ImportError as e:
|
|
637
|
-
return {"ok": False, "error": f"pyaudio not installed on this robot: {e}"}
|
|
638
715
|
mode = str(params.get("mode", "tone")).lower()
|
|
639
716
|
playback_only = bool(params.get("playback_only", mode == "tts"))
|
|
640
717
|
freq = float(params.get("frequency", SPEAKER_TEST_FREQUENCY_HZ))
|
|
@@ -646,8 +723,16 @@ def _speaker_roundtrip_test(params: dict) -> dict:
|
|
|
646
723
|
out_name = params.get("output_name") or params.get("output", os.environ.get("ROBOPARK_AUDIO_OUTPUT") or SPEAKER_TEST_OUTPUT_DEVICE)
|
|
647
724
|
in_name = params.get("input_name") or params.get("input", os.environ.get("ROBOPARK_AUDIO_INPUT") or SPEAKER_TEST_INPUT_DEVICE)
|
|
648
725
|
source_rate = int(params.get("sample_rate", SPEAKER_TEST_SAMPLE_RATE))
|
|
726
|
+
if mode == "mic_groundtruth":
|
|
727
|
+
if not sys.platform.startswith("linux"):
|
|
728
|
+
return {"ok": False, "error": "mic_groundtruth requires Linux ALSA"}
|
|
729
|
+
return _linux_mic_groundtruth(str(in_name), float(params.get("duration", 4.0)))
|
|
649
730
|
if mode not in ("tone", "tts"):
|
|
650
731
|
return {"ok": False, "error": f"unsupported speaker test mode: {mode}"}
|
|
732
|
+
try:
|
|
733
|
+
import pyaudio
|
|
734
|
+
except ImportError as e:
|
|
735
|
+
return {"ok": False, "error": f"pyaudio not installed on this robot: {e}"}
|
|
651
736
|
if mode == "tone" and not (50.0 <= freq <= 8000.0):
|
|
652
737
|
return {"ok": False, "error": f"frequency {freq}Hz out of allowed range (50..8000)"}
|
|
653
738
|
if not (0.1 <= dur <= 3.0):
|