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
|
# Verify silence detection on a real audio file (self-contained, headless).
# Usage: ./zig-out/bin/game game/study_player/verify_silence.rb <audio_file>
# ── Pure Core module (inlined for mruby) ──
module StudyPlayer
module Core
SILENCE_THRESHOLD = 0.015
SILENCE_MIN_DURATION = 0.75
PADDING_SECONDS = 0.25
CHUNK_SECONDS = 0.01
LEAD_FRAMES = 2
def self.detect_silence(samples, sample_rate,
threshold: SILENCE_THRESHOLD,
min_duration: SILENCE_MIN_DURATION)
total_frames = samples.length
return [] if total_frames == 0
chunk_size = (sample_rate * CHUNK_SECONDS).to_i
chunk_size = 1 if chunk_size < 1
min_frames = min_duration * sample_rate
regions = []
in_silence = false
silence_start = 0
i = 0
while i < total_frames
range_end = i + chunk_size
range_end = total_frames if range_end > total_frames
peak = 0.0
j = i
while j < range_end
v = samples[j]
v = -v if v < 0.0
peak = v if v > peak
j += 1
end
if peak < threshold
unless in_silence
silence_start = i
in_silence = true
end
else
if in_silence
len = i - silence_start
if len >= min_frames
regions << {
start: silence_start.to_f / total_frames,
end: i.to_f / total_frames,
}
end
in_silence = false
end
end
i = range_end
end
if in_silence
len = total_frames - silence_start
if len >= min_frames
regions << { start: silence_start.to_f / total_frames, end: 1.0 }
end
end
regions
end
def self.pad_silence_regions(regions, duration, padding: PADDING_SECONDS)
return [] if duration <= 0.0
return regions.dup if regions.empty?
pad_norm = padding / duration
result = []
regions.each do |r|
s = r[:start] + pad_norm
e = r[:end] - pad_norm
result << { start: s, end: e } if s < e
end
result
end
def self.total_speaking_portions(regions)
regions.length + 1
end
end
end
# ── Main ──
path = ARGV[0]
unless path && File.exist?(path)
puts "Usage: game verify_silence.rb <audio_file>"
exit 1
end
Rl.set_trace_log_level(4) # suppress INFO spam
Rl.init_window(100, 100, "verify")
Rl.set_window_state(Rl::FLAG_WINDOW_HIDDEN)
Rl.init_audio_device
begin
puts "Loading: #{path}"
result = StudyAudio.load_wave_samples(path)
if result.nil? || result.length != 2
puts "ERROR: load_wave_samples failed"
exit 1
end
sample_rate = result[0].to_i
samples = result[1]
duration = samples.length.to_f / sample_rate
puts "Rate: #{sample_rate} Hz Frames: #{samples.length} Duration: #{duration.round(1)}s"
raw = StudyPlayer::Core.detect_silence(samples, sample_rate)
padded = StudyPlayer::Core.pad_silence_regions(raw, duration)
portions = StudyPlayer::Core.total_speaking_portions(padded)
puts "Raw silences: #{raw.length} Padded: #{padded.length} Portions: #{portions}"
padded.first(5).each_with_index do |r, i|
ss = r[:start] * duration
es = r[:end] * duration
puts " [#{i}] #{ss.round(1)}s – #{es.round(1)}s (#{(es-ss).round(1)}s)"
end
puts " ..." if padded.length > 5
puts "OK: #{padded.length} silences, #{portions} portions"
rescue => e
puts "FAIL: #{e.class}: #{e.message}"
exit 1
ensure
Rl.close_audio_device
Rl.close_window
end
|