-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathltc_decoder.py
More file actions
365 lines (305 loc) · 13.4 KB
/
Copy pathltc_decoder.py
File metadata and controls
365 lines (305 loc) · 13.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
from __future__ import annotations
import ctypes
import ctypes.util
import mmap
import os
import struct
import threading
import time
import queue
import logging
import traceback
import numpy as np
logger = logging.getLogger(__name__)
import platform
import sys
_IS_WIN = platform.system() == "Windows"
def _base_dir() -> str:
"""App base dir — PyInstaller bundle or script directory."""
return getattr(sys, "_MEIPASS", os.path.dirname(os.path.abspath(__file__)))
if _IS_WIN:
LTC_PATHS = [
os.path.join(_base_dir(), "libltc.dll"),
os.path.join(_base_dir(), "libs", "win64", "libltc.dll"),
]
else:
LTC_PATHS = [
"/opt/homebrew/lib/libltc.dylib", # Apple Silicon
"/usr/local/lib/libltc.dylib", # Intel
]
SAMPLE_RATE = 48000
CHUNK = 1024
SIGNAL_LOSS_TIMEOUT = 2.0
# Standard LTC frame rates. fps values outside this set are rejected.
_STD_FPS = (24.0, 25.0, 29.97, 30.0)
def _nearest_fps(raw: float) -> float:
"""Round raw fps to the nearest standard LTC rate. Returns 25.0 if ambiguous."""
if raw <= 0:
return 25.0
best = min(_STD_FPS, key=lambda f: abs(f - raw))
return best if abs(best - raw) < 3.0 else 25.0
def _decode_ltc_bytes(data: bytes) -> tuple:
"""
Decode SMPTE timecode from raw LTC frame bytes (little-endian bitfield layout).
Returns (hours, mins, secs, frame_num, drop_frame).
"""
frame_num = (data[0] & 0x0F) + 10 * (data[1] & 0x03)
drop = bool(data[1] & 0x08)
secs = (data[2] & 0x0F) + 10 * (data[3] & 0x07)
mins = (data[4] & 0x0F) + 10 * (data[5] & 0x07)
hours = (data[6] & 0x0F) + 10 * (data[7] & 0x03)
return hours, mins, secs, frame_num, drop
def find_libltc():
for p in LTC_PATHS:
if os.path.exists(p):
return p
return ctypes.util.find_library("ltc")
# ── ctypes structs ────────────────────────────────────────────────────────────
class LTCFrame(ctypes.Structure):
_fields_ = [("data", ctypes.c_uint8 * 10)]
class LTCFrameExt(ctypes.Structure):
_fields_ = [
("ltc", LTCFrame),
("off_start", ctypes.c_int64),
("off_end", ctypes.c_int64),
("reverse", ctypes.c_int),
("fps", ctypes.c_float),
("startof", ctypes.c_int64),
("endof", ctypes.c_int64),
]
class LTCLibError(Exception):
pass
def load_libltc():
lib_path = find_libltc()
if not lib_path:
if _IS_WIN:
raise LTCLibError(
"libltc.dll not found.\n\nPlace libltc.dll next to the .exe.")
else:
raise LTCLibError(
"libltc not found.\n\nInstall with:\n brew install libltc"
)
logger.info("Loading libltc from: %s", lib_path)
try:
ltc = ctypes.CDLL(lib_path)
except OSError as e:
raise LTCLibError(f"Cannot load libltc from {lib_path}:\n{e}")
sz_ext = ctypes.sizeof(LTCFrameExt)
logger.debug(
"ctypes struct sizes — LTCFrame: %d LTCFrameExt: %d",
ctypes.sizeof(LTCFrame), sz_ext,
)
# Expected: LTCFrame=10, LTCFrameExt=56
if sz_ext not in (56, 40):
logger.warning("Unexpected LTCFrameExt size %d — struct may be misaligned", sz_ext)
# Use c_void_p for all pointer args to avoid POINTER(T) type-object bugs
# on Python 3.9 ARM64 macOS.
ltc.ltc_decoder_create.restype = ctypes.c_void_p
ltc.ltc_decoder_create.argtypes = [ctypes.c_int, ctypes.c_int]
ltc.ltc_decoder_write_s16.restype = None
ltc.ltc_decoder_write_s16.argtypes = [
ctypes.c_void_p, # decoder handle
ctypes.c_void_p, # int16 buffer (raw address)
ctypes.c_size_t, # nsamples
ctypes.c_int64, # posinfo
]
ltc.ltc_decoder_read.restype = ctypes.c_int
ltc.ltc_decoder_read.argtypes = [
ctypes.c_void_p, # decoder handle
ctypes.c_void_p, # LTCFrameExt* (raw address)
]
ltc.ltc_decoder_free.restype = None
ltc.ltc_decoder_free.argtypes = [ctypes.c_void_p]
return ltc
# ── Decoder thread ────────────────────────────────────────────────────────────
class LTCDecoder(threading.Thread):
"""
Background thread: reads audio → feeds libltc → emits timecodes via queue.
Queue messages:
("timecode", hours, mins, secs, frames, fps)
("signal_lost",)
("level", rms_db)
("error", message)
"""
def __init__(self, device_index=None, channel_index: int = 0):
super().__init__(daemon=True)
self.device_index = device_index
self.channel_index = channel_index
self.out_queue: queue.Queue = queue.Queue()
self._stop_event = threading.Event()
def stop(self):
self._stop_event.set()
def run(self):
logger.info(
"LTCDecoder starting — device_index=%s channel_index=%d",
self.device_index, self.channel_index,
)
try:
self._run_inner()
except Exception:
tb = traceback.format_exc()
logger.error("LTCDecoder unhandled exception:\n%s", tb)
self.out_queue.put(("error", f"LTC decoder crashed:\n{tb}"))
def _run_inner(self):
try:
ltc_lib = load_libltc()
except LTCLibError as e:
self.out_queue.put(("error", str(e)))
return
try:
import pyaudio
except ImportError:
self.out_queue.put(("error",
"pyaudio not installed.\n\nRun: pip install pyaudio"))
return
pa = pyaudio.PyAudio()
stream = None
decoder = None
num_channels = max(1, self.channel_index + 1)
# Validate the requested channel against the device before opening
# the stream. A raw PortAudio "Invalid number of channels" blows up
# the whole audio thread on some backends, so we reject early with
# a friendly message.
if self.device_index is not None:
try:
info = pa.get_device_info_by_index(self.device_index)
max_in = int(info.get("maxInputChannels", 0))
except Exception as e:
logger.warning("Could not query device info: %s", e)
max_in = None
if max_in is not None:
if max_in <= 0:
self.out_queue.put(("error",
"Selected device has no input channels."))
pa.terminate()
return
if self.channel_index >= max_in:
self.out_queue.put(("error",
f"Channel {self.channel_index + 1} is not available on this "
f"device (only {max_in} channel{'s' if max_in != 1 else ''} "
f"present).\n\nOpen Settings and pick a valid channel."))
pa.terminate()
return
try:
decoder = ltc_lib.ltc_decoder_create(SAMPLE_RATE, 1920)
if not decoder:
self.out_queue.put(("error", "Failed to create LTC decoder."))
return
logger.debug("ltc_decoder_create OK handle=0x%x", decoder)
open_kwargs: dict = dict(
format=pyaudio.paInt16,
channels=num_channels,
rate=SAMPLE_RATE,
input=True,
frames_per_buffer=CHUNK,
)
if self.device_index is not None:
open_kwargs["input_device_index"] = self.device_index
logger.info(
"Opening audio stream: device=%s channels=%d (using ch %d) rate=%d chunk=%d",
self.device_index, num_channels, self.channel_index, SAMPLE_RATE, CHUNK,
)
try:
stream = pa.open(**open_kwargs)
except Exception as e:
# PortAudio surfaces errors as OSError, ValueError, or its own
# pyaudio.PyAudioError depending on backend (WASAPI / MME /
# CoreAudio). Catch broadly so we never crash the audio thread.
logger.error("pa.open failed: %s", e)
self.out_queue.put(("error", f"Cannot open audio device:\n{e}"))
return
logger.info("Audio stream opened OK")
last_frame_t = time.monotonic()
signal_lost_sent = False
pos = 0 # sample position counter (plain int)
frames_decoded = 0
# Allocate an OVERSIZED buffer outside the Python heap for libltc
# to write LTCFrameExt into. This prevents heap corruption if
# libltc's actual struct is larger than our ctypes definition (56B).
# Using mmap gives us a page-aligned buffer that won't corrupt
# adjacent Python objects even if libltc writes beyond 56 bytes.
_FRAME_BUF_SIZE = 4096 # one full page — way more than any LTCFrameExt
frame_mmap = mmap.mmap(-1, _FRAME_BUF_SIZE)
frame_buf_addr = ctypes.addressof(ctypes.c_char.from_buffer(frame_mmap))
frame_ptr = ctypes.c_void_p(frame_buf_addr)
while not self._stop_event.is_set():
try:
raw = stream.read(CHUNK, exception_on_overflow=False)
except OSError as e:
logger.warning("stream.read error: %s", e)
continue
# Deinterleave: extract only the requested channel.
all_samples = np.frombuffer(raw, dtype=np.int16)
if num_channels > 1:
samples = all_samples[self.channel_index::num_channels].copy()
else:
samples = all_samples
rms = float(np.sqrt(np.mean(samples.astype(np.float32) ** 2)))
db = 20.0 * np.log10(rms / 32768.0) if rms > 0 else -120.0
self.out_queue.put(("level", db))
# Ensure contiguous C-order int16 buffer that stays alive
# during the ctypes call. np.ascontiguousarray guarantees this.
samples = np.ascontiguousarray(samples, dtype=np.int16)
buf_addr = samples.ctypes.data
n = len(samples)
logger.debug("write_s16: %d samples pos=%d rms=%.1f dB", n, pos, db)
ltc_lib.ltc_decoder_write_s16(decoder, buf_addr, n, pos)
pos += n
# Drain all decoded frames from the libltc queue.
while ltc_lib.ltc_decoder_read(decoder, frame_ptr):
# Read raw bytes from the mmap buffer (safe — isolated page).
# LTCFrameExt layout (ARM64 little-endian, 56 bytes):
# offset 0: LTCFrame.data [10 bytes]
# offset 10: padding [6 bytes]
# offset 16: off_start int64
# offset 24: off_end int64
# offset 32: reverse int32
# offset 36: fps float32 (padding to 40)
# offset 40: startof int64 (or absent in older libltc)
# offset 48: endof int64
frame_mmap.seek(0)
raw_frame = frame_mmap.read(56)
frame_data = raw_frame[0:10]
h, m, s, f, drop = _decode_ltc_bytes(frame_data)
off_start, off_end = struct.unpack_from("<qq", raw_frame, 16)
reverse_val = struct.unpack_from("<i", raw_frame, 32)[0]
fps_raw = struct.unpack_from("<f", raw_frame, 36)[0]
span = off_end - off_start
if span > 0:
fps = _nearest_fps(SAMPLE_RATE / span)
else:
fps = _nearest_fps(fps_raw)
frames_decoded += 1
logger.debug(
"LTC #%d %02d:%02d:%02d:%02d fps=%.2f drop=%s reverse=%d span=%d",
frames_decoded, h, m, s, f, fps, drop, reverse_val, span,
)
self.out_queue.put(("timecode", h, m, s, f, fps))
last_frame_t = time.monotonic()
signal_lost_sent = False
if (not signal_lost_sent and
time.monotonic() - last_frame_t > SIGNAL_LOSS_TIMEOUT):
logger.info(
"LTC signal lost after %.1fs silence",
time.monotonic() - last_frame_t,
)
self.out_queue.put(("signal_lost",))
signal_lost_sent = True
finally:
logger.info(
"LTCDecoder shutting down frames_decoded=%d",
frames_decoded if "frames_decoded" in dir() else 0,
)
if stream:
try:
stream.stop_stream()
stream.close()
except Exception:
pass
if decoder:
ltc_lib.ltc_decoder_free(decoder)
pa.terminate()
try:
frame_mmap.close()
except Exception:
pass