Prd/components/backend/lib/helpdesk/wav_split.rb
Lucy Doupalů be9f14ce34 Helpdesk - operator console + patched GrapheneOS Dialer for call handling
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.
2026-07-27 18:50:32 +02:00

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