xscribe 0.1.0__tar.gz
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.
- xscribe-0.1.0/.gitignore +6 -0
- xscribe-0.1.0/LICENSE +21 -0
- xscribe-0.1.0/PKG-INFO +131 -0
- xscribe-0.1.0/README.md +113 -0
- xscribe-0.1.0/__init__.py +0 -0
- xscribe-0.1.0/pyproject.toml +32 -0
- xscribe-0.1.0/xscribe.py +198 -0
xscribe-0.1.0/.gitignore
ADDED
xscribe-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 edbutlerx
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
xscribe-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: xscribe
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Transcribe video and audio to markdown with timestamps. Supports local files and streams.
|
|
5
|
+
Project-URL: Homepage, https://github.com/edbutlerx/xscribe
|
|
6
|
+
Project-URL: Issues, https://github.com/edbutlerx/xscribe/issues
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Keywords: audio,markdown,transcription,video,whisper
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Environment :: Console
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Topic :: Multimedia :: Sound/Audio :: Speech
|
|
15
|
+
Requires-Python: >=3.10
|
|
16
|
+
Requires-Dist: faster-whisper
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
|
|
19
|
+
# xscribe
|
|
20
|
+
|
|
21
|
+
**Download and transcribe any online video in minutes.**
|
|
22
|
+
|
|
23
|
+
Turn any video or audio file into a clean, timestamped markdown transcript. Just point xscribe at a file or stream URL and get a readable transcript — no cloud services, no subscriptions, everything runs locally on your machine.
|
|
24
|
+
|
|
25
|
+
Powered by [faster-whisper](https://github.com/SYSTRAN/faster-whisper).
|
|
26
|
+
|
|
27
|
+
## Install
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
pip install xscribe
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
You also need **ffmpeg** installed on your system:
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
# macOS
|
|
37
|
+
brew install ffmpeg
|
|
38
|
+
|
|
39
|
+
# Ubuntu/Debian
|
|
40
|
+
sudo apt install ffmpeg
|
|
41
|
+
|
|
42
|
+
# Windows
|
|
43
|
+
winget install ffmpeg
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Quick start
|
|
47
|
+
|
|
48
|
+
**Transcribe a video file on your computer:**
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
xscribe interview.mp4
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
This creates `interview.md` in your current folder with the full transcript and timestamps.
|
|
55
|
+
|
|
56
|
+
**Transcribe an online video stream:**
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
xscribe "https://stream.example.com/video/playlist.m3u8"
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
xscribe will download the video first, then transcribe it. You'll need [yt-dlp](https://github.com/yt-dlp/yt-dlp) installed for this (`brew install yt-dlp` or `pip install yt-dlp`).
|
|
63
|
+
|
|
64
|
+
## Usage examples
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
# Transcribe a podcast episode you downloaded
|
|
68
|
+
xscribe episode-42.mp3
|
|
69
|
+
|
|
70
|
+
# Transcribe a lecture recording
|
|
71
|
+
xscribe lecture.mov
|
|
72
|
+
|
|
73
|
+
# Transcribe a YouTube stream you grabbed the URL for
|
|
74
|
+
xscribe "https://manifest.googlevideo.com/.../playlist.m3u8"
|
|
75
|
+
|
|
76
|
+
# Use a more accurate model (slower but better for tricky audio)
|
|
77
|
+
xscribe meeting.mp4 -m large-v3
|
|
78
|
+
|
|
79
|
+
# Save the transcript to a specific file
|
|
80
|
+
xscribe keynote.mp4 -o keynote-notes.md
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
**Supported file types:** mp4, mp3, wav, mov, mkv, webm, m4a, flac, ogg, and anything else ffmpeg can read.
|
|
84
|
+
|
|
85
|
+
## How to get an .m3u8 URL from any website
|
|
86
|
+
|
|
87
|
+
Most streaming videos use .m3u8 playlist URLs behind the scenes. Here's how to find them:
|
|
88
|
+
|
|
89
|
+
1. Open the website with the video in Chrome or any browser
|
|
90
|
+
2. Right-click anywhere on the page and select **Inspect** (or press `F12`)
|
|
91
|
+
3. Click the **Network** tab in the developer tools panel
|
|
92
|
+
4. Play the video on the page
|
|
93
|
+
5. In the Network tab's filter/search bar, type `.m3u8`
|
|
94
|
+
6. You'll see one or more requests appear — right-click the URL and select **Copy URL**
|
|
95
|
+
7. Paste it into xscribe: `xscribe "https://...your-copied-url.m3u8"`
|
|
96
|
+
|
|
97
|
+
## Model sizes
|
|
98
|
+
|
|
99
|
+
xscribe uses OpenAI's Whisper speech recognition. You can choose different model sizes depending on whether you want speed or accuracy:
|
|
100
|
+
|
|
101
|
+
| Model | Flag | Best for |
|
|
102
|
+
|-------|------|----------|
|
|
103
|
+
| Tiny | `-m tiny` | Quick and dirty, when you just need the gist |
|
|
104
|
+
| Base | *(default)* | General use, good balance of speed and quality |
|
|
105
|
+
| Small | `-m small` | Better accuracy, still reasonably fast |
|
|
106
|
+
| Medium | `-m medium` | High accuracy for important transcripts |
|
|
107
|
+
| Large | `-m large-v3` | Best possible accuracy, but slowest |
|
|
108
|
+
|
|
109
|
+
The model downloads automatically the first time you use it and gets cached for future runs.
|
|
110
|
+
|
|
111
|
+
## Output format
|
|
112
|
+
|
|
113
|
+
xscribe saves transcripts as markdown files with timestamps:
|
|
114
|
+
|
|
115
|
+
```markdown
|
|
116
|
+
# Transcription
|
|
117
|
+
|
|
118
|
+
**Source:** `interview.mp4`
|
|
119
|
+
|
|
120
|
+
---
|
|
121
|
+
|
|
122
|
+
**[00:03]** Hello and welcome to the show.
|
|
123
|
+
|
|
124
|
+
**[00:07]** Today we're joined by a special guest...
|
|
125
|
+
|
|
126
|
+
**[01:24]** Let's dive into the first topic.
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
## License
|
|
130
|
+
|
|
131
|
+
MIT
|
xscribe-0.1.0/README.md
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# xscribe
|
|
2
|
+
|
|
3
|
+
**Download and transcribe any online video in minutes.**
|
|
4
|
+
|
|
5
|
+
Turn any video or audio file into a clean, timestamped markdown transcript. Just point xscribe at a file or stream URL and get a readable transcript — no cloud services, no subscriptions, everything runs locally on your machine.
|
|
6
|
+
|
|
7
|
+
Powered by [faster-whisper](https://github.com/SYSTRAN/faster-whisper).
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
pip install xscribe
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
You also need **ffmpeg** installed on your system:
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
# macOS
|
|
19
|
+
brew install ffmpeg
|
|
20
|
+
|
|
21
|
+
# Ubuntu/Debian
|
|
22
|
+
sudo apt install ffmpeg
|
|
23
|
+
|
|
24
|
+
# Windows
|
|
25
|
+
winget install ffmpeg
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Quick start
|
|
29
|
+
|
|
30
|
+
**Transcribe a video file on your computer:**
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
xscribe interview.mp4
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
This creates `interview.md` in your current folder with the full transcript and timestamps.
|
|
37
|
+
|
|
38
|
+
**Transcribe an online video stream:**
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
xscribe "https://stream.example.com/video/playlist.m3u8"
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
xscribe will download the video first, then transcribe it. You'll need [yt-dlp](https://github.com/yt-dlp/yt-dlp) installed for this (`brew install yt-dlp` or `pip install yt-dlp`).
|
|
45
|
+
|
|
46
|
+
## Usage examples
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
# Transcribe a podcast episode you downloaded
|
|
50
|
+
xscribe episode-42.mp3
|
|
51
|
+
|
|
52
|
+
# Transcribe a lecture recording
|
|
53
|
+
xscribe lecture.mov
|
|
54
|
+
|
|
55
|
+
# Transcribe a YouTube stream you grabbed the URL for
|
|
56
|
+
xscribe "https://manifest.googlevideo.com/.../playlist.m3u8"
|
|
57
|
+
|
|
58
|
+
# Use a more accurate model (slower but better for tricky audio)
|
|
59
|
+
xscribe meeting.mp4 -m large-v3
|
|
60
|
+
|
|
61
|
+
# Save the transcript to a specific file
|
|
62
|
+
xscribe keynote.mp4 -o keynote-notes.md
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
**Supported file types:** mp4, mp3, wav, mov, mkv, webm, m4a, flac, ogg, and anything else ffmpeg can read.
|
|
66
|
+
|
|
67
|
+
## How to get an .m3u8 URL from any website
|
|
68
|
+
|
|
69
|
+
Most streaming videos use .m3u8 playlist URLs behind the scenes. Here's how to find them:
|
|
70
|
+
|
|
71
|
+
1. Open the website with the video in Chrome or any browser
|
|
72
|
+
2. Right-click anywhere on the page and select **Inspect** (or press `F12`)
|
|
73
|
+
3. Click the **Network** tab in the developer tools panel
|
|
74
|
+
4. Play the video on the page
|
|
75
|
+
5. In the Network tab's filter/search bar, type `.m3u8`
|
|
76
|
+
6. You'll see one or more requests appear — right-click the URL and select **Copy URL**
|
|
77
|
+
7. Paste it into xscribe: `xscribe "https://...your-copied-url.m3u8"`
|
|
78
|
+
|
|
79
|
+
## Model sizes
|
|
80
|
+
|
|
81
|
+
xscribe uses OpenAI's Whisper speech recognition. You can choose different model sizes depending on whether you want speed or accuracy:
|
|
82
|
+
|
|
83
|
+
| Model | Flag | Best for |
|
|
84
|
+
|-------|------|----------|
|
|
85
|
+
| Tiny | `-m tiny` | Quick and dirty, when you just need the gist |
|
|
86
|
+
| Base | *(default)* | General use, good balance of speed and quality |
|
|
87
|
+
| Small | `-m small` | Better accuracy, still reasonably fast |
|
|
88
|
+
| Medium | `-m medium` | High accuracy for important transcripts |
|
|
89
|
+
| Large | `-m large-v3` | Best possible accuracy, but slowest |
|
|
90
|
+
|
|
91
|
+
The model downloads automatically the first time you use it and gets cached for future runs.
|
|
92
|
+
|
|
93
|
+
## Output format
|
|
94
|
+
|
|
95
|
+
xscribe saves transcripts as markdown files with timestamps:
|
|
96
|
+
|
|
97
|
+
```markdown
|
|
98
|
+
# Transcription
|
|
99
|
+
|
|
100
|
+
**Source:** `interview.mp4`
|
|
101
|
+
|
|
102
|
+
---
|
|
103
|
+
|
|
104
|
+
**[00:03]** Hello and welcome to the show.
|
|
105
|
+
|
|
106
|
+
**[00:07]** Today we're joined by a special guest...
|
|
107
|
+
|
|
108
|
+
**[01:24]** Let's dive into the first topic.
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
## License
|
|
112
|
+
|
|
113
|
+
MIT
|
|
File without changes
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "xscribe"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Transcribe video and audio to markdown with timestamps. Supports local files and streams."
|
|
5
|
+
requires-python = ">=3.10"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
readme = "README.md"
|
|
8
|
+
keywords = ["transcription", "whisper", "video", "audio", "markdown"]
|
|
9
|
+
classifiers = [
|
|
10
|
+
"Development Status :: 4 - Beta",
|
|
11
|
+
"Environment :: Console",
|
|
12
|
+
"Intended Audience :: Developers",
|
|
13
|
+
"License :: OSI Approved :: MIT License",
|
|
14
|
+
"Topic :: Multimedia :: Sound/Audio :: Speech",
|
|
15
|
+
]
|
|
16
|
+
dependencies = [
|
|
17
|
+
"faster-whisper",
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
[project.scripts]
|
|
21
|
+
xscribe = "xscribe:main"
|
|
22
|
+
|
|
23
|
+
[project.urls]
|
|
24
|
+
Homepage = "https://github.com/edbutlerx/xscribe"
|
|
25
|
+
Issues = "https://github.com/edbutlerx/xscribe/issues"
|
|
26
|
+
|
|
27
|
+
[build-system]
|
|
28
|
+
requires = ["hatchling"]
|
|
29
|
+
build-backend = "hatchling.build"
|
|
30
|
+
|
|
31
|
+
[tool.hatch.build.targets.wheel]
|
|
32
|
+
packages = ["."]
|
xscribe-0.1.0/xscribe.py
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Video transcription CLI. Transcribes video/audio files to markdown with timestamps."""
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import os
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
import tempfile
|
|
9
|
+
import threading
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def is_stream_url(path: str) -> bool:
|
|
14
|
+
return path.startswith("http://") or path.startswith("https://") or path.endswith(".m3u8")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def download_stream(url: str, output_dir: str) -> str:
|
|
18
|
+
"""Download a stream URL using yt-dlp and return the output file path."""
|
|
19
|
+
output_path = os.path.join(output_dir, "downloaded_video.%(ext)s")
|
|
20
|
+
cmd = [
|
|
21
|
+
"yt-dlp",
|
|
22
|
+
"-o", output_path,
|
|
23
|
+
"--no-playlist",
|
|
24
|
+
url,
|
|
25
|
+
]
|
|
26
|
+
spinner = ProgressSpinner("Downloading stream...")
|
|
27
|
+
spinner.start()
|
|
28
|
+
result = subprocess.run(cmd, capture_output=True, text=True)
|
|
29
|
+
if result.returncode != 0:
|
|
30
|
+
spinner.stop("✗ Download failed")
|
|
31
|
+
print(f"yt-dlp error: {result.stderr}", file=sys.stderr)
|
|
32
|
+
sys.exit(1)
|
|
33
|
+
spinner.stop("✓ Download complete")
|
|
34
|
+
|
|
35
|
+
# Find the downloaded file
|
|
36
|
+
for f in os.listdir(output_dir):
|
|
37
|
+
if f.startswith("downloaded_video"):
|
|
38
|
+
return os.path.join(output_dir, f)
|
|
39
|
+
|
|
40
|
+
print("Error: could not find downloaded file", file=sys.stderr)
|
|
41
|
+
sys.exit(1)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def get_audio_duration(file_path: str) -> float | None:
|
|
48
|
+
"""Get duration of a media file in seconds using ffprobe."""
|
|
49
|
+
try:
|
|
50
|
+
result = subprocess.run(
|
|
51
|
+
["ffprobe", "-v", "quiet", "-show_entries", "format=duration",
|
|
52
|
+
"-of", "default=noprint_wrappers=1:nokey=1", file_path],
|
|
53
|
+
capture_output=True, text=True,
|
|
54
|
+
)
|
|
55
|
+
return float(result.stdout.strip())
|
|
56
|
+
except (ValueError, FileNotFoundError):
|
|
57
|
+
return None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class ProgressSpinner:
|
|
61
|
+
"""Spinner with percentage progress on a single line."""
|
|
62
|
+
|
|
63
|
+
def __init__(self, label: str, total: float | None = None):
|
|
64
|
+
self.label = label
|
|
65
|
+
self.total = total
|
|
66
|
+
self.current = 0.0
|
|
67
|
+
self._stop = threading.Event()
|
|
68
|
+
self._frame = 0
|
|
69
|
+
self._thread = threading.Thread(target=self._spin, daemon=True)
|
|
70
|
+
|
|
71
|
+
def start(self):
|
|
72
|
+
self._thread.start()
|
|
73
|
+
|
|
74
|
+
def update(self, value: float):
|
|
75
|
+
self.current = value
|
|
76
|
+
|
|
77
|
+
def _spin(self):
|
|
78
|
+
while not self._stop.is_set():
|
|
79
|
+
frame = SPINNER_FRAMES[self._frame % len(SPINNER_FRAMES)]
|
|
80
|
+
if self.total and self.total > 0:
|
|
81
|
+
pct = min(self.current / self.total * 100, 100)
|
|
82
|
+
sys.stdout.write(f"\r{frame} {self.label} {pct:.0f}%")
|
|
83
|
+
else:
|
|
84
|
+
sys.stdout.write(f"\r{frame} {self.label}")
|
|
85
|
+
sys.stdout.flush()
|
|
86
|
+
self._frame += 1
|
|
87
|
+
self._stop.wait(0.1)
|
|
88
|
+
|
|
89
|
+
def stop(self, final_message: str = ""):
|
|
90
|
+
self._stop.set()
|
|
91
|
+
self._thread.join()
|
|
92
|
+
sys.stdout.write(f"\r\033[K{final_message}\n")
|
|
93
|
+
sys.stdout.flush()
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def format_timestamp(seconds: float) -> str:
|
|
97
|
+
"""Format seconds into HH:MM:SS."""
|
|
98
|
+
h = int(seconds // 3600)
|
|
99
|
+
m = int((seconds % 3600) // 60)
|
|
100
|
+
s = int(seconds % 60)
|
|
101
|
+
if h > 0:
|
|
102
|
+
return f"{h:02d}:{m:02d}:{s:02d}"
|
|
103
|
+
return f"{m:02d}:{s:02d}"
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def transcribe(file_path: str, model_size: str) -> list[dict]:
|
|
107
|
+
"""Transcribe a file using faster-whisper. Returns list of segments."""
|
|
108
|
+
try:
|
|
109
|
+
from faster_whisper import WhisperModel
|
|
110
|
+
except ImportError:
|
|
111
|
+
print("Error: faster-whisper is not installed.", file=sys.stderr)
|
|
112
|
+
print("Install it with: pip install faster-whisper", file=sys.stderr)
|
|
113
|
+
sys.exit(1)
|
|
114
|
+
|
|
115
|
+
duration = get_audio_duration(file_path)
|
|
116
|
+
|
|
117
|
+
spinner = ProgressSpinner("Loading model...", total=None)
|
|
118
|
+
spinner.start()
|
|
119
|
+
model = WhisperModel(model_size, device="auto", compute_type="auto")
|
|
120
|
+
spinner.stop(f"✓ Model loaded: {model_size}")
|
|
121
|
+
|
|
122
|
+
spinner = ProgressSpinner("Transcribing...", total=duration)
|
|
123
|
+
spinner.start()
|
|
124
|
+
segments_gen, info = model.transcribe(file_path, beam_size=5)
|
|
125
|
+
|
|
126
|
+
segments = []
|
|
127
|
+
for segment in segments_gen:
|
|
128
|
+
segments.append({
|
|
129
|
+
"start": segment.start,
|
|
130
|
+
"end": segment.end,
|
|
131
|
+
"text": segment.text.strip(),
|
|
132
|
+
})
|
|
133
|
+
spinner.update(segment.end)
|
|
134
|
+
|
|
135
|
+
spinner.stop(f"✓ Transcription complete ({info.language})")
|
|
136
|
+
|
|
137
|
+
return segments
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def write_markdown(segments: list[dict], output_path: str, source: str):
|
|
141
|
+
"""Write transcription segments to a markdown file."""
|
|
142
|
+
with open(output_path, "w") as f:
|
|
143
|
+
f.write(f"# Transcription\n\n")
|
|
144
|
+
f.write(f"**Source:** `{source}`\n\n")
|
|
145
|
+
f.write("---\n\n")
|
|
146
|
+
|
|
147
|
+
for seg in segments:
|
|
148
|
+
ts = format_timestamp(seg["start"])
|
|
149
|
+
f.write(f"**[{ts}]** {seg['text']}\n\n")
|
|
150
|
+
|
|
151
|
+
print(f"✓ Saved to: {output_path}")
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def main():
|
|
155
|
+
parser = argparse.ArgumentParser(description="Transcribe video/audio to markdown with timestamps.")
|
|
156
|
+
parser.add_argument("input", help="File path or stream URL (m3u8, etc.) to transcribe")
|
|
157
|
+
parser.add_argument("-o", "--output", help="Output markdown file path (default: <input_name>.md)")
|
|
158
|
+
parser.add_argument("-m", "--model", default="base", choices=["tiny", "base", "small", "medium", "large-v3"],
|
|
159
|
+
help="Whisper model size (default: base)")
|
|
160
|
+
args = parser.parse_args()
|
|
161
|
+
|
|
162
|
+
source = args.input
|
|
163
|
+
temp_dir = None
|
|
164
|
+
|
|
165
|
+
try:
|
|
166
|
+
if is_stream_url(source):
|
|
167
|
+
temp_dir = tempfile.mkdtemp(prefix="xscribe_")
|
|
168
|
+
file_path = download_stream(source, temp_dir)
|
|
169
|
+
else:
|
|
170
|
+
file_path = os.path.abspath(source)
|
|
171
|
+
if not os.path.isfile(file_path):
|
|
172
|
+
print(f"Error: file not found: {file_path}", file=sys.stderr)
|
|
173
|
+
sys.exit(1)
|
|
174
|
+
|
|
175
|
+
# Determine output path
|
|
176
|
+
if args.output:
|
|
177
|
+
output_path = os.path.abspath(args.output)
|
|
178
|
+
else:
|
|
179
|
+
base_name = Path(source).stem if not is_stream_url(source) else "transcription"
|
|
180
|
+
output_path = os.path.join(os.getcwd(), f"{base_name}.md")
|
|
181
|
+
|
|
182
|
+
segments = transcribe(file_path, args.model)
|
|
183
|
+
|
|
184
|
+
if not segments:
|
|
185
|
+
print("No speech detected.", file=sys.stderr)
|
|
186
|
+
sys.exit(1)
|
|
187
|
+
|
|
188
|
+
write_markdown(segments, output_path, source)
|
|
189
|
+
|
|
190
|
+
finally:
|
|
191
|
+
# Clean up temp files
|
|
192
|
+
if temp_dir and os.path.exists(temp_dir):
|
|
193
|
+
import shutil
|
|
194
|
+
shutil.rmtree(temp_dir)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
if __name__ == "__main__":
|
|
198
|
+
main()
|