# 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