-
Notifications
You must be signed in to change notification settings - Fork 5
Expand file tree
/
Copy pathrecorder.py
More file actions
955 lines (825 loc) · 43.2 KB
/
Copy pathrecorder.py
File metadata and controls
955 lines (825 loc) · 43.2 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
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
#!/usr/bin/env python3
"""
Vinyl AirPlay: Audio Recorder
Detects track boundaries via silence gaps, captures full album sides
as FLAC files with track boundary timestamps.
"""
import contextlib
import io
import os
import queue
import subprocess
import tempfile
import threading
import time
import wave
from pathlib import Path
import numpy as np
# ── Config ────────────────────────────────────────────────────────────────────
SAMPLE_RATE = 44100
CHANNELS = 2
# Silence detection
# Adaptive silence detection
# Rather than a fixed RMS threshold (which varies by pressing), we measure the
# actual playing level and call something "silent" when it drops to a fraction of that.
SILENCE_RATIO = 0.40 # silence = RMS drops below this fraction of signal level
# 0.40 = must be about 8dB quieter than the music
# Vinyl groove noise is typically 10-20dB below music,
# so this reliably catches inter-track gaps on any pressing
SILENCE_RATIO_MIN = 0.006 # absolute floor: never treat above this as silence
SIGNAL_ADAPT_RATE = 0.002 # EMA rate for signal level tracker (slow: ~500 chunks)
SIGNAL_DECAY_RATE = 0.0004 # decay rate when below threshold: 5x slower than adapt
# prevents signal level from getting stuck high on dynamic albums
SILENCE_FORGIVE_SECS = 0.3 # ignore above-threshold blips shorter than this during a gap
# vinyl pops/crackles shouldn't reset the silence counter
SILENCE_MIN_SECS = 1.5 # silence must last this long to split track
# reduced from 2.0: some albums have short inter-track gaps
EARLY_SPLIT_RATIO = 0.75 # suppress silence splits until 75% of expected duration elapsed
# lets time-based fallback handle albums with long vinyl gaps
END_OF_SIDE_SECS = 20.0 # silence this long = end of side: auto-flush final track
# _split_track trims to silence_start+pad so no long silence
# is appended to the file
END_OF_SIDE_RMS = 0.004 # RMS must stay below this to count toward end-of-side
# Run-out grooves typically read 0.001-0.003 RMS.
# Quiet music passages sit around 0.004-0.006, so this
# avoids false end-of-side triggers on quiet records.
SILENCE_PAD_SECS = 0.5 # keep this much silence at end of track (natural fade)
MIN_TRACK_SECS = 15 # ignore tracks shorter than this (needle drop, interludes)
STARTUP_AUDIO_SECS = 2.0 # sustained audio required before silence detection begins
DURATION_SPLIT_TOLERANCE = 10.0 # seconds past expected track duration before forcing a split
# fallback for albums with seamless transitions (no silence gaps)
STREAM_STALL_SECS = 10.0 # if no audio chunks arrive for this long during recording,
# the audio stream has likely died (USB overflow, ALSA glitch)
TRIM_BLOCK = SAMPLE_RATE * CHANNELS * 2 # 1 second of PCM (used for trailing silence trim)
TRIM_THRESHOLD = 0.002 # RMS below this = silence (trailing trim)
FADE_TAIL = SAMPLE_RATE * CHANNELS * 2 # keep 1s after last audio block (natural fade)
# ── Recording Buffer ──────────────────────────────────────────────────────────
class RecordingBuffer:
"""
Receives raw PCM chunks from the audio callback.
Detects silence gaps and either:
- auto-splits into tracks (auto mode)
- records one continuous chunk until stop() called (manual mode)
Thread-safe: put() from audio thread, everything else from main thread.
"""
def __init__(self,
on_track_ready, # callback(pcm_bytes, duration_secs)
on_level_update, # callback(rms_float): for UI meter
on_audio_detected=None, # callback(): fired once when startup gate opens
on_end_of_side=None, # callback(): fired when end-of-side silence detected
auto_split: bool = True,
gate_threshold: float = SILENCE_RATIO_MIN):
self._lock = threading.Lock()
self._on_track_ready = on_track_ready
self._on_level_update = on_level_update
self._on_audio_detected = on_audio_detected
self._on_end_of_side = on_end_of_side
self._auto_split = auto_split
self._gate_threshold = gate_threshold
self._chunks: list[bytes] = []
self._total_bytes = 0
self._active = False
# Silence detection state
self._silence_secs = 0.0
self._last_rms = 0.0
self._block_secs = 1024 / SAMPLE_RATE # seconds per callback block (approx)
# Track where silence started so we can trim it from the end
self._silence_start_byte = 0
# Startup gate: don't act on silence until we've seen sustained audio first.
self._sustained_audio_secs = 0.0 # how long we've heard audio above threshold
self._audio_seen = False # True once startup gate is cleared
self._end_of_side_fired = False # prevent double-firing end-of-side flush
self._eos_silence_secs = 0.0 # separate counter for end-of-side (stricter threshold)
# Adaptive signal level: exponential moving average of RMS while music is playing.
# Silence threshold = _signal_level * SILENCE_RATIO.
# Adapts automatically to any pressing's loudness.
self._signal_level = 0.03 # initial estimate; refined once audio starts
self._silence_log_countdown = 0 # rate-limit diagnostic prints
# Forgiveness window: brief above-threshold blips don't reset silence counter
self._above_thresh_secs = 0.0 # how long audio has been above threshold
# Duration-based fallback for seamless albums (no silence between tracks)
self._expected_durations: list[float] = [] # per-track expected durations (seconds)
self._duration_track_idx = 0 # which expected track we're on
self._track_elapsed_secs = 0.0 # seconds since last split
# Stream stall detection: track when audio last arrived
self._last_put_time: float = 0.0 # monotonic timestamp of last put() call
# Number of remaining expected tracks. The caller (album record flow) sets the
# real value; initialized here so a split firing before that can't raise
# AttributeError inside the real-time audio callback.
self.remaining_tracks = 0
# Audio-callback offload: put() only enqueues; this worker thread runs all
# detection/splitting so nothing heavy ever runs on the real-time thread.
self._queue: queue.Queue = queue.Queue(maxsize=512)
self._dropped = 0
self._worker = threading.Thread(target=self._process_loop, daemon=True)
self._worker.start()
def start(self, auto_split: bool = True):
with self._lock:
self._chunks = []
self._total_bytes = 0
self._active = True
self._auto_split = auto_split
self._silence_secs = 0.0
self._silence_start_byte = 0
self._sustained_audio_secs = 0.0
self._audio_seen = False
self._signal_level = 0.03
self._silence_log_countdown = 0
self._end_of_side_fired = False
self._eos_silence_secs = 0.0
self._above_thresh_secs = 0.0
self._duration_track_idx = 0
self._track_elapsed_secs = 0.0
self._last_put_time = time.monotonic()
# Keep _expected_durations: set externally before start()
if self._worker is None or not self._worker.is_alive():
self._worker = threading.Thread(target=self._process_loop, daemon=True)
self._worker.start()
print(f"[recorder] Recording started (auto_split={auto_split})")
def stop(self) -> bytes | None:
"""Stop recording and return the accumulated PCM, or None if too short."""
with self._lock:
if not self._active:
return None
self._active = False
# Let the worker finish processing everything captured while recording so
# the flushed buffer is complete before we read it.
ev = threading.Event()
with contextlib.suppress(queue.Full):
self._queue.put(('__flush__', ev), timeout=2)
ev.wait(timeout=5)
with self._lock:
pcm = b"".join(self._chunks)
self._chunks = []
self._total_bytes = 0
duration = _pcm_duration(pcm)
if duration < MIN_TRACK_SECS:
print(f"[recorder] Track too short ({duration:.1f}s): discarding")
return None
print(f"[recorder] Recording stopped: {duration:.1f}s captured")
return pcm
def set_expected_durations(self, durations: list[float]):
"""Set expected track durations (from Discogs) for time-based fallback splitting.
Zero-duration entries are kept so the track index stays synchronized --
time-based splitting is simply skipped for those tracks while silence
detection still operates normally."""
self._expected_durations = [float(d) for d in durations]
known = [d for d in self._expected_durations if d > 0]
if self._expected_durations:
print(f"[recorder] Expected track durations set: "
f"{[f'{d:.0f}s' for d in self._expected_durations]}"
f" ({len(known)} known, {len(self._expected_durations) - len(known)} unknown)")
@property
def is_active(self) -> bool:
return self._active
@property
def stream_stalled(self) -> bool:
"""True if recording is active but no audio has arrived recently."""
if not self._active or self._last_put_time == 0.0:
return False
return (time.monotonic() - self._last_put_time) >= STREAM_STALL_SECS
@property
def elapsed_secs(self) -> float:
with self._lock:
# Bytes / (rate * channels * 2). Avoids allocating a multi-hundred-MB
# dummy buffer just to divide a length (matches AlbumRecorder.elapsed_secs).
return self._total_bytes / (SAMPLE_RATE * CHANNELS * 2)
def put(self, pcm_chunk: bytes, rms: float | None = None):
"""Audio-callback hot path: timestamp the block and hand it to the worker
thread. All detection/splitting/encoding happens off this thread, so the
callback can never stall the real-time audio stream.
"""
self._last_put_time = time.monotonic()
try:
self._queue.put_nowait((pcm_chunk, rms, self._active))
except queue.Full:
self._dropped += 1 # never block the audio thread
def _process_loop(self):
"""Worker thread: drain the queue and run the (previously in-callback)
detection + split logic. Exits after ~30s with no audio (stream stopped)."""
idle = 0
while True:
try:
item = self._queue.get(timeout=1.0)
except queue.Empty:
idle += 1
if idle >= 30:
return
continue
idle = 0
if item is None:
return
if item[0] == '__flush__':
item[1].set()
continue
pcm_chunk, rms, was_active = item
try:
self._process(pcm_chunk, rms, was_active)
except Exception as e:
print(f"[recorder] worker error: {type(e).__name__}: {e}")
def _process(self, pcm_chunk: bytes, rms: float | None, was_active: bool):
"""Runs on the worker thread (not the audio callback).
Silence detection and level monitoring always run; chunk accumulation is
gated on whether recording was active when the block was captured.
"""
if rms is None:
samples = np.frombuffer(pcm_chunk, dtype=np.int16).astype(np.float32) / 32768.0
rms = float(np.sqrt(np.mean(samples ** 2)))
with self._lock:
if was_active:
self._chunks.append(pcm_chunk)
self._total_bytes += len(pcm_chunk)
self._last_rms = rms
# Level update (outside lock to avoid blocking audio thread)
self._on_level_update(rms)
if not self._auto_split:
return
# Startup gate: accumulate sustained audio before enabling silence detection.
# Once we've heard STARTUP_AUDIO_SECS of continuous audio, the gate opens
# and normal split logic takes over.
chunk_secs = len(pcm_chunk) / (SAMPLE_RATE * CHANNELS * 2)
if not self._audio_seen:
if rms >= self._gate_threshold:
self._sustained_audio_secs += chunk_secs
self._silence_secs = 0.0 # reset gate silence counter
if self._sustained_audio_secs >= STARTUP_AUDIO_SECS:
self._audio_seen = True
# Seed signal level from the startup burst so threshold is
# calibrated before the first track even ends
self._signal_level = rms
thresh = max(self._gate_threshold, rms * SILENCE_RATIO)
print(f"[recorder] Audio detected: silence detection active"
f" signal={rms:.5f} silence_threshold={thresh:.5f}")
if self._on_audio_detected:
self._on_audio_detected()
else:
# Reset sustained counter if audio drops before gate opens
self._sustained_audio_secs = 0.0
# Track silence while gate is closed -- if we just split the
# final track and the needle is in the run-out groove, the gate
# never reopens. Detect end-of-side here so we don't record
# silence indefinitely.
self._silence_secs += chunk_secs
# End-of-side requires near-zero RMS (run-out groove), not just
# below the gate threshold. Quiet music between tracks on albums
# like Midnight Marauders hovers around 0.004-0.006, which is
# below the gate but above the run-out groove floor.
if rms < END_OF_SIDE_RMS:
self._eos_silence_secs += chunk_secs
else:
self._eos_silence_secs = 0.0
# Periodic log while gate is closed so we can diagnose issues
self._silence_log_countdown -= 1
if self._silence_log_countdown <= 0:
print(f"[recorder] Gate closed: RMS={rms:.5f} gate_thresh={self._gate_threshold}"
f" silence={self._silence_secs:.1f}s eos={self._eos_silence_secs:.1f}s")
self._silence_log_countdown = 20
# Mark where silence started so _split_track trims correctly
if self._silence_start_byte == 0:
with self._lock:
self._silence_start_byte = self._total_bytes - len(pcm_chunk)
if (not self._end_of_side_fired
and self._eos_silence_secs >= END_OF_SIDE_SECS
and self._total_bytes > 0):
self._end_of_side_fired = True
print(f"[recorder] End-of-side detected while waiting for audio"
f" ({self._eos_silence_secs:.1f}s near-silence, gate closed)")
# Don't call _split_track -- the last real track already
# split cleanly. Only silence scraps remain in the buffer.
# Just fire end-of-side so the album recorder can finalize.
if self._on_end_of_side:
self._on_end_of_side()
return # don't do split logic until gate is open
# Adaptive silence detection (gate is open)
# Compute dynamic threshold from current signal level estimate
silence_threshold = max(self._gate_threshold, self._signal_level * SILENCE_RATIO)
if rms < silence_threshold:
self._silence_secs += chunk_secs
self._above_thresh_secs = 0.0 # reset above-threshold counter
if self._silence_start_byte == 0:
with self._lock:
self._silence_start_byte = self._total_bytes - len(pcm_chunk)
# Slowly decay signal level even during silence: prevents threshold
# from getting stuck high on dynamic albums where quiet music sits
# below an inflated threshold from earlier loud passages.
self._signal_level -= SIGNAL_DECAY_RATE * self._signal_level
# End-of-side uses a stricter absolute threshold: run-out grooves
# read 0.001-0.003 RMS, well below quiet music passages (0.004-0.006).
# Track this separately so quiet music doesn't false-trigger end-of-side.
if rms < END_OF_SIDE_RMS:
self._eos_silence_secs += chunk_secs
else:
self._eos_silence_secs = 0.0 # quiet music, not actual silence
# Periodic diagnostic log so we can see gap RMS in journalctl
self._silence_log_countdown -= 1
if self._silence_log_countdown <= 0:
print(f"[recorder] Gap: RMS={rms:.5f} threshold={silence_threshold:.5f}"
f" signal={self._signal_level:.5f} silence={self._silence_secs:.1f}s"
f" eos={self._eos_silence_secs:.1f}s")
self._silence_log_countdown = 20 # log every ~20 chunks
# End-of-side detection: prolonged near-silence = run-out groove / needle lifted.
# Uses the stricter _eos_silence_secs counter (requires RMS < END_OF_SIDE_RMS)
# instead of _silence_secs (which just requires below the adaptive threshold).
if (not self._end_of_side_fired
and self._eos_silence_secs >= END_OF_SIDE_SECS):
self._end_of_side_fired = True
print(f"[recorder] End-of-side detected ({self._eos_silence_secs:.1f}s near-silence)"
f": flushing final track (trimmed to music end)")
self._split_track() # trims silence, hands off final track
self._audio_seen = False # re-arm startup gate for next side
if self._on_end_of_side:
self._on_end_of_side()
else:
# Update signal level EMA while music is playing
self._signal_level += SIGNAL_ADAPT_RATE * (rms - self._signal_level)
self._above_thresh_secs += chunk_secs
self._eos_silence_secs = 0.0 # audio above threshold, reset end-of-side counter
# Forgiveness: brief above-threshold blips (vinyl pops, crackles) during
# a gap shouldn't reset the silence counter. Require sustained audio
# to confirm the gap is actually over.
if self._above_thresh_secs >= SILENCE_FORGIVE_SECS:
# Genuine audio returned: finalize any pending silence
self._silence_log_countdown = 0 # reset so next gap logs immediately
self._end_of_side_fired = False # reset if audio returns
# Determine whether silence-based splitting is allowed.
# When we have expected durations and we're well short of the
# expected track length, suppress silence-based splits entirely
# and let the time-based fallback handle it instead. This
# prevents false splits from long inter-track gaps on vinyl.
allow_silence_split = True
min_silence = SILENCE_MIN_SECS
if (self._expected_durations
and self._duration_track_idx < len(self._expected_durations)):
expected = self._expected_durations[self._duration_track_idx]
if expected > 0 and self._track_elapsed_secs < expected * EARLY_SPLIT_RATIO:
allow_silence_split = False # too early: let time-based fallback handle it
if allow_silence_split and self._silence_secs >= min_silence:
# Sustained silence ended: split track
self._split_track()
self._silence_secs = 0.0
self._silence_start_byte = 0
# else: blip is too short, keep accumulating silence
# ── Duration-based fallback for seamless albums ──────────────────
# If we know the expected track durations (from Discogs) and silence
# detection hasn't triggered a split, force one after the expected
# duration + tolerance. This handles albums where tracks blend
# into each other with no silence gaps.
if (self._expected_durations
and self._audio_seen
and self._duration_track_idx < len(self._expected_durations)):
self._track_elapsed_secs += chunk_secs
expected = self._expected_durations[self._duration_track_idx]
if expected > 0 and self._track_elapsed_secs >= expected + DURATION_SPLIT_TOLERANCE:
print(f"[recorder] Time-based split: {self._track_elapsed_secs:.1f}s "
f"elapsed (expected ~{expected:.0f}s + {DURATION_SPLIT_TOLERANCE:.0f}s tolerance)")
# Cut at the current position (no silence to trim to)
with self._lock:
self._silence_start_byte = self._total_bytes
self._silence_secs = 0.0
self._split_track()
def _split_track(self):
"""Called when silence gap detected: extract the completed track."""
with self._lock:
pcm = b"".join(self._chunks)
# Trim to silence start + pad (keep natural fade)
pad_bytes = int(SILENCE_PAD_SECS * SAMPLE_RATE * CHANNELS * 2)
cut_at = self._silence_start_byte + pad_bytes
track_pcm = pcm[:cut_at]
# Keep audio after silence for next track
self._chunks = [pcm[cut_at:]]
self._total_bytes = len(pcm[cut_at:])
self._silence_secs = 0.0
self._silence_start_byte = 0
duration = _pcm_duration(track_pcm)
if duration < MIN_TRACK_SECS:
print(f"[recorder] Gap detected ({duration:.1f}s PCM): notifying track boundary")
# Still notify for recogniser reset even if not recording
self._on_track_ready(None, 0.0)
return
print(f"[recorder] Auto-split: track ready ({duration:.1f}s)")
if self.remaining_tracks > 0:
self.remaining_tracks -= 1
# Reset duration-based tracking after a real split
self._track_elapsed_secs = 0.0
if self._expected_durations and self._duration_track_idx < len(self._expected_durations):
self._duration_track_idx += 1
# Re-arm startup gate: require sustained audio before looking for
# the next silence gap. Prevents false splits from residual silence
# or quiet passages right after a legitimate split.
self._audio_seen = False
self._sustained_audio_secs = 0.0
self._on_track_ready(track_pcm, duration)
# ── PCM Helpers ───────────────────────────────────────────────────────────────
def _pcm_duration(pcm: bytes) -> float:
return len(pcm) / (SAMPLE_RATE * CHANNELS * 2)
def _pcm_to_wav(pcm: bytes) -> bytes:
buf = io.BytesIO()
with wave.open(buf, 'wb') as wf:
wf.setnchannels(CHANNELS)
wf.setsampwidth(2)
wf.setframerate(SAMPLE_RATE)
wf.writeframes(pcm)
return buf.getvalue()
# ── Needle Drop Detection ─────────────────────────────────────────────────────
def _find_music_start(pcm: bytes) -> int:
"""
Scan the first few seconds of PCM audio and return the byte offset
where the needle-drop transient ends (the quiet valley after it).
Pattern across all vinyl recordings:
1. Needle hits groove: sharp burst (70-500ms, up to 75% peak)
2. Burst decays into a quiet valley (vinyl surface noise only)
3. Actual music begins
Strategy: compute RMS in 100ms windows over the first 3 seconds,
find the initial burst (peak window), then find where it settles
to the quiet valley (the minimum RMS point after the burst decays).
Trim to that valley so playback starts from clean vinyl noise
just before the music.
Returns a frame-aligned byte offset to trim to, or 0 if no trim needed.
"""
BYTES_PER_FRAME = CHANNELS * 2
BYTES_PER_SEC = SAMPLE_RATE * BYTES_PER_FRAME
scan_limit = min(len(pcm), 3 * BYTES_PER_SEC)
if scan_limit < BYTES_PER_SEC:
return 0
WINDOW_MS = 100
WINDOW_BYTES = int(SAMPLE_RATE * WINDOW_MS / 1000) * BYTES_PER_FRAME
# Build RMS profile of first 3 seconds
rms_values = []
for pos in range(0, scan_limit - WINDOW_BYTES, WINDOW_BYTES):
block = np.frombuffer(pcm[pos:pos + WINDOW_BYTES], dtype=np.int16)
rms = float(np.sqrt(np.mean((block.astype(np.float32) / 32768.0) ** 2)))
rms_values.append(rms)
if len(rms_values) < 5:
return 0
# Find peak in the first 500ms (5 windows) -- this is the needle drop
burst_end = min(5, len(rms_values))
peak_rms = max(rms_values[:burst_end])
peak_idx = rms_values[:burst_end].index(peak_rms)
# If the peak is very low (< 0.5%), there's no real needle drop
if peak_rms < 0.005:
return 0
# Find the quiet valley: the minimum RMS point after the burst
# Search from after the peak through to 2.5s
search_start = peak_idx + 1
search_end = min(len(rms_values), 25) # up to 2.5s
if search_start >= search_end:
return 0
valley_slice = rms_values[search_start:search_end]
valley_idx_local = valley_slice.index(min(valley_slice))
valley_idx = search_start + valley_idx_local
# Only trim if the valley is significantly quieter than the peak
# (confirms it's a needle drop, not just music starting immediately)
if rms_values[valley_idx] > peak_rms * 0.3:
return 0 # no clear dip, probably not a needle drop
# Trim to the valley point
trim_byte = valley_idx * WINDOW_BYTES
trim_byte = trim_byte - (trim_byte % BYTES_PER_FRAME) # frame-align
return trim_byte
def trim_needle_drop_flac(flac_path: str) -> dict:
"""
Post-process an existing FLAC file: decode, detect and trim the
needle-drop transient, re-encode. Returns info about what was trimmed.
Used for the one-time cleanup of existing recordings.
Returns: {"trimmed_secs": float, "success": bool, "error": str|None}
"""
flac_path = Path(flac_path)
if not flac_path.exists():
return {"trimmed_secs": 0, "success": False, "error": "File not found"}
# Decode FLAC to raw PCM
cmd = [
"ffmpeg", "-hide_banner", "-loglevel", "error",
"-i", str(flac_path),
"-f", "s16le", "-acodec", "pcm_s16le",
"-ar", str(SAMPLE_RATE), "-ac", str(CHANNELS),
"pipe:1",
]
try:
result = subprocess.run(cmd, capture_output=True, timeout=300)
if result.returncode != 0:
return {"trimmed_secs": 0, "success": False,
"error": result.stderr.decode()[:200]}
pcm = result.stdout
except Exception as e:
return {"trimmed_secs": 0, "success": False, "error": str(e)}
trim_pos = _find_music_start(pcm)
if trim_pos == 0:
return {"trimmed_secs": 0, "success": True, "error": None}
trimmed_secs = trim_pos / (SAMPLE_RATE * CHANNELS * 2)
# Trim
pcm = pcm[trim_pos:]
# Apply 50ms fade-in
fade_samples = int(SAMPLE_RATE * 0.05)
fade_bytes = fade_samples * CHANNELS * 2
if len(pcm) > fade_bytes:
arr = np.frombuffer(pcm[:fade_bytes], dtype=np.int16).copy()
ramp = np.repeat(
np.linspace(0.0, 1.0, fade_samples, dtype=np.float32),
CHANNELS,
)
arr = (arr.astype(np.float32) * ramp).astype(np.int16)
pcm = arr.tobytes() + pcm[fade_bytes:]
# Read original FLAC metadata
probe_cmd = [
"ffprobe", "-hide_banner", "-loglevel", "error",
"-show_entries", "format_tags",
"-of", "json", str(flac_path),
]
metadata = {}
try:
probe = subprocess.run(probe_cmd, capture_output=True, timeout=10)
if probe.returncode == 0:
import json
info = json.loads(probe.stdout)
tags = info.get("format", {}).get("tags", {})
metadata = {
"title": tags.get("TITLE", tags.get("title", "")),
"artist": tags.get("ARTIST", tags.get("artist", "")),
"album": tags.get("ALBUM", tags.get("album", "")),
"year": tags.get("DATE", tags.get("date", "")),
"genre": tags.get("GENRE", tags.get("genre", "")),
"disc": tags.get("DISC", tags.get("disc", "")),
}
except Exception:
pass
# Re-encode to FLAC (overwrite original)
if encode_flac(pcm, flac_path, metadata):
return {"trimmed_secs": trimmed_secs, "success": True, "error": None}
else:
return {"trimmed_secs": 0, "success": False, "error": "FLAC encode failed"}
# ── FLAC Encoding ─────────────────────────────────────────────────────────────
DEFAULT_AUDIO_DIR = Path(__file__).parent / "album_audio"
def encode_flac(pcm: bytes, output_path: Path, metadata: dict | None = None) -> bool:
"""Encode PCM audio to FLAC using ffmpeg. Returns True on success."""
if metadata is None:
metadata = {}
wav_bytes = _pcm_to_wav(pcm)
with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as f:
f.write(wav_bytes)
tmp_wav = f.name
try:
cmd = [
"ffmpeg", "-y",
"-i", tmp_wav,
"-c:a", "flac",
"-compression_level", "5", # good balance of speed vs size
]
# Add metadata tags
if metadata.get("title"):
cmd += ["-metadata", f"TITLE={metadata['title']}"]
if metadata.get("artist"):
cmd += ["-metadata", f"ARTIST={metadata['artist']}"]
if metadata.get("album"):
cmd += ["-metadata", f"ALBUM={metadata['album']}"]
if metadata.get("year"):
cmd += ["-metadata", f"DATE={metadata['year']}"]
if metadata.get("genre"):
cmd += ["-metadata", f"GENRE={metadata['genre']}"]
if metadata.get("disc"):
cmd += ["-metadata", f"DISC={metadata['disc']}"]
cmd.append(str(output_path))
result = subprocess.run(cmd, capture_output=True, timeout=120)
if result.returncode != 0:
print(f"[recorder] ffmpeg FLAC error: {result.stderr.decode()[:300]}")
return False
return True
except (subprocess.TimeoutExpired, FileNotFoundError) as e:
print(f"[recorder] encode_flac failed: {e}")
return False
finally:
os.unlink(tmp_wav)
def make_album_audio_filename(artist: str, album: str, side: str) -> str:
"""Build filename for a full-side album recording."""
def san(s: str) -> str:
if not s:
return "Unknown"
for ch in r'\/:*?"<>|':
s = s.replace(ch, "-")
return s.strip(" .")[:60]
return f"{san(artist)} - {san(album)} - Side {side}.flac"
# ── Album Recorder (Full-Side Capture) ───────────────────────────────────────
class AlbumRecorder:
"""
Captures a full album side as one continuous FLAC file while the existing
RecordingBuffer handles track-level splitting for fingerprinting.
Usage:
recorder = AlbumRecorder(album_id, side, album_info)
# Feed PCM from audio callback:
recorder.put(pcm_chunk)
# When track boundary detected by RecordingBuffer:
recorder.mark_track_boundary(track_id)
# When end-of-side detected or user stops:
path, duration = recorder.finish()
"""
def __init__(self, album_id: int, side: str, album_info: dict,
audio_dir: Path | None = None,
gate_threshold: float = SILENCE_RATIO_MIN):
self._lock = threading.Lock()
self.album_id = album_id
self.side = side
self.album_info = album_info # {artist, title, year, genre, ...}
self._audio_dir = (audio_dir or DEFAULT_AUDIO_DIR).resolve()
self._gate_threshold = gate_threshold
self._chunks: list[bytes] = []
self._total_bytes = 0
self._active = True
# Track boundary tracking
self._track_boundaries: list[dict] = [] # [{track_id, start_byte, start_secs}]
self._current_track_start_byte = 0
# Startup gate: same idea as RecordingBuffer: don't count silence
self._audio_started = False
self.on_audio_detected = None # callback when first audio arrives
self._audio_dir.mkdir(parents=True, exist_ok=True)
print(f"[album-rec] Started: {album_info.get('artist')} - "
f"{album_info.get('title')} Side {side}")
@property
def is_active(self) -> bool:
return self._active
@property
def elapsed_secs(self) -> float:
with self._lock:
return self._total_bytes / (SAMPLE_RATE * CHANNELS * 2)
@property
def track_count(self) -> int:
return len(self._track_boundaries)
def put(self, pcm_chunk: bytes, rms: float | None = None):
"""Called from audio callback with each block of int16 stereo PCM.
If *rms* is provided (pre-computed in the callback), the expensive
int16->float32 conversion + RMS calculation is skipped.
"""
if not self._active:
return
# Detect first audio to mark start
if not self._audio_started:
if rms is None:
samples = np.frombuffer(pcm_chunk, dtype=np.int16).astype(np.float32) / 32768.0
rms = float(np.sqrt(np.mean(samples ** 2)))
if rms >= self._gate_threshold:
self._audio_started = True
print("[album-rec] Audio detected: recording")
if self.on_audio_detected:
with contextlib.suppress(Exception):
self.on_audio_detected()
else:
return # skip pre-needle silence
with self._lock:
self._chunks.append(pcm_chunk)
self._total_bytes += len(pcm_chunk)
def mark_track_boundary(self, track_id: int | None = None):
"""
Called when RecordingBuffer detects a track split (silence gap).
Records the timestamp of the boundary within the full-side audio.
"""
with self._lock:
boundary_byte = self._total_bytes
boundary_secs = boundary_byte / (SAMPLE_RATE * CHANNELS * 2)
# Close out the previous track boundary
if self._track_boundaries:
prev = self._track_boundaries[-1]
prev["end_byte"] = boundary_byte
prev["end_secs"] = boundary_secs
# Start new track
self._track_boundaries.append({
"track_id": track_id,
"start_byte": boundary_byte,
"start_secs": boundary_secs,
"end_byte": None,
"end_secs": None,
})
print(f"[album-rec] Track boundary at {boundary_secs:.1f}s "
f"(track {len(self._track_boundaries)}, id={track_id})")
def mark_first_track(self, track_id: int | None = None):
"""
Mark the start of the first track (called when audio is first detected).
"""
with self._lock:
if not self._track_boundaries:
self._track_boundaries.append({
"track_id": track_id,
"start_byte": 0,
"start_secs": 0.0,
"end_byte": None,
"end_secs": None,
})
print(f"[album-rec] First track started (id={track_id})")
def finish(self) -> tuple[Path | None, float, list[dict]]:
"""
Finalize the recording: encode to FLAC, return path + duration + boundaries.
Returns (file_path, duration_secs, track_boundaries) or (None, 0, []).
"""
with self._lock:
self._active = False
if not self._chunks:
print("[album-rec] Nothing recorded: no audio received")
return None, 0.0, []
pcm = b"".join(self._chunks)
self._chunks = []
# Close out the last track boundary
total_secs = len(pcm) / (SAMPLE_RATE * CHANNELS * 2)
if self._track_boundaries:
last = self._track_boundaries[-1]
if last["end_secs"] is None:
last["end_byte"] = len(pcm)
last["end_secs"] = total_secs
boundaries = list(self._track_boundaries)
# ── Trim leading noise + needle drop ─────────────────────────
# The needle drop creates a short transient (70-350ms, up to 75%
# peak) followed by a return to near-silence before the actual
# music starts. Simple RMS thresholds mistake it for audio.
# Strategy: scan in 100ms windows, find the first point where
# audio is *sustained* (3+ consecutive windows above threshold),
# then trim everything before that point with a short fade-in.
len(pcm)
lead_pos = _find_music_start(pcm)
if lead_pos > 0:
lead_trimmed_secs = lead_pos / (SAMPLE_RATE * CHANNELS * 2)
pcm = pcm[lead_pos:]
print(f"[album-rec] Trimmed {lead_trimmed_secs:.2f}s "
f"(needle drop + lead-in)")
# Shift all track boundaries back by the trimmed amount
for b in boundaries:
b["start_byte"] = max(0, b["start_byte"] - lead_pos)
if b["end_byte"] is not None:
b["end_byte"] = max(0, b["end_byte"] - lead_pos)
b["start_secs"] = max(0.0, b["start_secs"] - lead_trimmed_secs)
if b["end_secs"] is not None:
b["end_secs"] = max(0.0, b["end_secs"] - lead_trimmed_secs)
# Apply a 50ms fade-in at the new start to avoid any residual click
fade_samples = int(SAMPLE_RATE * 0.05)
fade_bytes = fade_samples * CHANNELS * 2
if len(pcm) > fade_bytes:
arr = np.frombuffer(pcm[:fade_bytes], dtype=np.int16).copy()
ramp = np.repeat(
np.linspace(0.0, 1.0, fade_samples, dtype=np.float32),
CHANNELS,
)
arr = (arr.astype(np.float32) * ramp).astype(np.int16)
pcm = arr.tobytes() + pcm[fade_bytes:]
# ── Trim trailing silence ──────────────────────────────────────
original_len = len(pcm)
trim_pos = original_len
# Walk backwards in 1-second blocks
while trim_pos > TRIM_BLOCK:
block_start = trim_pos - TRIM_BLOCK
block = np.frombuffer(pcm[block_start:trim_pos], dtype=np.int16)
rms = float(np.sqrt(np.mean((block.astype(np.float32) / 32768.0) ** 2)))
if rms >= TRIM_THRESHOLD:
# This block has audio: keep everything up to here + fade tail
trim_pos = min(trim_pos + FADE_TAIL, original_len)
# Align to frame boundary (2 channels x 2 bytes = 4 bytes per frame)
trim_pos = trim_pos - (trim_pos % 4)
break
trim_pos = block_start
else:
trim_pos = original_len # don't trim if everything is quiet (shouldn't happen)
if trim_pos < original_len:
trimmed_secs = (original_len - trim_pos) / (SAMPLE_RATE * CHANNELS * 2)
pcm = pcm[:trim_pos]
print(f"[album-rec] Trimmed {trimmed_secs:.1f}s trailing silence")
# Update last track boundary to match trimmed length
new_total = len(pcm) / (SAMPLE_RATE * CHANNELS * 2)
if boundaries and boundaries[-1]["end_secs"] is not None:
boundaries[-1]["end_secs"] = new_total
boundaries[-1]["end_byte"] = len(pcm)
# ──────────────────────────────────────────────────────────────
duration = _pcm_duration(pcm)
if duration < 30: # less than 30 seconds: probably not a real side
print(f"[album-rec] Recording too short ({duration:.1f}s): discarding")
return None, 0.0, []
# Build filename and encode
filename = make_album_audio_filename(
self.album_info.get("artist", "Unknown"),
self.album_info.get("title", "Unknown Album"),
self.side,
)
output_path = self._audio_dir / filename
# Avoid overwriting
counter = 1
base = output_path.stem
while output_path.exists():
output_path = self._audio_dir / f"{base} ({counter}).flac"
counter += 1
metadata = {
"title": f"{self.album_info.get('title', 'Unknown')} - Side {self.side}",
"artist": self.album_info.get("artist", "Unknown"),
"album": self.album_info.get("title", "Unknown Album"),
"year": self.album_info.get("year", ""),
"genre": self.album_info.get("genre", ""),
"disc": self.side,
}
print(f"[album-rec] Encoding FLAC: {output_path.name} ({duration:.0f}s)")
if not encode_flac(pcm, output_path, metadata):
print("[album-rec] FLAC encoding failed!")
return None, 0.0, []
if not output_path.exists():
print("[album-rec] Output file missing after encode")
return None, 0.0, []
size_mb = output_path.stat().st_size / (1024 * 1024)
print(f"[album-rec] ✓ Saved {output_path.name} ({duration:.0f}s, {size_mb:.1f} MB)")
return output_path, duration, boundaries
def cancel(self):
"""Discard the recording without saving."""
with self._lock:
self._active = False
self._chunks = []
self._total_bytes = 0
print("[album-rec] Recording cancelled")