feat: transcribe long recordings in overlapping chunks
Sending entire recordings in one request can exceed provider limits. Decode audio once to a shared PCM sample clock and split uploads at a configurable maximum of 1 to 30 minutes, defaulting to 30 minutes. Prefer nearby pauses and include six seconds of overlap to protect words at audio cuts while keeping each encoded MP3 within the duration cap. Request word timestamps for chunked text and SRT output. Match overlapping words and times to retain each passage once, warn when a join falls back to timestamps, and generate SRT cues on the original recording timeline. Reuse subtitle timestamp validation without reordering regressing words. Only emit output after every request and conversion succeeds. Document the Python dependency, temporary storage, boundary limitations, and lack of automatic retry or resume. Keep single-request text formatting. Test Plan: - python3 -B -m unittest -v: all 29 tests passed, including mocked provider failures, native MP3 payload checks, boundary stitching, and SRT timing. - bash -n transcribe and shellcheck transcribe: passed. - git diff --cached --check: passed. - Locally decoded and encoded the supplied 74-minute WebM into three MP3 chunks; ffprobe confirmed each duration was below 30 minutes. - Live transcription was not verified: the configured 1Password credential was unavailable.
This commit is contained in:
1 parent
300203d33f
commit
a5c535cfb9
7 files changed
+635
-57
No files matched your search
@@ -1,19 +1,56 @@
|
|||||||
# Transcribe
|
# Transcribe
|
||||||
|
|
||||||
Requires Bash, FFmpeg, curl, jq, and standard coreutils; `--srt` also requires
|
Requires Bash, FFmpeg, curl, jq, Python 3, and standard coreutils. Keep
|
||||||
Python 3 and the accompanying `subtitles.py` file.
|
`chunks.py` and `subtitles.py` beside the script. The API key is read using
|
||||||
|
the 1Password CLI (`op`) from `op://API Keys/openrouter-mai-transcribe-2/credential`.
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
./transcribe recording.m4a
|
./transcribe recording.m4a
|
||||||
./transcribe video.mp4 > transcript.txt
|
./transcribe video.mp4 > transcript.txt
|
||||||
./transcribe --srt video.mp4 > subtitles.srt
|
./transcribe --srt video.mp4 > subtitles.srt
|
||||||
|
./transcribe --chunk-minutes 10 too_long.webm > transcript.txt
|
||||||
```
|
```
|
||||||
|
|
||||||
Accepts m4a, mp4, aac, flac, opus, mp3, ogg, wav, and other formats your
|
Accepts m4a, mp4, aac, flac, opus, mp3, ogg, wav, and other formats your
|
||||||
FFmpeg supports. Uses the first audio track, converts it to mono 16 kHz MP3
|
FFmpeg supports. Uses the first audio track, converts it to mono 16 kHz MP3
|
||||||
at 64 kbps (requires FFmpeg's libmp3lame encoder),
|
at 64 kbps (requires FFmpeg's libmp3lame encoder),
|
||||||
and sends it to OpenRouter's `microsoft/mai-transcribe-2` model. Temporary
|
and sends it to OpenRouter's `microsoft/mai-transcribe-2` model. Temporary
|
||||||
files are removed on exit. The transcript goes to stdout; errors to stderr.
|
files are removed on exit. The transcript goes to stdout; progress and errors
|
||||||
|
go to stderr. Output is emitted only after all requests and conversion succeed.
|
||||||
|
|
||||||
|
Long recordings are automatically split into uploads of at most **30 minutes**,
|
||||||
|
including overlap and MP3 padding. Use `--chunk-minutes N` (an integer from 1 to
|
||||||
|
30) to lower the limit. At 64 kbps, a 30-minute upload is about 14.4 MB of MP3
|
||||||
|
or 19.2 MB of base64. This is a local default, not a guaranteed provider limit:
|
||||||
|
[OpenRouter documents a 60-second processing timeout](https://openrouter.ai/docs/guides/overview/multimodal/stt),
|
||||||
|
so reduce the chunk size if requests still time out.
|
||||||
|
|
||||||
|
For each boundary, the script looks for a pause of at least 0.3 seconds in the
|
||||||
|
last 30 seconds before the limit, using a -35 dBFS peak threshold. It joins in
|
||||||
|
the middle of the last suitable pause, or at the duration boundary if there is
|
||||||
|
no pause. Each upload includes three seconds beyond the join, so neighbouring
|
||||||
|
uploads share six seconds of audio even during continuous speech. The duration
|
||||||
|
cap includes this overlap; the next chunk starts before the previous one ends.
|
||||||
|
|
||||||
|
Chunked requests always ask for word timestamps, including for plain text.
|
||||||
|
The script matches words and their times in the overlap, switches transcripts
|
||||||
|
at a shared word, and keeps the repeated passage once. It prefers a matching
|
||||||
|
phrase to distinguish intentional repetitions. If no words match, it uses
|
||||||
|
timestamp ownership and warns when speech near the join needs review. Overlap
|
||||||
|
protects words at the audio cuts, but recognition errors or inaccurate model
|
||||||
|
timestamps can still require corrections. Missing or invalid word timestamps
|
||||||
|
are an error. Plain text for a chunked recording is assembled from the words.
|
||||||
|
|
||||||
|
SRT timestamps are shifted back to the original recording's timeline before
|
||||||
|
cue generation, with continuous cue numbering. Speaker labels, when returned,
|
||||||
|
are local to each request; the script does not identify the same speaker across
|
||||||
|
chunks. Short recordings still use one request.
|
||||||
|
|
||||||
|
Audio is first decoded to temporary mono 16 kHz PCM (about 115 MB per hour),
|
||||||
|
so all cuts share the same sample clock even when container duration metadata
|
||||||
|
is missing. Only one MP3 chunk and its request payload are staged at a time.
|
||||||
|
Requests run sequentially. A failed request stops the run; there is no automatic
|
||||||
|
retry or resume, and rerunning sends the earlier chunks again.
|
||||||
|
|
||||||
`--srt` requests word timestamps using OpenRouter's `verbose_json` response.
|
`--srt` requests word timestamps using OpenRouter's `verbose_json` response.
|
||||||
Words are grouped into cues of at most two lines (42 characters per line,
|
Words are grouped into cues of at most two lines (42 characters per line,
|
||||||
@@ -26,7 +63,10 @@ to the adjusted start only when needed. Subtitle output goes to stdout; missing
|
|||||||
or invalid word timestamps produce an error. Without `--srt`, output remains
|
or invalid word timestamps produce an error. Without `--srt`, output remains
|
||||||
plain text.
|
plain text.
|
||||||
|
|
||||||
One file per invocation; the whole recording is sent in one request, so
|
One input file per invocation. To run the local regression tests:
|
||||||
the service's audio length and request size limits apply.
|
|
||||||
|
```sh
|
||||||
|
python3 -B -m unittest -v
|
||||||
|
```
|
||||||
|
|
||||||
[OpenRouter model and API reference](https://openrouter.ai/microsoft/mai-transcribe-2)
|
[OpenRouter model and API reference](https://openrouter.ai/microsoft/mai-transcribe-2)
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
"""Plan bounded audio uploads and stitch their overlapping word transcripts."""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import array
|
||||||
|
import json
|
||||||
|
import math
|
||||||
|
from pathlib import Path
|
||||||
|
import sys
|
||||||
|
import wave
|
||||||
|
|
||||||
|
from subtitles import convert, normalized_words
|
||||||
|
|
||||||
|
CONTEXT = 3.0 # Audio on each side of a seam: six seconds shared by two uploads.
|
||||||
|
ENCODING_MARGIN = 0.1 # MP3 encoder delay and frame padding at 16 kHz.
|
||||||
|
PAUSE_SEARCH = 30.0
|
||||||
|
MIN_SILENCE = 0.3
|
||||||
|
SILENCE_PEAK = 32768 * 10 ** (-35 / 20)
|
||||||
|
|
||||||
|
|
||||||
|
def pause_before(audio, earliest, latest):
|
||||||
|
"""Find the last quiet interval in a small PCM window before the limit."""
|
||||||
|
rate = audio.getframerate()
|
||||||
|
first, last = round(earliest * rate), round(latest * rate)
|
||||||
|
audio.setpos(first)
|
||||||
|
samples = array.array("h", audio.readframes(last - first))
|
||||||
|
if sys.byteorder != "little":
|
||||||
|
samples.byteswap()
|
||||||
|
frame = round(0.02 * rate)
|
||||||
|
quiet_start = None
|
||||||
|
boundary = latest
|
||||||
|
for offset in range(0, len(samples) + frame, frame):
|
||||||
|
block = samples[offset:offset + frame]
|
||||||
|
quiet = bool(block) and max(map(abs, block)) <= SILENCE_PEAK
|
||||||
|
if quiet and quiet_start is None:
|
||||||
|
quiet_start = offset
|
||||||
|
elif not quiet and quiet_start is not None:
|
||||||
|
quiet_end = min(offset, len(samples))
|
||||||
|
if (quiet_end - quiet_start) / rate >= MIN_SILENCE:
|
||||||
|
boundary = (first + (quiet_start + quiet_end) / 2) / rate
|
||||||
|
quiet_start = None
|
||||||
|
return boundary
|
||||||
|
|
||||||
|
|
||||||
|
def plan(path, max_seconds=1800):
|
||||||
|
if not math.isfinite(max_seconds) or not 60 <= max_seconds <= 1800:
|
||||||
|
raise ValueError("Chunk length must be between 1 and 30 minutes")
|
||||||
|
with wave.open(str(path), "rb") as audio:
|
||||||
|
if (audio.getnchannels(), audio.getsampwidth(), audio.getframerate()) != (1, 2, 16000):
|
||||||
|
raise ValueError("Chunk planning requires mono 16 kHz 16-bit PCM WAV")
|
||||||
|
duration = audio.getnframes() / audio.getframerate()
|
||||||
|
if not duration:
|
||||||
|
raise ValueError("Audio file contains no samples")
|
||||||
|
chunks = []
|
||||||
|
core_start = 0.0
|
||||||
|
while core_start < duration:
|
||||||
|
start = max(0.0, core_start - CONTEXT)
|
||||||
|
if duration - start <= max_seconds - ENCODING_MARGIN:
|
||||||
|
core_end = duration
|
||||||
|
else:
|
||||||
|
latest = start + max_seconds - ENCODING_MARGIN - CONTEXT
|
||||||
|
core_end = pause_before(audio, max(core_start, latest - PAUSE_SEARCH), latest)
|
||||||
|
end = min(duration, core_end + CONTEXT)
|
||||||
|
chunks.append({"start": start, "end": end, "seam": core_start})
|
||||||
|
core_start = core_end
|
||||||
|
return chunks
|
||||||
|
|
||||||
|
|
||||||
|
def midpoint(word):
|
||||||
|
return (word["start"] + word["end"]) / 2
|
||||||
|
|
||||||
|
|
||||||
|
def token(word):
|
||||||
|
return "".join(c for c in word["word"].casefold() if c.isalnum())
|
||||||
|
|
||||||
|
|
||||||
|
def stitch(left, right, seam):
|
||||||
|
"""Switch transcripts at a shared word, away from either audio cut.
|
||||||
|
|
||||||
|
Match text AND time to avoid deleting intentional repetitions. Prefer an
|
||||||
|
anchor with a matching neighbour; accept a single shared word when speech
|
||||||
|
is sparse. Timestamp ownership is the fallback if hypotheses disagree.
|
||||||
|
"""
|
||||||
|
radius = CONTEXT - 0.5
|
||||||
|
left_near = [(i, word, token(word)) for i, word in enumerate(left)
|
||||||
|
if abs(midpoint(word) - seam) <= radius]
|
||||||
|
right_near = [(i, word, token(word)) for i, word in enumerate(right)
|
||||||
|
if abs(midpoint(word) - seam) <= radius]
|
||||||
|
matches = {}
|
||||||
|
for i, a, a_token in left_near:
|
||||||
|
for j, b, b_token in right_near:
|
||||||
|
difference = abs(midpoint(a) - midpoint(b))
|
||||||
|
if a_token and a_token == b_token and difference <= 1.0:
|
||||||
|
matches[i, j] = (difference, abs((midpoint(a) + midpoint(b)) / 2 - seam))
|
||||||
|
if matches:
|
||||||
|
def score(pair):
|
||||||
|
i, j = pair
|
||||||
|
run = [matches[pair][0]]
|
||||||
|
for direction in (-1, 1):
|
||||||
|
step = direction
|
||||||
|
while (i + step, j + step) in matches:
|
||||||
|
run.append(matches[i + step, j + step][0])
|
||||||
|
step += direction
|
||||||
|
# A whole matching phrase disambiguates "yes yes yes" better than
|
||||||
|
# a single word that just happens to be closer to the seam.
|
||||||
|
return (-len(run), round(sum(run) / len(run), 3), matches[pair][1])
|
||||||
|
|
||||||
|
i, j = min(matches, key=score)
|
||||||
|
return left[:i + 1] + right[j + 1:]
|
||||||
|
|
||||||
|
if left_near or right_near:
|
||||||
|
print(
|
||||||
|
f"Warning: no shared words near {seam:.2f}s; joining by timestamps. "
|
||||||
|
"Review the transcript at this boundary.", file=sys.stderr,
|
||||||
|
)
|
||||||
|
return ([word for word in left if midpoint(word) < seam] +
|
||||||
|
[word for word in right if midpoint(word) >= seam])
|
||||||
|
|
||||||
|
|
||||||
|
def merge(chunks, responses):
|
||||||
|
if len(chunks) != len(responses) or not chunks:
|
||||||
|
raise ValueError("Expected one transcription response per chunk")
|
||||||
|
merged = []
|
||||||
|
for index, (chunk, response) in enumerate(zip(chunks, responses)):
|
||||||
|
try:
|
||||||
|
words = normalized_words(response, "Chunk stitching")
|
||||||
|
for word in words:
|
||||||
|
if word["end"] > chunk["end"] - chunk["start"] + 1.0:
|
||||||
|
raise ValueError("Word timestamp exceeds the chunk duration")
|
||||||
|
word["start"] += chunk["start"]
|
||||||
|
word["end"] += chunk["start"]
|
||||||
|
if word.get("speaker") is not None:
|
||||||
|
# Speaker IDs belong to individual requests, not the file.
|
||||||
|
word["speaker"] = (index, word["speaker"])
|
||||||
|
except ValueError as error:
|
||||||
|
raise ValueError(f"Chunk {index + 1}: {error}") from error
|
||||||
|
merged = stitch(merged, words, chunk["seam"]) if index else words
|
||||||
|
return {"text": " ".join(word["word"] for word in merged), "words": merged}
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
commands = parser.add_subparsers(dest="command", required=True)
|
||||||
|
planner = commands.add_parser("plan")
|
||||||
|
planner.add_argument("audio")
|
||||||
|
planner.add_argument("minutes", type=int)
|
||||||
|
merger = commands.add_parser("merge")
|
||||||
|
merger.add_argument("manifest", type=Path)
|
||||||
|
merger.add_argument("--srt", action="store_true")
|
||||||
|
args = parser.parse_args()
|
||||||
|
try:
|
||||||
|
if args.command == "plan":
|
||||||
|
print(json.dumps(plan(args.audio, args.minutes * 60)))
|
||||||
|
else:
|
||||||
|
chunks = json.loads(args.manifest.read_text(encoding="utf-8"))
|
||||||
|
responses = [json.loads(
|
||||||
|
(args.manifest.parent / f"response-{i}.json").read_text(encoding="utf-8")
|
||||||
|
) for i in range(len(chunks))]
|
||||||
|
response = merge(chunks, responses)
|
||||||
|
sys.stdout.write(convert(response) if args.srt else response["text"] + "\n")
|
||||||
|
except (ValueError, OSError, wave.Error) as error:
|
||||||
|
parser.exit(1, f"Audio chunking failed: {error}\n")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
+34
-17
@@ -25,27 +25,21 @@ def timestamp(seconds):
|
|||||||
return f"{hours:02}:{minutes:02}:{seconds:02},{milliseconds:03}"
|
return f"{hours:02}:{minutes:02}:{seconds:02},{milliseconds:03}"
|
||||||
|
|
||||||
|
|
||||||
def convert(response):
|
def normalized_words(response, purpose="SRT output"):
|
||||||
|
"""Validate word timing and preserve transcript order despite regressions."""
|
||||||
|
if not isinstance(response, dict):
|
||||||
|
raise ValueError("Transcription response must be a JSON object")
|
||||||
if response.get("error"):
|
if response.get("error"):
|
||||||
raise ValueError(f"Transcription failed: {response['error']}")
|
raise ValueError(f"Transcription failed: {response['error']}")
|
||||||
|
text = response.get("text", "")
|
||||||
|
if not isinstance(text, str):
|
||||||
|
raise ValueError("Transcription response must contain text as a string")
|
||||||
words = response.get("words")
|
words = response.get("words")
|
||||||
if not isinstance(words, list) or (not words and response.get("text", "").strip()):
|
missing = f"{purpose} requires word timestamps; the API returned none"
|
||||||
raise ValueError("SRT output requires word timestamps; the API returned none")
|
if not isinstance(words, list):
|
||||||
|
raise ValueError(missing)
|
||||||
cues = []
|
result = []
|
||||||
current = []
|
|
||||||
previous_start = -1
|
previous_start = -1
|
||||||
|
|
||||||
def flush():
|
|
||||||
if current:
|
|
||||||
text = " ".join(word["word"] for word in current)
|
|
||||||
start = current[0]["start"]
|
|
||||||
end = min(max(word["end"] for word in current), start + MAX_DURATION)
|
|
||||||
# Avoid zero-length cues after rounding very short word durations.
|
|
||||||
end = max(end, math.floor(start * 1000 + 0.5) / 1000 + 0.001)
|
|
||||||
cues.append((start, end, "\n".join(lines(text))))
|
|
||||||
current.clear()
|
|
||||||
|
|
||||||
for item in words:
|
for item in words:
|
||||||
if not isinstance(item, dict) or not isinstance(item.get("word"), str):
|
if not isinstance(item, dict) or not isinstance(item.get("word"), str):
|
||||||
raise ValueError("Invalid subtitle word: expected word text")
|
raise ValueError("Invalid subtitle word: expected word text")
|
||||||
@@ -70,6 +64,29 @@ def convert(response):
|
|||||||
end = max(end, start)
|
end = max(end, start)
|
||||||
word.update(start=start, end=end)
|
word.update(start=start, end=end)
|
||||||
previous_start = start
|
previous_start = start
|
||||||
|
result.append(word)
|
||||||
|
if not result and text.strip():
|
||||||
|
raise ValueError(missing)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def convert(response):
|
||||||
|
words = normalized_words(response)
|
||||||
|
cues = []
|
||||||
|
current = []
|
||||||
|
|
||||||
|
def flush():
|
||||||
|
if current:
|
||||||
|
text = " ".join(word["word"] for word in current)
|
||||||
|
start = current[0]["start"]
|
||||||
|
end = min(max(word["end"] for word in current), start + MAX_DURATION)
|
||||||
|
# Avoid zero-length cues after rounding very short word durations.
|
||||||
|
end = max(end, math.floor(start * 1000 + 0.5) / 1000 + 0.001)
|
||||||
|
cues.append((start, end, "\n".join(lines(text))))
|
||||||
|
current.clear()
|
||||||
|
|
||||||
|
for word in words:
|
||||||
|
start, end = word["start"], word["end"]
|
||||||
if len(lines(word["word"])) > 2:
|
if len(lines(word["word"])) > 2:
|
||||||
raise ValueError("Invalid subtitle word: text exceeds two lines")
|
raise ValueError("Invalid subtitle word: text exceeds two lines")
|
||||||
if current:
|
if current:
|
||||||
|
|||||||
+154
@@ -0,0 +1,154 @@
|
|||||||
|
import contextlib
|
||||||
|
import copy
|
||||||
|
import io
|
||||||
|
from pathlib import Path
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
import wave
|
||||||
|
|
||||||
|
from chunks import CONTEXT, ENCODING_MARGIN, merge, plan, stitch
|
||||||
|
from subtitles import convert
|
||||||
|
|
||||||
|
|
||||||
|
def word(text, start, end=None):
|
||||||
|
return {"word": text, "start": start, "end": start + 0.4 if end is None else end}
|
||||||
|
|
||||||
|
|
||||||
|
def make_audio(path, duration, pauses=()):
|
||||||
|
rate = 16000
|
||||||
|
samples = bytearray(b"\x10\x27" * round(rate * duration))
|
||||||
|
for start, end in pauses:
|
||||||
|
first, last = round(start * rate) * 2, round(end * rate) * 2
|
||||||
|
samples[first:last] = bytes(last - first)
|
||||||
|
with wave.open(str(path), "wb") as audio:
|
||||||
|
audio.setparams((1, 2, rate, 0, "NONE", "not compressed"))
|
||||||
|
audio.writeframes(samples)
|
||||||
|
|
||||||
|
|
||||||
|
class ChunkPlanTests(unittest.TestCase):
|
||||||
|
def planned(self, duration, pauses=(), limit=60):
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
path = Path(directory) / "audio.wav"
|
||||||
|
make_audio(path, duration, pauses)
|
||||||
|
return plan(path, limit)
|
||||||
|
|
||||||
|
def test_short_audio_is_one_upload(self):
|
||||||
|
self.assertEqual(self.planned(20), [{"start": 0, "end": 20, "seam": 0}])
|
||||||
|
|
||||||
|
def test_continuous_speech_is_bounded_and_has_no_gaps(self):
|
||||||
|
for duration in (59.9, 60, 70, 120, 140):
|
||||||
|
with self.subTest(duration=duration):
|
||||||
|
chunks = self.planned(duration)
|
||||||
|
self.assertEqual(chunks[0]["start"], 0)
|
||||||
|
self.assertEqual(chunks[-1]["end"], duration)
|
||||||
|
for chunk in chunks:
|
||||||
|
self.assertGreater(chunk["end"], chunk["start"])
|
||||||
|
self.assertLessEqual(chunk["end"] - chunk["start"], 60 - ENCODING_MARGIN + 1e-8)
|
||||||
|
for previous, current in zip(chunks, chunks[1:]):
|
||||||
|
self.assertAlmostEqual(previous["end"] - current["start"], CONTEXT * 2)
|
||||||
|
self.assertAlmostEqual(current["seam"] - current["start"], CONTEXT)
|
||||||
|
|
||||||
|
def test_uses_last_pause_before_limit(self):
|
||||||
|
chunks = self.planned(130, [(30, 31), (53, 54)])
|
||||||
|
self.assertAlmostEqual(chunks[1]["seam"], 53.5)
|
||||||
|
self.assertAlmostEqual(chunks[0]["end"], 56.5)
|
||||||
|
self.assertAlmostEqual(chunks[1]["start"], 50.5)
|
||||||
|
|
||||||
|
def test_short_quiet_sound_does_not_count_as_pause(self):
|
||||||
|
chunks = self.planned(70, [(53, 53.1)])
|
||||||
|
self.assertAlmostEqual(chunks[1]["seam"], 56.9)
|
||||||
|
|
||||||
|
def test_all_silence_still_makes_progress(self):
|
||||||
|
chunks = self.planned(140, [(0, 140)])
|
||||||
|
self.assertEqual(chunks[-1]["end"], 140)
|
||||||
|
self.assertLess(len(chunks), 10)
|
||||||
|
self.assertTrue(all(0 < c["end"] - c["start"] <= 60 for c in chunks))
|
||||||
|
|
||||||
|
def test_invalid_limit_and_empty_audio(self):
|
||||||
|
for limit in (0, 59, 1801, float("inf"), float("nan")):
|
||||||
|
with self.subTest(limit=limit), self.assertRaises(ValueError):
|
||||||
|
self.planned(1, limit=limit)
|
||||||
|
with self.assertRaisesRegex(ValueError, "no samples"):
|
||||||
|
self.planned(0)
|
||||||
|
|
||||||
|
|
||||||
|
class ChunkMergeTests(unittest.TestCase):
|
||||||
|
def test_boundary_word_survives_once_despite_timestamp_drift(self):
|
||||||
|
left = [word("before", 8), word("boundary,", 9.8, 10.4), word("after", 10.5)]
|
||||||
|
right = [word("Boundary", 10.0, 10.6), word("after", 10.6), word("later", 14)]
|
||||||
|
result = stitch(left, right, 10)
|
||||||
|
self.assertEqual([w["word"] for w in result], ["before", "boundary,", "after", "later"])
|
||||||
|
|
||||||
|
def test_words_cut_at_upload_edges_are_discarded(self):
|
||||||
|
left = [word("Before", 5), word("shared", 9), word("words", 10), word("incom-", 12.8)]
|
||||||
|
right = [word("-fore", 7), word("shared", 9.1), word("words", 10.1), word("incomplete", 12.6)]
|
||||||
|
result = stitch(left, right, 10)
|
||||||
|
self.assertEqual([w["word"] for w in result], ["Before", "shared", "words", "incomplete"])
|
||||||
|
|
||||||
|
def test_intentional_repeated_words_are_not_removed(self):
|
||||||
|
left = [word("yes", start, start + 0.1) for start in (9.4, 9.8, 10.2, 10.6)]
|
||||||
|
right = copy.deepcopy(left) + [word("done", 14)]
|
||||||
|
result = stitch(left, right, 10)
|
||||||
|
self.assertEqual([w["word"] for w in result], ["yes"] * 4 + ["done"])
|
||||||
|
|
||||||
|
def test_identical_text_outside_overlap_is_not_deduplicated(self):
|
||||||
|
left = [word("again", 1)]
|
||||||
|
right = [word("again", 20)]
|
||||||
|
self.assertEqual(stitch(left, right, 10), left + right)
|
||||||
|
|
||||||
|
def test_no_match_uses_whole_words_and_warns(self):
|
||||||
|
left = [word("before", 8), word("left", 9.5, 10.1), word("discard", 11)]
|
||||||
|
right = [word("discard", 8), word("right", 10.1), word("after", 14)]
|
||||||
|
with contextlib.redirect_stderr(io.StringIO()) as errors:
|
||||||
|
result = stitch(left, right, 10)
|
||||||
|
self.assertEqual([w["word"] for w in result], ["before", "left", "right", "after"])
|
||||||
|
self.assertIn("joining by timestamps", errors.getvalue())
|
||||||
|
|
||||||
|
def test_silent_overlap_does_not_warn(self):
|
||||||
|
with contextlib.redirect_stderr(io.StringIO()) as errors:
|
||||||
|
self.assertEqual(stitch([word("before", 1)], [word("after", 20)], 10),
|
||||||
|
[word("before", 1), word("after", 20)])
|
||||||
|
self.assertEqual(errors.getvalue(), "")
|
||||||
|
|
||||||
|
def test_offsets_three_chunks_and_keeps_srt_numbering(self):
|
||||||
|
chunks = [{"start": 0, "end": 63, "seam": 0},
|
||||||
|
{"start": 57, "end": 120, "seam": 60},
|
||||||
|
{"start": 114, "end": 140, "seam": 117}]
|
||||||
|
responses = [
|
||||||
|
{"words": [word("First.", 1), word("Join.", 59.8, 60.2)]},
|
||||||
|
{"words": [word("Join.", 2.9, 3.3), word("Second.", 20), word("Next.", 59.8, 60.2)]},
|
||||||
|
{"words": [word("Next.", 2.9, 3.3), word("Last.", 20)]},
|
||||||
|
]
|
||||||
|
original = copy.deepcopy(responses)
|
||||||
|
result = merge(chunks, responses)
|
||||||
|
self.assertEqual(result["text"], "First. Join. Second. Next. Last.")
|
||||||
|
self.assertEqual([w["start"] for w in result["words"]], [1, 59.8, 77, 116.8, 134])
|
||||||
|
self.assertEqual(responses, original)
|
||||||
|
srt = convert(result)
|
||||||
|
self.assertIn("5\n00:02:14,000", srt)
|
||||||
|
|
||||||
|
def test_regression_preserves_order_and_scopes_speakers_per_chunk(self):
|
||||||
|
chunks = [{"start": 0, "end": 63, "seam": 0}, {"start": 57, "end": 100, "seam": 60}]
|
||||||
|
first = dict(word("First", 10), speaker=0)
|
||||||
|
second = dict(word("Second", 9), speaker=0)
|
||||||
|
last = dict(word("Last", 20), speaker=0)
|
||||||
|
result = merge(chunks, [{"words": [first, second]}, {"words": [last]}])
|
||||||
|
self.assertEqual([w["start"] for w in result["words"]], [10, 10, 77])
|
||||||
|
self.assertNotEqual(result["words"][0]["speaker"], result["words"][-1]["speaker"])
|
||||||
|
|
||||||
|
def test_missing_or_malformed_words_fail(self):
|
||||||
|
chunks = [{"start": 0, "end": 30, "seam": 0}]
|
||||||
|
for response in ({"text": "missing"}, {"text": "missing", "words": []},
|
||||||
|
{"text": "missing", "words": [word(" ", 1)]},
|
||||||
|
{"text": None, "words": []},
|
||||||
|
{"words": [word("invalid", 100)]}, {"words": [word("invalid", -1)]},
|
||||||
|
{"error": "bad response"}, []):
|
||||||
|
with self.subTest(response=response), self.assertRaisesRegex(ValueError, "Chunk 1"):
|
||||||
|
merge(chunks, [response])
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
merge(chunks, [])
|
||||||
|
self.assertEqual(merge(chunks, [{"text": "", "words": []}])["text"], "")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from test_chunks import make_audio
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent
|
||||||
|
TOOLS = ("bash", "ffmpeg", "ffprobe", "jq", "base64", "mktemp", "realpath")
|
||||||
|
|
||||||
|
|
||||||
|
@unittest.skipUnless(all(shutil.which(tool) for tool in TOOLS), "CLI media dependencies missing")
|
||||||
|
class TranscribeTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.directory = tempfile.TemporaryDirectory()
|
||||||
|
self.addCleanup(self.directory.cleanup)
|
||||||
|
self.path = Path(self.directory.name)
|
||||||
|
self.bin = self.path / "bin"
|
||||||
|
self.bin.mkdir()
|
||||||
|
for name in ("op", "curl"):
|
||||||
|
fake = self.bin / name
|
||||||
|
shutil.copyfile(ROOT / "tests" / "fake_provider.py", fake)
|
||||||
|
fake.chmod(0o755)
|
||||||
|
self.log = self.path / "requests.jsonl"
|
||||||
|
self.env = dict(os.environ, PATH=f"{self.bin}{os.pathsep}{os.environ['PATH']}",
|
||||||
|
TRANSCRIBE_TEST_LOG=str(self.log), TRANSCRIBE_TEST_MODE="ok")
|
||||||
|
self.audio = self.path / "input with spaces.wav"
|
||||||
|
|
||||||
|
def run_cli(self, *args, mode="ok", duration=70):
|
||||||
|
make_audio(self.audio, duration)
|
||||||
|
return subprocess.run([str(ROOT / "transcribe"), *args, str(self.audio)],
|
||||||
|
env=dict(self.env, TRANSCRIBE_TEST_MODE=mode),
|
||||||
|
capture_output=True, text=True, timeout=30)
|
||||||
|
|
||||||
|
def requests(self):
|
||||||
|
return [json.loads(line) for line in self.log.read_text().splitlines()]
|
||||||
|
|
||||||
|
def assert_cleaned(self):
|
||||||
|
self.assertTrue(all(not Path(r["staging"]).exists() for r in self.requests()))
|
||||||
|
|
||||||
|
def test_long_text_uploads_bounded_mp3_and_stitches_once(self):
|
||||||
|
result = self.run_cli("--chunk-minutes", "1")
|
||||||
|
self.assertEqual(result.returncode, 0, result.stderr)
|
||||||
|
self.assertEqual(result.stdout, "Hello. boundary continues. Last.\n")
|
||||||
|
self.assertIn("chunk 2/2", result.stderr)
|
||||||
|
requests = self.requests()
|
||||||
|
self.assertEqual(len(requests), 2)
|
||||||
|
self.assertAlmostEqual(requests[0]["chunk"]["end"] - requests[1]["chunk"]["start"], 6)
|
||||||
|
self.assert_cleaned()
|
||||||
|
|
||||||
|
def test_long_srt_uses_original_timeline_and_continuous_numbers(self):
|
||||||
|
result = self.run_cli("--srt", "--chunk-minutes", "1")
|
||||||
|
self.assertEqual(result.returncode, 0, result.stderr)
|
||||||
|
self.assertEqual(result.stdout, (
|
||||||
|
"1\n00:00:01,000 --> 00:00:01,400\nHello.\n\n"
|
||||||
|
"2\n00:00:56,700 --> 00:00:57,800\nboundary continues.\n\n"
|
||||||
|
"3\n00:01:05,000 --> 00:01:05,400\nLast.\n\n"
|
||||||
|
))
|
||||||
|
self.assert_cleaned()
|
||||||
|
|
||||||
|
def test_short_text_retains_provider_formatting(self):
|
||||||
|
result = self.run_cli(duration=5)
|
||||||
|
self.assertEqual(result.returncode, 0, result.stderr)
|
||||||
|
self.assertEqual(result.stdout, "Provider's original formatting.\nNext line.\n")
|
||||||
|
self.assertEqual(len(self.requests()), 1)
|
||||||
|
self.assert_cleaned()
|
||||||
|
|
||||||
|
def test_short_srt_still_requests_word_timestamps(self):
|
||||||
|
result = self.run_cli("--srt", duration=5)
|
||||||
|
self.assertEqual(result.returncode, 0, result.stderr)
|
||||||
|
self.assertEqual(result.stdout, "1\n00:00:01,000 --> 00:00:01,400\nHello.\n\n")
|
||||||
|
|
||||||
|
def test_failed_later_chunk_has_no_partial_stdout_and_cleans_up(self):
|
||||||
|
for mode in ("http-error", "bad-json", "api-error", "missing-words"):
|
||||||
|
with self.subTest(mode=mode):
|
||||||
|
result = self.run_cli("--chunk-minutes", "1", mode=mode)
|
||||||
|
self.assertNotEqual(result.returncode, 0)
|
||||||
|
self.assertEqual(result.stdout, "")
|
||||||
|
self.assertTrue(result.stderr)
|
||||||
|
self.assert_cleaned()
|
||||||
|
|
||||||
|
def test_invalid_limits_fail_before_provider_access(self):
|
||||||
|
for value in ("0", "31", "abc", "1.5", "-1"):
|
||||||
|
with self.subTest(value=value):
|
||||||
|
result = self.run_cli("--chunk-minutes", value, duration=1)
|
||||||
|
self.assertNotEqual(result.returncode, 0)
|
||||||
|
self.assertIn("integer between 1 and 30", result.stderr)
|
||||||
|
self.assertFalse(self.log.exists())
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Test-only replacements for op/curl; no network or real credentials."""
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
|
if Path(sys.argv[0]).name == "op":
|
||||||
|
print("test-api-key")
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
args = sys.argv[1:]
|
||||||
|
request = json.loads(Path(args[args.index("--data-binary") + 1][1:]).read_text())
|
||||||
|
output = Path(args[args.index("-o") + 1])
|
||||||
|
index = int(output.stem.split("-")[1])
|
||||||
|
manifest = json.loads((output.parent / "chunks.json").read_text())
|
||||||
|
chunk = manifest[index]
|
||||||
|
assert request["model"] == "microsoft/mai-transcribe-2"
|
||||||
|
assert request["input_audio"]["format"] == "mp3"
|
||||||
|
if len(manifest) > 1:
|
||||||
|
assert request["response_format"] == "verbose_json"
|
||||||
|
assert request["timestamp_granularities"] == ["segment", "word"]
|
||||||
|
|
||||||
|
audio = output.parent / "uploaded.mp3"
|
||||||
|
audio.write_bytes(base64.b64decode(request["input_audio"]["data"], validate=True))
|
||||||
|
info = json.loads(subprocess.check_output([
|
||||||
|
"ffprobe", "-v", "error", "-show_entries",
|
||||||
|
"format=duration:stream=codec_name,sample_rate,channels", "-of", "json", str(audio),
|
||||||
|
], text=True))
|
||||||
|
assert info["streams"][0] == {"codec_name": "mp3", "sample_rate": "16000", "channels": 1}
|
||||||
|
assert float(info["format"]["duration"]) <= 60
|
||||||
|
assert abs(float(info["format"]["duration"]) - (chunk["end"] - chunk["start"])) < 0.1
|
||||||
|
with open(os.environ["TRANSCRIBE_TEST_LOG"], "a") as log:
|
||||||
|
log.write(json.dumps({"index": index, "chunk": chunk, "staging": str(output.parent)}) + "\n")
|
||||||
|
|
||||||
|
mode = os.environ.get("TRANSCRIBE_TEST_MODE", "ok")
|
||||||
|
if index == 1:
|
||||||
|
if mode == "http-error":
|
||||||
|
output.write_text('{"error":{"message":"simulated timeout"}}')
|
||||||
|
sys.exit(22)
|
||||||
|
if mode == "bad-json":
|
||||||
|
output.write_text("not json")
|
||||||
|
sys.exit(0)
|
||||||
|
if mode == "api-error":
|
||||||
|
output.write_text('{"error":{"message":"simulated API error"},"text":""}')
|
||||||
|
sys.exit(0)
|
||||||
|
if mode == "missing-words":
|
||||||
|
output.write_text('{"text":"speech without timestamps"}')
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
if "response_format" not in request:
|
||||||
|
response = {"text": "Provider's original formatting.\nNext line."}
|
||||||
|
else:
|
||||||
|
words = [
|
||||||
|
{"word": text, "start": start - chunk["start"], "end": end - chunk["start"]}
|
||||||
|
for text, start, end in [
|
||||||
|
("Hello.", 1, 1.4), ("boundary", 56.7, 57.1),
|
||||||
|
("continues.", 57.4, 57.8), ("Last.", 65, 65.4),
|
||||||
|
] if start >= chunk["start"] and end <= chunk["end"]
|
||||||
|
]
|
||||||
|
response = {"text": " ".join(w["word"] for w in words), "words": words}
|
||||||
|
output.write_text(json.dumps(response))
|
||||||
+76
-35
@@ -3,18 +3,32 @@
|
|||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
srt=false
|
srt=false
|
||||||
if [[ ${1:-} == --srt ]]; then
|
chunk_minutes=30
|
||||||
srt=true
|
usage() {
|
||||||
shift
|
echo "Usage: ${0##*/} [--srt] [--chunk-minutes 1..30] AUDIO_FILE"
|
||||||
fi
|
|
||||||
if [[ $# == 1 && ($1 == -h || $1 == --help) ]]; then
|
|
||||||
echo "Usage: ${0##*/} [--srt] AUDIO_FILE"
|
|
||||||
echo 'Transcribe audio using OPENROUTER_API_KEY; print the text to stdout.'
|
echo 'Transcribe audio using OPENROUTER_API_KEY; print the text to stdout.'
|
||||||
echo ' --srt Print SRT subtitles in short cues using word timestamps instead.'
|
echo ' --srt Print SRT subtitles in short cues using word timestamps instead.'
|
||||||
exit 0
|
echo ' --chunk-minutes N Maximum upload duration in minutes (default: 30).'
|
||||||
fi
|
}
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case $1 in
|
||||||
|
--srt) srt=true; shift ;;
|
||||||
|
--chunk-minutes)
|
||||||
|
if [[ ! ${2:-} =~ ^([1-9]|[12][0-9]|30)$ ]]; then
|
||||||
|
echo '--chunk-minutes must be an integer between 1 and 30' >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
chunk_minutes=$2
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
-h|--help) usage; exit 0 ;;
|
||||||
|
--) shift; break ;;
|
||||||
|
-*) usage >&2; exit 1 ;;
|
||||||
|
*) break ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
if [[ $# != 1 ]]; then
|
if [[ $# != 1 ]]; then
|
||||||
echo "Usage: ${0##*/} [--srt] AUDIO_FILE" >&2
|
usage >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
if [[ ! -f $1 || ! -r $1 ]]; then
|
if [[ ! -f $1 || ! -r $1 ]]; then
|
||||||
@@ -24,10 +38,7 @@ fi
|
|||||||
export OPENROUTER_API_KEY
|
export OPENROUTER_API_KEY
|
||||||
OPENROUTER_API_KEY="$(op read "op://API Keys/openrouter-mai-transcribe-2/credential")"
|
OPENROUTER_API_KEY="$(op read "op://API Keys/openrouter-mai-transcribe-2/credential")"
|
||||||
: "${OPENROUTER_API_KEY:?Set OPENROUTER_API_KEY first}"
|
: "${OPENROUTER_API_KEY:?Set OPENROUTER_API_KEY first}"
|
||||||
dependencies=(ffmpeg curl jq base64 mktemp)
|
dependencies=(ffmpeg curl jq base64 mktemp python3)
|
||||||
if "$srt"; then
|
|
||||||
dependencies+=(python3)
|
|
||||||
fi
|
|
||||||
for tool in "${dependencies[@]}"; do
|
for tool in "${dependencies[@]}"; do
|
||||||
command -v "$tool" >/dev/null || {
|
command -v "$tool" >/dev/null || {
|
||||||
echo "Missing dependency: $tool" >&2
|
echo "Missing dependency: $tool" >&2
|
||||||
@@ -39,31 +50,61 @@ tmp=$(mktemp -d)
|
|||||||
trap 'rm -rf -- "$tmp"' EXIT
|
trap 'rm -rf -- "$tmp"' EXIT
|
||||||
trap 'exit 130' INT
|
trap 'exit 130' INT
|
||||||
trap 'exit 143' TERM
|
trap 'exit 143' TERM
|
||||||
|
script_dir=$(dirname -- "$(realpath -- "$0")")
|
||||||
|
|
||||||
# Normalize any FFmpeg-readable audio (or the first audio track in a video).
|
# Decode once so duration, pause detection, and all cuts share a sample clock.
|
||||||
|
# This also works for inputs whose container omits or misreports the duration.
|
||||||
ffmpeg -nostdin -hide_banner -loglevel error -i "$(realpath -- "$1")" \
|
ffmpeg -nostdin -hide_banner -loglevel error -i "$(realpath -- "$1")" \
|
||||||
-map 0:a:0 -ac 1 -ar 16000 -c:a libmp3lame -b:a 64k "$tmp/audio.mp3"
|
-map 0:a:0 -ac 1 -ar 16000 -c:a pcm_s16le "$tmp/audio.wav"
|
||||||
|
python3 "$script_dir/chunks.py" plan "$tmp/audio.wav" "$chunk_minutes" >"$tmp/chunks.json"
|
||||||
|
count=$(jq 'length' "$tmp/chunks.json")
|
||||||
|
timestamps=$srt
|
||||||
|
if (( count > 1 )); then
|
||||||
|
timestamps=true
|
||||||
|
echo "Splitting audio into $count overlapping chunks (maximum $chunk_minutes minutes each)." >&2
|
||||||
|
fi
|
||||||
|
|
||||||
# Keep large audio payloads out of shell arguments (which have a size limit).
|
for ((i = 0; i < count; i++)); do
|
||||||
base64 <"$tmp/audio.mp3" | tr -d '\n\r' >"$tmp/audio.b64"
|
read -r start duration < <(jq -r --argjson i "$i" \
|
||||||
jq -n --rawfile data "$tmp/audio.b64" --argjson srt "$srt" \
|
'.[$i] | "\(.start) \(.end - .start)"' "$tmp/chunks.json")
|
||||||
'{model: "microsoft/mai-transcribe-2", input_audio: {data: $data, format: "mp3"}}
|
if (( count > 1 )); then
|
||||||
+ (if $srt then {response_format: "verbose_json", timestamp_granularities: ["segment", "word"]}
|
echo "Transcribing chunk $((i + 1))/$count (start ${start}s, duration ${duration}s)..." >&2
|
||||||
else {} end)' \
|
fi
|
||||||
>"$tmp/request.json"
|
ffmpeg -nostdin -hide_banner -loglevel error -y -ss "$start" -i "$tmp/audio.wav" \
|
||||||
|
-t "$duration" -map 0:a:0 -ac 1 -ar 16000 -c:a libmp3lame -b:a 64k "$tmp/audio.mp3"
|
||||||
|
|
||||||
curl --silent --show-error --fail-with-body \
|
# Keep large audio payloads out of shell arguments (which have a size limit).
|
||||||
https://openrouter.ai/api/v1/audio/transcriptions \
|
base64 <"$tmp/audio.mp3" | tr -d '\n\r' >"$tmp/audio.b64"
|
||||||
-H "Authorization: Bearer $OPENROUTER_API_KEY" \
|
jq -n --rawfile data "$tmp/audio.b64" --argjson timestamps "$timestamps" \
|
||||||
-H 'Content-Type: application/json' \
|
'{model: "microsoft/mai-transcribe-2", input_audio: {data: $data, format: "mp3"}}
|
||||||
--data-binary @"$tmp/request.json" -o "$tmp/response.json" || {
|
+ (if $timestamps then {response_format: "verbose_json", timestamp_granularities: ["segment", "word"]}
|
||||||
cat "$tmp/response.json" >&2
|
else {} end)' \
|
||||||
exit 1
|
>"$tmp/request.json"
|
||||||
}
|
|
||||||
if "$srt"; then
|
curl --silent --show-error --fail-with-body \
|
||||||
python3 "$(dirname -- "$(realpath -- "$0")")/subtitles.py" "$tmp/response.json"
|
https://openrouter.ai/api/v1/audio/transcriptions \
|
||||||
|
-H "Authorization: Bearer $OPENROUTER_API_KEY" \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
--data-binary @"$tmp/request.json" -o "$tmp/response-$i.json" || {
|
||||||
|
echo "Transcription failed for chunk $((i + 1))/$count." >&2
|
||||||
|
if [[ -f $tmp/response-$i.json ]]; then
|
||||||
|
cat "$tmp/response-$i.json" >&2
|
||||||
|
fi
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
jq -e 'if .error then error("Transcription failed: " + (.error | tostring))
|
||||||
|
elif (.text | type) == "string" then true
|
||||||
|
else error("Transcription failed: missing text in response") end' \
|
||||||
|
"$tmp/response-$i.json" >/dev/null
|
||||||
|
done
|
||||||
|
|
||||||
|
# Only emit a transcript after every chunk succeeds, including SRT conversion.
|
||||||
|
if (( count > 1 )); then
|
||||||
|
format=()
|
||||||
|
if "$srt"; then format+=(--srt); fi
|
||||||
|
python3 "$script_dir/chunks.py" merge "$tmp/chunks.json" "${format[@]}"
|
||||||
|
elif "$srt"; then
|
||||||
|
python3 "$script_dir/subtitles.py" "$tmp/response-0.json"
|
||||||
else
|
else
|
||||||
jq -r 'if (.text | type) == "string" then .text
|
jq -r .text "$tmp/response-0.json"
|
||||||
else error("Transcription failed: " + (.error.message // tojson)) end' \
|
|
||||||
"$tmp/response.json"
|
|
||||||
fi
|
fi
|
||||||
Reference in new issue
Block a user