A small helpdesk system: an office Pixel running a patched GrapheneOS Dialer answers technician calls, records both call legs as separate channels, and a Ruby backend transcribes them through Whisper and files an AI summary against the caller. Squashed to a single commit for sharing. No credentials are included; secrets live outside the repo in /etc/helpdesk/env on the server or a gitignored .claude/env.local locally. See .claude/env.local.example for the shape. Start at README.md, then docs/architecture.md.
111 lines
4.6 KiB
Ruby
111 lines
4.6 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
# WavSplit - separates a two-channel call recording into one file per speaker.
|
|
#
|
|
# The phone can record the call legs as separate channels (left = operator, right = caller). That is
|
|
# only useful if they stay separate: Whisper resamples everything to 16 kHz MONO before decoding, so
|
|
# handing it the stereo file downmixes the two people back together and throws away exactly the
|
|
# information the phone went to the trouble of capturing. Splitting first means each transcript is one
|
|
# known speaker, and attribution stops being something a diariser has to infer.
|
|
#
|
|
# Deliberately hand-rolled rather than shelling out to ffmpeg: de-interleaving PCM is arithmetic, and a
|
|
# pure-Ruby version keeps the pipeline free of a binary dependency the production box would otherwise
|
|
# need. It parses only what it must (fmt + data) and refuses anything it does not fully understand,
|
|
# because silently mangling a call recording is worse than declining to split it.
|
|
#
|
|
# grep anchors: wav-parse, wav-split.
|
|
|
|
module Helpdesk
|
|
module WavSplit
|
|
class Error < StandardError; end
|
|
|
|
RIFF = "RIFF"
|
|
WAVE = "WAVE"
|
|
PCM_FORMATS = [1, 0xFFFE].freeze # WAVE_FORMAT_PCM, WAVE_FORMAT_EXTENSIBLE
|
|
|
|
module_function
|
|
|
|
# Is this a stereo PCM WAV we can split? Cheap check for callers that just want to know.
|
|
def splittable?(path)
|
|
info = parse(File.binread(path, 64 + 4096))
|
|
info[:channels] == 2 && PCM_FORMATS.include?(info[:format]) && info[:bits] == 16
|
|
rescue StandardError
|
|
false
|
|
end
|
|
|
|
# Read the header. Chunks may appear in any order and unknown ones (LIST, fact) must be skipped
|
|
# rather than assumed absent. grep anchor: wav-parse.
|
|
def parse(bytes)
|
|
raise Error, "not a RIFF file" unless bytes[0, 4] == RIFF
|
|
raise Error, "not a WAVE file" unless bytes[8, 4] == WAVE
|
|
pos = 12
|
|
info = {}
|
|
while pos + 8 <= bytes.bytesize
|
|
id = bytes[pos, 4]
|
|
size = bytes[pos + 4, 4].unpack1("V")
|
|
body = pos + 8
|
|
case id
|
|
when "fmt "
|
|
f = bytes[body, size]
|
|
raise Error, "short fmt chunk" if f.nil? || f.bytesize < 16
|
|
info[:format] = f[0, 2].unpack1("v")
|
|
info[:channels] = f[2, 2].unpack1("v")
|
|
info[:sample_rate] = f[4, 4].unpack1("V")
|
|
info[:bits] = f[14, 2].unpack1("v")
|
|
when "data"
|
|
info[:data_offset] = body
|
|
info[:data_size] = size
|
|
return info if info[:channels] # header complete
|
|
end
|
|
pos = body + size + (size.odd? ? 1 : 0) # chunks are word-aligned
|
|
end
|
|
raise Error, "no data chunk" unless info[:data_offset]
|
|
info
|
|
end
|
|
|
|
# -> { left: path, right: path, frames: n }
|
|
# Left is the operator (uplink), right the caller (downlink), matching DualLegCapture on the phone.
|
|
# grep anchor: wav-split.
|
|
def split(path, left_path:, right_path:)
|
|
raw = File.binread(path)
|
|
info = parse(raw)
|
|
raise Error, "expected 2 channels, got #{info[:channels]}" unless info[:channels] == 2
|
|
raise Error, "expected 16-bit PCM, got #{info[:bits]}-bit format #{info[:format]}" unless
|
|
info[:bits] == 16 && PCM_FORMATS.include?(info[:format])
|
|
|
|
# data_size can overstate what is actually present when a recording was cut short, so trust the
|
|
# file length. A truncated tail is normal for a call that ended abruptly.
|
|
avail = raw.bytesize - info[:data_offset]
|
|
size = [info[:data_size].to_i, avail].reject(&:zero?).min || 0
|
|
frames = size / 4 # stereo, 2 bytes per sample
|
|
raise Error, "no audio frames" if frames.zero?
|
|
|
|
pcm = raw.byteslice(info[:data_offset], frames * 4)
|
|
samples = pcm.unpack("v*") # interleaved L,R,L,R...
|
|
left = String.new(capacity: frames * 2)
|
|
right = String.new(capacity: frames * 2)
|
|
i = 0
|
|
while i < samples.length - 1
|
|
left << [samples[i]].pack("v")
|
|
right << [samples[i + 1]].pack("v")
|
|
i += 2
|
|
end
|
|
|
|
File.binwrite(left_path, mono_wav(left, info[:sample_rate]))
|
|
File.binwrite(right_path, mono_wav(right, info[:sample_rate]))
|
|
{ left: left_path, right: right_path, frames: frames, sample_rate: info[:sample_rate] }
|
|
end
|
|
|
|
# Minimal 44-byte canonical mono WAV header plus the samples.
|
|
def mono_wav(pcm, sample_rate)
|
|
byte_rate = sample_rate * 2
|
|
header = +""
|
|
header << "RIFF" << [36 + pcm.bytesize].pack("V") << "WAVE"
|
|
header << "fmt " << [16].pack("V") << [1].pack("v") << [1].pack("v")
|
|
header << [sample_rate].pack("V") << [byte_rate].pack("V")
|
|
header << [2].pack("v") << [16].pack("v")
|
|
header << "data" << [pcm.bytesize].pack("V")
|
|
header << pcm
|
|
end
|
|
end
|
|
end
|