sawalign 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.
sawalign/__init__.py ADDED
@@ -0,0 +1,4 @@
1
+ """Transcript alignment through Sawalni API."""
2
+ from .client import Sawalign, AlignmentFailed, AlignmentTimeout
3
+ __all__ = ["Sawalign", "AlignmentFailed", "AlignmentTimeout"]
4
+ __version__ = "0.1.0"
sawalign/cli.py ADDED
@@ -0,0 +1,33 @@
1
+ import argparse
2
+ import json
3
+ from pathlib import Path
4
+ from . import Sawalign
5
+
6
+
7
+ def main():
8
+ parser = argparse.ArgumentParser(description="Align reviewed text to an audio recording")
9
+ sub = parser.add_subparsers(dest="command", required=True)
10
+ align = sub.add_parser("align")
11
+ align.add_argument("audio", type=Path)
12
+ align.add_argument("--text", type=Path, required=True)
13
+ align.add_argument("--output", type=Path, default=Path("captions.srt"))
14
+ submit = sub.add_parser("submit")
15
+ submit.add_argument("audio", type=Path)
16
+ submit.add_argument("--text", type=Path, required=True)
17
+ for name in ("status", "wait", "srt"):
18
+ cmd = sub.add_parser(name)
19
+ cmd.add_argument("job_id")
20
+ args = parser.parse_args()
21
+ with Sawalign() as client:
22
+ if args.command in {"align", "submit"}:
23
+ if args.text.stat().st_size > 1_000_000:
24
+ parser.error("Transcript exceeds 1,000,000 UTF-8 bytes")
25
+ job = client.submit(args.audio, args.text.read_text(encoding="utf-8"))
26
+ print(json.dumps(job, ensure_ascii=False), flush=True)
27
+ if args.command == "align":
28
+ job = client.wait(job["job_id"])
29
+ args.output.write_text(client.srt(job["job_id"]), encoding="utf-8")
30
+ elif args.command == "srt":
31
+ print(client.srt(args.job_id), end="")
32
+ else:
33
+ print(json.dumps(getattr(client, args.command)(args.job_id), ensure_ascii=False))
sawalign/client.py ADDED
@@ -0,0 +1,111 @@
1
+ import math
2
+ import os
3
+ import time
4
+ from pathlib import Path
5
+ from urllib.parse import quote
6
+ from typing import Any
7
+ import httpx
8
+
9
+
10
+ class AlignmentFailed(RuntimeError):
11
+ def __init__(self, job: dict[str, Any]):
12
+ self.job = job
13
+ self.job_id = job["job_id"]
14
+ super().__init__(f"Alignment {self.job_id} ended with status {job['status']}")
15
+
16
+
17
+ class AlignmentTimeout(TimeoutError):
18
+ def __init__(self, job_id: str):
19
+ self.job_id = job_id
20
+ super().__init__(f"Alignment timed out; resume with wait({job_id!r})")
21
+
22
+
23
+ class Sawalign:
24
+ """Submit once, poll or resume by job ID. No automatic upload retries."""
25
+ def __init__(self, api_key: str | None = None, *,
26
+ base_url: str = "https://api.sawalni.com/v1",
27
+ timeout: float = 120, transport: httpx.BaseTransport | None = None):
28
+ key = api_key or os.environ.get("SAWALNI_API_KEY")
29
+ if not key:
30
+ raise ValueError("Set SAWALNI_API_KEY or pass api_key")
31
+ self._http = httpx.Client(base_url=base_url.rstrip("/") + "/",
32
+ headers={"Authorization": f"Bearer {key}"}, timeout=timeout,
33
+ transport=transport, follow_redirects=False)
34
+
35
+ def close(self) -> None:
36
+ self._http.close()
37
+
38
+ def __enter__(self):
39
+ return self
40
+
41
+ def __exit__(self, *args):
42
+ self.close()
43
+
44
+ def submit(self, audio: str | Path, text: str) -> dict[str, Any]:
45
+ """Upload an audio recording and its reviewed transcript once."""
46
+ if not text.strip():
47
+ raise ValueError("Transcript must not be empty")
48
+ if len(text.encode("utf-8")) > 1_000_000:
49
+ raise ValueError("Transcript exceeds 1,000,000 UTF-8 bytes")
50
+ path = Path(audio)
51
+ with path.open("rb") as stream:
52
+ size = os.fstat(stream.fileno()).st_size
53
+ if size == 0 or size > 128 * 1024 * 1024:
54
+ raise ValueError("Audio recording must contain 1 byte to 128 MiB")
55
+ response = self._http.post("speech/align",
56
+ files={"audio": (path.name, stream, "application/octet-stream")},
57
+ data={"text": text})
58
+ response.raise_for_status()
59
+ return self._job(response.json())
60
+
61
+ @staticmethod
62
+ def _job(value: Any) -> dict[str, Any]:
63
+ if not isinstance(value, dict) or not isinstance(value.get("job_id"), str) or not value["job_id"] or not isinstance(value.get("status"), str):
64
+ raise ValueError("Invalid alignment job response")
65
+ return value
66
+
67
+ @staticmethod
68
+ def _id(job_id: str) -> str:
69
+ if not isinstance(job_id, str) or not job_id.strip():
70
+ raise ValueError("job_id must not be empty")
71
+ return quote(job_id, safe="")
72
+
73
+ def status(self, job_id: str) -> dict[str, Any]:
74
+ response = self._http.get(f"speech/align/job/{self._id(job_id)}")
75
+ response.raise_for_status()
76
+ job = self._job(response.json())
77
+ if job["job_id"] != job_id:
78
+ raise ValueError("Alignment response has a different job ID")
79
+ return job
80
+
81
+ def wait(self, job_id: str, *, timeout: float = 600,
82
+ poll_interval: float = 2) -> dict[str, Any]:
83
+ """Wait for a submitted job. A timeout preserves its ID for resumption."""
84
+ if not math.isfinite(timeout) or timeout <= 0 or not math.isfinite(poll_interval) or poll_interval <= 0:
85
+ raise ValueError("timeout and poll_interval must be positive finite numbers")
86
+ deadline = time.monotonic() + timeout
87
+ while True:
88
+ remaining = deadline - time.monotonic()
89
+ if remaining <= 0:
90
+ raise AlignmentTimeout(job_id)
91
+ response = self._http.get(f"speech/align/job/{self._id(job_id)}", timeout=min(remaining, 120))
92
+ response.raise_for_status()
93
+ job = self._job(response.json())
94
+ if job["job_id"] != job_id:
95
+ raise ValueError("Alignment response has a different job ID")
96
+ if job["status"] == "done":
97
+ return job
98
+ if job["status"] in {"error", "failed", "cancelled"}:
99
+ raise AlignmentFailed(job)
100
+ time.sleep(min(poll_interval, max(0, deadline - time.monotonic())))
101
+
102
+ def srt(self, job_id: str) -> str:
103
+ """Download SRT from the authenticated API, never from a response URL."""
104
+ response = self._http.get(f"speech/align/job/{self._id(job_id)}/srt")
105
+ response.raise_for_status()
106
+ return response.text
107
+
108
+ def align(self, audio: str | Path, text: str, *, timeout: float = 600,
109
+ poll_interval: float = 2) -> dict[str, Any]:
110
+ job = self.submit(audio, text)
111
+ return self.wait(job["job_id"], timeout=timeout, poll_interval=poll_interval)
sawalign/py.typed ADDED
File without changes
@@ -0,0 +1,78 @@
1
+ Metadata-Version: 2.4
2
+ Name: sawalign
3
+ Version: 0.1.0
4
+ Summary: Align a transcript to an audio recording with Sawalni API
5
+ Author-email: Omar Kamali <omar@omneitylabs.com>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://api.sawalni.com
8
+ Project-URL: Documentation, https://api.sawalni.com/docs
9
+ Project-URL: Examples, https://github.com/omneity-labs/starter-transcription
10
+ Requires-Python: >=3.10
11
+ Description-Content-Type: text/markdown
12
+ License-File: LICENSE
13
+ Requires-Dist: httpx<1,>=0.27
14
+ Dynamic: license-file
15
+
16
+ # Sawalign
17
+
18
+ Align a reviewed transcript to an audio recording. Get word timings and SRT captions through [Sawalni API](https://api.sawalni.com/docs).
19
+
20
+ ```sh
21
+ pip install sawalign
22
+ export SAWALNI_API_KEY="your-key"
23
+ ```
24
+
25
+ ```python
26
+ from pathlib import Path
27
+ from sawalign import Sawalign
28
+
29
+ text = Path("transcript.txt").read_text(encoding="utf-8")
30
+ with Sawalign() as client:
31
+ job = client.submit("interview.wav", text)
32
+ print(job["job_id"]) # Keep this ID so you can resume without uploading again.
33
+ result = client.wait(job["job_id"])
34
+ words = result.get("words_so_far", [])
35
+ Path("captions.srt").write_text(client.srt(job["job_id"]), encoding="utf-8")
36
+ ```
37
+
38
+ Sawalign places the supplied words on the timeline. It does not transcribe the audio recording. Review the transcript before submitting it. For the full transcription workflow, see the [Python and TypeScript starter](https://github.com/omneity-labs/starter-transcription).
39
+
40
+ ## Flow
41
+
42
+ ```mermaid
43
+ flowchart LR
44
+ A[Audio recording] --> C[Sawalign API]
45
+ B[Reviewed transcript] --> C
46
+ C --> D[Job ID]
47
+ D --> E[Word timings]
48
+ D --> F[SRT captions]
49
+ ```
50
+
51
+ ## Command line
52
+
53
+ ```sh
54
+ sawalign align interview.wav --text transcript.txt --output captions.srt
55
+ ```
56
+
57
+ Submit separately to keep the job ID:
58
+
59
+ ```sh
60
+ sawalign submit interview.wav --text transcript.txt
61
+ sawalign status JOB_ID
62
+ sawalign wait JOB_ID
63
+ sawalign srt JOB_ID > captions.srt
64
+ ```
65
+
66
+ ## Client
67
+
68
+ - `submit(audio, text)` uploads once and returns the job.
69
+ - `status(job_id)` returns its current state and available words.
70
+ - `wait(job_id, timeout=600, poll_interval=2)` waits for completion.
71
+ - `srt(job_id)` retrieves SRT captions.
72
+ - `align(audio, text)` submits and waits in one call.
73
+
74
+ `AlignmentTimeout.job_id` lets you resume a timed-out wait. `AlignmentFailed.job` contains the terminal response. HTTP errors use `httpx.HTTPStatusError`; transport errors use `httpx.RequestError`. Uploads are not retried automatically. After an uncertain upload, check the service before submitting again.
75
+
76
+ Audio recording limit: 128 MiB. Transcript limit: 1,000,000 UTF-8 bytes. Keep the API key in a server environment. The package sends data to Sawalni; it does not run the model locally.
77
+
78
+ Python 3.10 or later. MIT license.
@@ -0,0 +1,10 @@
1
+ sawalign/__init__.py,sha256=QbHXn1297KsU37VgXukI4mvk_htXa06V_lBWgX6cxhE,196
2
+ sawalign/cli.py,sha256=VbOlEBCgtm36OWXsNLsAYyc-d9rEhYDafodUevVs7Yc,1470
3
+ sawalign/client.py,sha256=Pfs4jCBzYR5ooGGXV2WhChUy3HnCaz37j5KcVXU2_Hs,4741
4
+ sawalign/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
5
+ sawalign-0.1.0.dist-info/licenses/LICENSE,sha256=nRQxaiQTOi3f1G6BfpYfKOMwtIj7mM6gREvd8qyjIqY,1069
6
+ sawalign-0.1.0.dist-info/METADATA,sha256=EASj-2qxMoPJTWI7jI1LEftaCVJkhrRR2fkNhnZaDyc,2750
7
+ sawalign-0.1.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
8
+ sawalign-0.1.0.dist-info/entry_points.txt,sha256=qhn0quxsxP2xzZKpBIC2tPLE4Cy8e9t1pLmUdUcKunk,47
9
+ sawalign-0.1.0.dist-info/top_level.txt,sha256=l_UfgY6s283qjToZhyXvDd4e1t3Y0HdToukKaZSwLP8,9
10
+ sawalign-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ sawalign = sawalign.cli:main
@@ -0,0 +1,9 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Omneity Labs
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
6
+
7
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
8
+
9
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@@ -0,0 +1 @@
1
+ sawalign