diff --git a/tests/test_asr_incremental.py b/tests/test_asr_incremental.py index 02e821a..9da8ae9 100644 --- a/tests/test_asr_incremental.py +++ b/tests/test_asr_incremental.py @@ -8,10 +8,12 @@ from transclip.asr import TranscriptionResult from transclip.asr_incremental import ( BUCKET_S, + FORCED_CUT_WINDOW_S, IncrementalNarSession, _find_commit_cut, _pad_to_bucket, incremental_transcription_enabled, + plan_segments, ) from transclip.settings import Settings @@ -318,5 +320,91 @@ def transcribe_waveform(self, waveform, sample_rate: int = SR) -> TranscriptionR session.close() +class PlanSegmentsTests(unittest.TestCase): + def test_cuts_at_silence_between_speech(self): + # 15s speech + 1s silence + 15s speech, max 20s: the cut must land in the silence. + pcm = tone_pcm16(15.0) + silence_pcm16(1.0) + tone_pcm16(15.0) + segs = plan_segments(pcm, sample_rate=SR, max_segment_s=20.0) + self.assertEqual(len(segs), 2) + cut_s = segs[0].end_sample / SR + self.assertGreater(cut_s, 15.0) + self.assertLess(cut_s, 16.0) + self.assertTrue(all(s.has_speech for s in segs)) + # Contiguous, no gaps or overlaps: + self.assertEqual(segs[0].start_sample, 0) + self.assertEqual(segs[0].end_sample, segs[1].start_sample) + self.assertEqual(segs[1].end_sample, len(pcm) // 2) + + def test_forced_cut_when_no_silence(self): + # 45s of continuous speech, max 20s: no qualifying silence anywhere -> forced + # cuts; no segment may exceed max, and every segment must be non-empty. + pcm = tone_pcm16(45.0) + segs = plan_segments(pcm, sample_rate=SR, max_segment_s=20.0) + self.assertGreaterEqual(len(segs), 3) + for s in segs: + self.assertGreater(s.end_sample, s.start_sample) + self.assertLessEqual((s.end_sample - s.start_sample) / SR, 20.0 + 1e-9) + # Forced cut lands in the final FORCED_CUT_WINDOW_S of the first window: + self.assertGreaterEqual(segs[0].end_sample / SR, 20.0 - FORCED_CUT_WINDOW_S - 0.1) + + def test_forced_cut_lands_on_the_quietest_frame_not_merely_inside_the_window(self): + # Continuous speech with one short low-energy NOTCH (too brief to be a + # qualifying silence, so the forced-cut path fires) placed at ~18s inside + # the first 20s window's final FORCED_CUT_WINDOW_S. The cut must land ON + # the notch — this is what pins argmin (quietest); an argmax mutation + # would cut at the loud window edge (~16s) and fail. + pcm = tone_pcm16(18.0) + tone_pcm16(0.15, amplitude=0.05) + tone_pcm16(27.0) + segs = plan_segments(pcm, sample_rate=SR, max_segment_s=20.0) + cut_s = segs[0].end_sample / SR + self.assertGreater(cut_s, 17.8) + self.assertLess(cut_s, 18.3) + + def test_cut_lands_on_the_latest_of_two_qualifying_silences(self): + # Two silences in one 20s window; the policy cuts at the LATEST one. An + # earliest-silence mutation would cut at ~4.5s and fail. + pcm = tone_pcm16(4.0) + silence_pcm16(1.0) + tone_pcm16(9.0) + silence_pcm16(1.0) + tone_pcm16(6.0) + segs = plan_segments(pcm, sample_rate=SR, max_segment_s=20.0) + cut_s = segs[0].end_sample / SR + self.assertGreater(cut_s, 14.0) + self.assertLess(cut_s, 15.0) + + def test_mixed_file_flags_only_the_silent_segment_speechless(self): + # A pure-silence segment between speech segments (spanning >1 window) must + # be has_speech=False while its speech neighbours are True — this exercises + # the per-segment recompute, not just homogeneous all-speech/all-silence. + pcm = tone_pcm16(25.0) + silence_pcm16(25.0) + tone_pcm16(25.0) + segs = plan_segments(pcm, sample_rate=SR, max_segment_s=20.0) + self.assertTrue(any(s.has_speech for s in segs)) + self.assertTrue(any(not s.has_speech for s in segs)) + + def test_silence_only_audio_is_flagged_speechless(self): + segs = plan_segments(silence_pcm16(30.0), sample_rate=SR, max_segment_s=20.0) + self.assertTrue(all(not s.has_speech for s in segs)) + + def test_short_audio_is_one_segment(self): + pcm = tone_pcm16(5.0) + segs = plan_segments(pcm, sample_rate=SR, max_segment_s=28.0) + self.assertEqual(len(segs), 1) + self.assertEqual((segs[0].start_sample, segs[0].end_sample), (0, len(pcm) // 2)) + + def test_rejects_window_that_cannot_fit_a_forced_cut(self): + # The forced-cut tail must fit inside the window; below that the planner + # would degenerate (confetti segments, or a stall at <= 0). + with self.assertRaises(ValueError): + plan_segments(tone_pcm16(10.0), sample_rate=SR, max_segment_s=FORCED_CUT_WINDOW_S) + with self.assertRaises(ValueError): + plan_segments(tone_pcm16(10.0), sample_rate=SR, max_segment_s=0.0) + + def test_empty_input_plans_no_segments(self): + self.assertEqual(plan_segments(b"", sample_rate=SR), []) + + def test_rejects_odd_byte_input_with_a_legible_error(self): + # The docstring's even-byte precondition must fail as a caller-oriented + # ValueError, not numpy's "buffer size must be a multiple of element + # size" (the regex is what discriminates the guard from the numpy error). + with self.assertRaisesRegex(ValueError, "even byte length"): + plan_segments(b"\x00", sample_rate=SR) + + if __name__ == "__main__": unittest.main() diff --git a/transclip/asr_incremental.py b/transclip/asr_incremental.py index faefd50..4e034b5 100644 --- a/transclip/asr_incremental.py +++ b/transclip/asr_incremental.py @@ -5,6 +5,14 @@ to process a small residual tail. Committed text can never change because the committed audio is physically trimmed from the buffer (text-agreement commits were measured unsafe; see plans/2026-06-12-streaming-investigation-report.md). + +This module also exposes an OFFLINE segment planner, ``plan_segments`` — a pure +function that cuts a whole PCM16 buffer into bounded segments. It lives here (not +in a module of its own) so it can reuse the private silence-cut helpers +``_find_commit_cut``/``_frame_dbfs``/``_silence_threshold`` the live path uses; a +downstream meeting recorder drives it to segment finished recordings with the +same cut policy dictation applies live. It has no in-tree caller and never runs +during dictation. """ from __future__ import annotations @@ -13,6 +21,7 @@ import math import threading from collections.abc import Callable +from dataclasses import dataclass from typing import Any from transclip.asr import GRANITE_NAR_BUCKET_SECONDS, TranscriptionResult @@ -197,6 +206,36 @@ def _pad_to_bucket(samples: Any, sample_rate: int) -> Any: return padded +def _frame_dbfs(samples: Any, frame_samples: int) -> Any | None: + """Per-frame dBFS for float32 mono samples; None if less than one frame.""" + import numpy as np + + frame_count = len(samples) // frame_samples + if frame_count == 0: + return None + frames = samples[: frame_count * frame_samples].reshape(frame_count, frame_samples) + rms = np.sqrt(np.mean(frames * frames, axis=1)) + return 20.0 * np.log10(rms + 1e-10) + + +def _silence_threshold(dbfs: Any) -> float: + """Adaptive silence threshold: fixed floor, relaxed toward the noise floor. + + PURE function of the passed-in window — the offline ``plan_segments`` path + reproduces the live path's cuts by re-running this over the same content, so + do NOT introduce cross-call/session state here (a cached noise floor, an EMA, + hysteresis): it would keep every test green while silently breaking offline + cut parity with the live session. + """ + import numpy as np + + relative_floor = min( + float(np.percentile(dbfs, 10)) + NOISE_FLOOR_MARGIN_DB, + float(np.percentile(dbfs, 90)) - SPEECH_GAP_DB, + ) + return max(SILENCE_RMS_DBFS, relative_floor) + + def _find_commit_cut( snapshot: bytes, *, @@ -216,18 +255,11 @@ def _find_commit_cut( return None, False frame_samples = sample_rate * FRAME_MS // 1000 samples = np.frombuffer(snapshot[:eligible_bytes], dtype=np.int16).astype(np.float32) / 32768.0 - frame_count = len(samples) // frame_samples - if frame_count == 0: + dbfs = _frame_dbfs(samples, frame_samples) + if dbfs is None: return None, False - frames = samples[: frame_count * frame_samples].reshape(frame_count, frame_samples) - rms = np.sqrt(np.mean(frames * frames, axis=1)) - dbfs = 20.0 * np.log10(rms + 1e-10) - relative_floor = min( - float(np.percentile(dbfs, 10)) + NOISE_FLOOR_MARGIN_DB, - float(np.percentile(dbfs, 90)) - SPEECH_GAP_DB, - ) - threshold = max(SILENCE_RMS_DBFS, relative_floor) - silent = dbfs < threshold + frame_count = len(dbfs) + silent = dbfs < _silence_threshold(dbfs) min_run = max(1, int(SILENCE_MIN_DUR_S * 1000 / FRAME_MS)) run_end = None @@ -250,3 +282,95 @@ def _find_commit_cut( # a wasted pass on noise is cheap, dropping real speech is not. has_speech = bool(np.any(~silent[:best_mid_frame])) return cut, has_speech + + +# Offline segment planner (meeting mode / batch): same silence-cut policy as the +# live session, driven iteratively over a whole file. Far-end speech may never +# pause, so a forced cut at the quietest frame near the window end guarantees +# bounded segments (dictation's pause assumption does not hold in meetings). +FORCED_CUT_WINDOW_S = 4.0 + + +@dataclass(frozen=True, slots=True) +class PlannedSegment: + """One planned segment: half-open sample interval plus a speech flag.""" + + start_sample: int + end_sample: int + has_speech: bool + + +# Default matches the downstream meeting recorder's batch segmentation window. +def plan_segments(pcm16: bytes, *, sample_rate: int, max_segment_s: float = 28.0) -> list[PlannedSegment]: + """Plan bounded transcription segments over PCM16 mono audio. + + ``pcm16`` must contain whole PCM16 samples (even byte length). Cuts at the + latest qualifying silence within each ``max_segment_s`` window (reusing the + live commit-cut policy); if none exists, forces a cut at the quietest frame + in the window's final ``FORCED_CUT_WINDOW_S``. Segments are contiguous, + non-empty, half-open sample intervals ``[start_sample, end_sample)`` that + partition the whole input, and no segment exceeds ``max_segment_s``. + ``has_speech`` uses the same adaptive RMS *threshold* as the live path, + applied over the whole segment (callers may refine it with a VAD). + """ + if max_segment_s <= FORCED_CUT_WINDOW_S: + raise ValueError(f"max_segment_s ({max_segment_s}) must exceed FORCED_CUT_WINDOW_S ({FORCED_CUT_WINDOW_S})") + if len(pcm16) % 2: + # Fail the documented precondition legibly, not as numpy's buffer error. + raise ValueError(f"pcm16 must contain whole PCM16 samples (even byte length), got {len(pcm16)} bytes") + bytes_per_second = sample_rate * 2 + max_bytes = (int(max_segment_s * bytes_per_second) // 2) * 2 + min_cut_bytes = (int(MIN_COMMIT_S * bytes_per_second) // 2) * 2 + segments: list[PlannedSegment] = [] + cursor = 0 + while len(pcm16) - cursor > max_bytes: + window = pcm16[cursor : cursor + max_bytes] + # tail_bytes=0: unlike the live path, offline has the whole file, so no + # trailing context must be reserved — each segment is bucket-padded + # independently downstream. + cut, _ = _find_commit_cut(window, sample_rate=sample_rate, tail_bytes=0, min_commit_bytes=min_cut_bytes) + if cut is None or cut <= 0: + cut = _forced_cut_offset(window, sample_rate) + segments.append(_planned(pcm16, cursor, cursor + cut, sample_rate)) + cursor += cut + if len(pcm16) - cursor > 0: + segments.append(_planned(pcm16, cursor, len(pcm16), sample_rate)) + return segments + + +def _planned(pcm16: bytes, start_byte: int, end_byte: int, sample_rate: int) -> PlannedSegment: + return PlannedSegment( + start_sample=start_byte // 2, + end_sample=end_byte // 2, + has_speech=_segment_has_speech(pcm16[start_byte:end_byte], sample_rate), + ) + + +def _segment_has_speech(segment: bytes, sample_rate: int) -> bool: + import numpy as np + + samples = np.frombuffer(segment, dtype=np.int16).astype(np.float32) / 32768.0 + dbfs = _frame_dbfs(samples, sample_rate * FRAME_MS // 1000) + if dbfs is None: + return False + return bool(np.any(dbfs >= _silence_threshold(dbfs))) + + +def _forced_cut_offset(window: bytes, sample_rate: int) -> int: + """Byte offset of the quietest frame boundary in the window's final stretch.""" + import numpy as np + + frame_samples = sample_rate * FRAME_MS // 1000 + samples = np.frombuffer(window, dtype=np.int16).astype(np.float32) / 32768.0 + dbfs = _frame_dbfs(samples, frame_samples) + if dbfs is None or len(dbfs) < 2: + # Defense-in-depth: unreachable via plan_segments (loop windows are always + # a full max_bytes, > 200 frames given the max_segment_s > 4 s guard), but + # a private helper with an unstated precondition should not argmin an + # empty tail if called directly. + return len(window) + tail_frames = max(1, int(FORCED_CUT_WINDOW_S * 1000 / FRAME_MS)) + start = max(1, len(dbfs) - tail_frames) # never frame 0: cut must advance + quietest = start + int(np.argmin(dbfs[start:])) + cut = quietest * frame_samples * 2 + return cut if 0 < cut <= len(window) else len(window) diff --git a/uv.lock b/uv.lock index c6db704..fe5a3ef 100644 --- a/uv.lock +++ b/uv.lock @@ -1908,6 +1908,10 @@ windows-ui = [ { name = "keyboard", marker = "sys_platform == 'win32'" }, { name = "pillow" }, { name = "pystray" }, + { name = "winrt-runtime", marker = "sys_platform == 'win32'" }, + { name = "winrt-windows-data-xml-dom", marker = "sys_platform == 'win32'" }, + { name = "winrt-windows-foundation", marker = "sys_platform == 'win32'" }, + { name = "winrt-windows-ui-notifications", marker = "sys_platform == 'win32'" }, ] [package.metadata] @@ -1939,6 +1943,10 @@ requires-dist = [ { name = "torchvision", marker = "extra == 'models'", specifier = ">=0.20" }, { name = "transformers", marker = "extra == 'models'", specifier = ">=4.52.1" }, { name = "ty", marker = "extra == 'dev'", specifier = ">=0.0.1a19" }, + { name = "winrt-runtime", marker = "sys_platform == 'win32' and extra == 'windows-ui'", specifier = ">=3.2" }, + { name = "winrt-windows-data-xml-dom", marker = "sys_platform == 'win32' and extra == 'windows-ui'", specifier = ">=3.2" }, + { name = "winrt-windows-foundation", marker = "sys_platform == 'win32' and extra == 'windows-ui'", specifier = ">=3.2" }, + { name = "winrt-windows-ui-notifications", marker = "sys_platform == 'win32' and extra == 'windows-ui'", specifier = ">=3.2" }, ] provides-extras = ["audio", "models", "mlx", "openvino", "macos-ui", "windows-ui", "dev"] @@ -2027,3 +2035,83 @@ sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac8 wheels = [ { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, ] + +[[package]] +name = "winrt-runtime" +version = "3.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/16/dd/acdd527c1d890c8f852cc2af644aa6c160974e66631289420aa871b05e65/winrt_runtime-3.2.1.tar.gz", hash = "sha256:c8dca19e12b234ae6c3dadf1a4d0761b51e708457492c13beb666556958801ea", size = 21721, upload-time = "2025-06-06T14:40:27.593Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d3/54/3dd06f2341fab6abb06588a16b30e0b213b0125be7b79dafc3bdba3b334a/winrt_runtime-3.2.1-cp312-cp312-win32.whl", hash = "sha256:762b3d972a2f7037f7db3acbaf379dd6d8f6cda505f71f66c6b425d1a1eae2f1", size = 210090, upload-time = "2025-06-06T06:44:08.151Z" }, + { url = "https://files.pythonhosted.org/packages/ca/a1/1d7248d5c62ccbea5f3e0da64ca4529ce99c639c3be2485b6ed709f5c740/winrt_runtime-3.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:06510db215d4f0dc45c00fbb1251c6544e91742a0ad928011db33b30677e1576", size = 241391, upload-time = "2025-06-06T06:44:09.442Z" }, + { url = "https://files.pythonhosted.org/packages/8a/ae/6a205d8dafc79f7c242be7f940b1e0c1971fd64ab3079bda4b514aa3d714/winrt_runtime-3.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:14562c29a087ccad38e379e585fef333e5c94166c807bdde67b508a6261aa195", size = 415242, upload-time = "2025-06-06T06:44:10.407Z" }, + { url = "https://files.pythonhosted.org/packages/79/d4/1a555d8bdcb8b920f8e896232c82901cc0cda6d3e4f92842199ae7dff70a/winrt_runtime-3.2.1-cp313-cp313-win32.whl", hash = "sha256:44e2733bc709b76c554aee6c7fe079443b8306b2e661e82eecfebe8b9d71e4d1", size = 210022, upload-time = "2025-06-06T06:44:11.767Z" }, + { url = "https://files.pythonhosted.org/packages/aa/24/2b6e536ca7745d788dfd17a2ec376fa03a8c7116dc638bb39b035635484f/winrt_runtime-3.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:3c1fdcaeedeb2920dc3b9039db64089a6093cad2be56a3e64acc938849245a6d", size = 241349, upload-time = "2025-06-06T06:44:12.661Z" }, + { url = "https://files.pythonhosted.org/packages/d4/7f/6d72973279e2929b2a71ed94198ad4a5d63ee2936e91a11860bf7b431410/winrt_runtime-3.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:28f3dab083412625ff4d2b46e81246932e6bebddf67bea7f05e01712f54e6159", size = 415126, upload-time = "2025-06-06T06:44:13.702Z" }, + { url = "https://files.pythonhosted.org/packages/c8/87/88bd98419a9da77a68e030593fee41702925a7ad8a8aec366945258cbb31/winrt_runtime-3.2.1-cp314-cp314-win32.whl", hash = "sha256:9b6298375468ac2f6815d0c008a059fc16508c8f587e824c7936ed9216480dad", size = 210257, upload-time = "2025-09-20T07:06:41.054Z" }, + { url = "https://files.pythonhosted.org/packages/87/85/e5c2a10d287edd9d3ee8dc24bf7d7f335636b92bf47119768b7dd2fd1669/winrt_runtime-3.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:e36e587ab5fd681ee472cd9a5995743f75107a1a84d749c64f7e490bc86bc814", size = 241873, upload-time = "2025-09-20T07:06:42.059Z" }, + { url = "https://files.pythonhosted.org/packages/52/2a/eb9e78397132175f70dd51dfa4f93e489c17d6b313ae9dce60369b8d84a7/winrt_runtime-3.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:35d6241a2ebd5598e4788e69768b8890ee1eee401a819865767a1fbdd3e9a650", size = 416222, upload-time = "2025-09-20T07:06:43.376Z" }, +] + +[[package]] +name = "winrt-windows-data-xml-dom" +version = "3.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "winrt-runtime" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cc/11/6b1e59abf5f8f0cba1fee13447c9d077d144332b9b664bb236fdfa878558/winrt_windows_data_xml_dom-3.2.1.tar.gz", hash = "sha256:db81f5956bf63ce8c9d8bfb6d0794ab2849f26e62a6256b833af8de3d6451399", size = 50032, upload-time = "2025-06-06T14:41:15.877Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/f6/215e54dce3099507d2a1009792d32555ec1354bfb43cb3431e7389ec3dda/winrt_windows_data_xml_dom-3.2.1-cp312-cp312-win32.whl", hash = "sha256:be2e9edbc4702c0cfd88ae48260b55bb30ee3e15c86c6c4240aaf4f2c54ff380", size = 158777, upload-time = "2025-06-06T06:58:54.755Z" }, + { url = "https://files.pythonhosted.org/packages/9f/f0/b44bdce659118fb684a45f6c4e4932cb7356883079b7ce284352433ecf3f/winrt_windows_data_xml_dom-3.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:b531a61a30106ecc7c7b3ad8f1b22e98d8366bbc2c60c47f5053fe62479109d6", size = 186784, upload-time = "2025-06-06T06:58:55.672Z" }, + { url = "https://files.pythonhosted.org/packages/78/57/cbd053f01b0a50073e4b6e92d1d1e97e992a8cd7dea3dd9afc35a19f5e57/winrt_windows_data_xml_dom-3.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:0b3eed7fd618d0bfbafea730bbcb24cb6a94dcd742b2b9c7d5d66319494e5f35", size = 160634, upload-time = "2025-06-06T06:58:56.589Z" }, + { url = "https://files.pythonhosted.org/packages/ad/3f/682185135537c99629663935155c2332bc500553a2f6aad134deadd2c2d3/winrt_windows_data_xml_dom-3.2.1-cp313-cp313-win32.whl", hash = "sha256:7091ee8d472a18336887edee6bf90d9261783f82b1a0fec00ba0e6a1a00623eb", size = 158825, upload-time = "2025-06-06T06:58:57.484Z" }, + { url = "https://files.pythonhosted.org/packages/e6/02/89057b180a2dda10af0ae8a256180e7f6ec8f688b547501a89a36c72c4e4/winrt_windows_data_xml_dom-3.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:b51b67c0bf88995827151d4e8ff0edcb2120406d4aedcb9574854b904b92a11f", size = 186818, upload-time = "2025-06-06T06:58:58.368Z" }, + { url = "https://files.pythonhosted.org/packages/e5/aa/9894e19b34275307e4857ea3ada1d230bcdc7e399ce0ae504c6309c7e55d/winrt_windows_data_xml_dom-3.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:e4ed27ec593b96bcd13fffd9fd0bf74744b0dacc249faa5af0ef1d903ed59c1b", size = 160670, upload-time = "2025-06-06T06:58:59.225Z" }, + { url = "https://files.pythonhosted.org/packages/a2/e1/22556c7dd4d777c35a270d776a87c4de7e8316d91b51888816e565d4f1f0/winrt_windows_data_xml_dom-3.2.1-cp314-cp314-win32.whl", hash = "sha256:77258030a725a32359fd2a93855cafa0e3f3f38e67afd5dfe74a8a215cbb31c4", size = 160792, upload-time = "2025-09-20T07:09:36.528Z" }, + { url = "https://files.pythonhosted.org/packages/ab/d2/3a4b7e38feafdec21cb044e0c1ee2c76a71cfbda02bfedcc50634e9fb652/winrt_windows_data_xml_dom-3.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:1cf1b6f31fff4e4c0ae30f1643b169da72b3b053a2996010f2c3a1e26b5d4970", size = 189170, upload-time = "2025-09-20T07:09:37.441Z" }, + { url = "https://files.pythonhosted.org/packages/45/cc/9d301892e9a640a63ac56f565d38d5897002598e18acdfbd4ed168836ab3/winrt_windows_data_xml_dom-3.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:6de83f853d889444e4897413c2fa8183f86aa602edd34abf36a4fdc69db1650e", size = 169804, upload-time = "2025-09-20T07:09:38.448Z" }, +] + +[[package]] +name = "winrt-windows-foundation" +version = "3.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "winrt-runtime" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0c/55/098ce7ea0679efcc1298b269c48768f010b6c68f90c588f654ec874c8a74/winrt_windows_foundation-3.2.1.tar.gz", hash = "sha256:ad2f1fcaa6c34672df45527d7c533731fdf65b67c4638c2b4aca949f6eec0656", size = 30485, upload-time = "2025-06-06T14:41:53.344Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f3/f8/495e304ddedd5ff2f196efbde906265cb75ade4d79e2937837f72ef654a0/winrt_windows_foundation-3.2.1-cp312-cp312-win32.whl", hash = "sha256:867642ccf629611733db482c4288e17b7919f743a5873450efb6d69ae09fdc2b", size = 112169, upload-time = "2025-06-06T07:11:01.438Z" }, + { url = "https://files.pythonhosted.org/packages/9b/5e/b5059e4ece095351c496c9499783130c302d25e353c18031d5231b1b3b3c/winrt_windows_foundation-3.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:45550c5b6c2125cde495c409633e6b1ea5aa1677724e3b95eb8140bfccbe30c9", size = 118668, upload-time = "2025-06-06T07:11:02.475Z" }, + { url = "https://files.pythonhosted.org/packages/a5/70/acbcb3ef07b1b67e2de4afab9176a5282cfd775afd073efe6828dfc65ace/winrt_windows_foundation-3.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:94f4661d71cb35ebc52be7af112f2eeabdfa02cb05e0243bf9d6bd2cafaa6f37", size = 109671, upload-time = "2025-06-06T07:11:03.538Z" }, + { url = "https://files.pythonhosted.org/packages/7b/71/5e87131e4aecc8546c76b9e190bfe4e1292d028bda3f9dd03b005d19c76c/winrt_windows_foundation-3.2.1-cp313-cp313-win32.whl", hash = "sha256:3998dc58ed50ecbdbabace1cdef3a12920b725e32a5806d648ad3f4829d5ba46", size = 112184, upload-time = "2025-06-06T07:11:04.459Z" }, + { url = "https://files.pythonhosted.org/packages/ba/7f/8d5108461351d4f6017f550af8874e90c14007f9122fa2eab9f9e0e9b4e1/winrt_windows_foundation-3.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:6e98617c1e46665c7a56ce3f5d28e252798416d1ebfee3201267a644a4e3c479", size = 118672, upload-time = "2025-06-06T07:11:05.55Z" }, + { url = "https://files.pythonhosted.org/packages/44/f5/2edf70922a3d03500dab17121b90d368979bd30016f6dbca0d043f0c71f1/winrt_windows_foundation-3.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:2a8c1204db5c352f6a563130a5a41d25b887aff7897bb677d4ff0b660315aad4", size = 109673, upload-time = "2025-06-06T07:11:06.398Z" }, + { url = "https://files.pythonhosted.org/packages/e3/0a/d77346e39fe0c81f718cde49f83fe77c368c0e14c6418f72dfa1e7ef22d0/winrt_windows_foundation-3.2.1-cp314-cp314-win32.whl", hash = "sha256:35e973ab3c77c2a943e139302256c040e017fd6ff1a75911c102964603bba1da", size = 114590, upload-time = "2025-09-20T07:11:49.97Z" }, + { url = "https://files.pythonhosted.org/packages/a1/56/4d2b545bea0f34f68df6d4d4ca22950ff8a935497811dccdc0ca58737a05/winrt_windows_foundation-3.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:a22a7ebcec0d262e60119cff728f32962a02df60471ded8b2735a655eccc0ef5", size = 122148, upload-time = "2025-09-20T07:11:50.826Z" }, + { url = "https://files.pythonhosted.org/packages/ed/ed/b9d3a11cac73444c0a3703200161cd7267dab5ab85fd00e1f965526e74a8/winrt_windows_foundation-3.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:3be7fbae829b98a6a946db4fbaf356b11db1fbcbb5d4f37e7a73ac6b25de8b87", size = 114360, upload-time = "2025-09-20T07:11:51.626Z" }, +] + +[[package]] +name = "winrt-windows-ui-notifications" +version = "3.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "winrt-runtime" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fc/f9/4798657fd6999e6dae2c84876b40e91873ccd5f056b27c40ebd7f4ff11d8/winrt_windows_ui_notifications-3.2.1.tar.gz", hash = "sha256:eb29bf1c3306f3ceabd71d092b5505d5fe73fbc0660347dcc63ebc4204d34d21", size = 28942, upload-time = "2025-06-06T14:43:54.629Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/73/ae/d495b602154ee70ede2baf3fdb1c60703a581f678e1acc70089a3bdc7d60/winrt_windows_ui_notifications-3.2.1-cp312-cp312-win32.whl", hash = "sha256:041643bd83762900cdd5704a6e08f886879f78952cfc9834b184ee81df924d72", size = 154616, upload-time = "2025-06-06T14:11:47.788Z" }, + { url = "https://files.pythonhosted.org/packages/f7/68/0775e390275cc8fcbd0515eed51e43d78562bfa5d647454e3c8992c2ac8c/winrt_windows_ui_notifications-3.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:7f2378246f6db14d16bf93830f1bcd8efa1c5b4c740878ad86d2f482fb08feab", size = 165782, upload-time = "2025-06-06T14:11:49.905Z" }, + { url = "https://files.pythonhosted.org/packages/d2/fa/09e304022e0ccb4816b27627b04783526e496fe84fd84fade79465b8ad2f/winrt_windows_ui_notifications-3.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:e7eda8cb11a1e82d2da87b2b98cbf73ce153417cb93debe95c739534c6dbde7c", size = 155243, upload-time = "2025-06-06T14:11:51.225Z" }, + { url = "https://files.pythonhosted.org/packages/ab/34/eee24f0ff5522ff3d9c91c983c706fc2a06ed78a5aa84dd9b9fe51125b78/winrt_windows_ui_notifications-3.2.1-cp313-cp313-win32.whl", hash = "sha256:cff07f0193365c38a07cf87030c609b57b7ca7fcbf8740c720425edfa6205fa8", size = 154502, upload-time = "2025-06-06T14:11:52.135Z" }, + { url = "https://files.pythonhosted.org/packages/14/7c/8163639827003c411787d6f6f682e543206ecb47abd29291d184006b5834/winrt_windows_ui_notifications-3.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:9f8d109c8c6634dac258a32d7f3d939aa50605bd7a6a43137a3ffc2ab4a8f667", size = 165795, upload-time = "2025-06-06T14:11:53.004Z" }, + { url = "https://files.pythonhosted.org/packages/0d/10/89012eeb14b932eb3f0ac541d8788d794e09f8fe1934fdae1760d0cbd252/winrt_windows_ui_notifications-3.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:aec78f6a632e2ae57a55b7aee60f8df848ccffd1c4d14c3c9fbfaa6bb564e1a2", size = 155184, upload-time = "2025-06-06T14:11:53.863Z" }, + { url = "https://files.pythonhosted.org/packages/68/43/c81672457002908b8c0f1e4672db1d0b8885c2894c6768fe9b0162af9ee5/winrt_windows_ui_notifications-3.2.1-cp314-cp314-win32.whl", hash = "sha256:00a7579ca0870134c27def1418c259fb18efe5b88aa399de85b4712f1add1b55", size = 157561, upload-time = "2025-09-20T07:19:10.162Z" }, + { url = "https://files.pythonhosted.org/packages/17/d8/910a58698d722120f2401c3bfeb6018361d92f3b2814caae839f54693305/winrt_windows_ui_notifications-3.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:943599c727abf710ae94644b1d521e11857bd568e080e894a8be11aa717e383a", size = 170152, upload-time = "2025-09-20T07:19:11.05Z" }, + { url = "https://files.pythonhosted.org/packages/b5/a3/1db0f2ec28a4eadd0876c0ac3a3d78514027c5afe35737b164e03c98b234/winrt_windows_ui_notifications-3.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:2a496c7e796b537966df7b26753b20bc40c5c1fbe96b3b3051a1debc00b80f07", size = 163245, upload-time = "2025-09-20T07:19:12.352Z" }, +]