Module: OpenAI::LocalAudio

Defined in:
lib/openai/helpers/local_audio.rb,
lib/openai/helpers/local_audio/errors.rb,
lib/openai/helpers/local_audio/process.rb,
sig/openai/helpers/local_audio/interface.rbs

Overview

Optional local microphone and speaker helpers. Loading this file opens no devices.

Defined Under Namespace

Classes: DependencyError, DeviceError, Error, FormatError, MediaProcess, PlaybackError, TimeoutError, UnsupportedPlatformError

Class Method Summary collapse

Class Method Details

.capture_device(device, platform: RUBY_PLATFORM) ⇒ Object

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.



120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
# File 'lib/openai/helpers/local_audio.rb', line 120

def capture_device(device, platform: RUBY_PLATFORM)
  unless device.nil? || device.is_a?(String) || (device.is_a?(Integer) && device >= 0)
    raise ArgumentError, "device must be a device name or nonnegative index"
  end

  raise ArgumentError, "invalid device identifier" if device.to_s.include?("\0") || device == ""
  case platform
  when /darwin/
    ["avfoundation", "none:#{device || "default"}"]
  when /linux/
    ["alsa", device.is_a?(Integer) ? "hw:#{device}" : (device || "default")]
  when /mswin|mingw|cygwin/
    raise ArgumentError, "Windows capture requires an explicit audio device name" unless device.is_a?(String)
    ["dshow", "audio=#{device}"]
  else
    raise UnsupportedPlatformError, "Local capture is not supported on this platform."
  end
end

.play(source, format: :auto, timeout: nil) ⇒ nil

Play encoded audio, or explicitly selected mono 24 kHz raw PCM16. The source is consumed from its current position and remains caller-owned.

Returns:

  • (nil)

Raises:

  • (ArgumentError)


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
# File 'lib/openai/helpers/local_audio.rb', line 67

def play(source, format: :auto, timeout: nil)
  raise ArgumentError, "format must be :auto or :pcm" unless [:auto, :pcm].include?(format)
  MediaProcess.duration(timeout, name: :timeout) unless timeout.nil?
  io = playback_source(source)
  reader, writer = IO.pipe(binmode: true)
  child = nil
  begin
    # FFplay can exit zero on decoder/device failure. Its owner drains
    # error output while feeding input, retaining only a boolean.
    args = ["ffplay", "-autoexit", "-nodisp", "-nostats", "-loglevel", "error", "-protocol_whitelist", "pipe"]
    args.concat(
      format == :pcm ? ["-f", "s16le", "-ar", "24000", "-ch_layout", "mono"] : [
        "-format_whitelist",
        "wav,mp3,ogg,aac,flac"
      ]
    )
    child = MediaProcess.new(
      args + ["-i", "pipe:0"],
      input: reader,
      output: File::NULL,
      capture_errors: true,
      timeout: timeout
    )
    reader.close
    bytes = 0
    while (chunk = child.read(io, stop_on_exit: true))
      break if chunk.empty?
      bytes += chunk.bytesize
      child.write(writer, chunk)
    end

    raise PlaybackError, "Audio input is empty." if bytes.zero?
    raise FormatError, "PCM input ends with an incomplete sample." if format == :pcm && bytes.odd?
    writer.close
    status = child.wait
    raise PlaybackError, "Audio playback failed." unless status.success? && !child.errors?
    nil
  ensure
    reader.close unless reader.closed?
    writer.close unless writer.closed?
    child&.close
  end
end

.playback_source(source) ⇒ Object

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.



112
113
114
115
116
117
# File 'lib/openai/helpers/local_audio.rb', line 112

def playback_source(source)
  content = source.is_a?(OpenAI::FilePart) ? source.content : source
  return content if content.is_a?(IO) || content.is_a?(StringIO)
  return StringIO.new(content) if source.is_a?(OpenAI::FilePart) && content.is_a?(String)
  raise ArgumentError, "source must be IO, StringIO, or a FilePart containing IO or bytes"
end

.record(duration:, device: nil, timeout: nil) ⇒ OpenAI::FilePart

Record a finite mono 24 kHz WAV, ready for a multipart audio upload.

Returns:



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
# File 'lib/openai/helpers/local_audio.rb', line 13

def record(duration:, device: nil, timeout: nil)
  seconds = MediaProcess.duration(duration, name: :duration)
  MediaProcess.duration(timeout, name: :timeout) unless timeout.nil?
  format, source = capture_device(device)
  reader, writer = IO.pipe(binmode: true)
  child = nil
  begin
    child = MediaProcess.new(
      [
        "ffmpeg",
        "-nostdin",
        "-hide_banner",
        "-loglevel",
        "quiet",
        "-f",
        format,
        "-i",
        source,
        "-t",
        seconds.to_s,
        "-ar",
        "24000",
        "-ac",
        "1",
        "-acodec",
        "pcm_s16le",
        "-f",
        "s16le",
        "pipe:1"
      ],
      input: File::NULL,
      output: writer,
      timeout: timeout
    )
    writer.close
    pcm = +"".b
    while (chunk = child.read(reader))
      pcm << chunk
    end

    raise DeviceError, "Microphone capture failed." unless child.wait.success?
    raise DeviceError, "Microphone returned no complete audio samples." if pcm.empty? || pcm.bytesize.odd?
    audio = StringIO.new(wav_header(pcm.bytesize) << pcm)
    OpenAI::FilePart.new(audio, filename: "audio.wav", content_type: "audio/wav")
  ensure
    reader.close unless reader.closed?
    writer.close unless writer.closed?
    child&.close
  end
end

.wav_header(bytes) ⇒ String

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Parameters:

  • bytes (Integer)

Returns:

  • (String)


140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
# File 'lib/openai/helpers/local_audio.rb', line 140

def wav_header(bytes)
  format = "fmt " + [16, 1, 1, 24_000, 48_000, 2, 16].pack("VvvVVvv")
  if bytes <= 0xFFFFFFFF - 36
    "RIFF" + [bytes + 36].pack("V") + "WAVE" + format + "data" + [bytes].pack("V")
  else
    "RF64" +
      [0xFFFFFFFF].pack("V") +
      "WAVEds64" +
      [28].pack("V") +
      [bytes + 72, bytes, bytes / 2, 0].pack("Q<Q<Q<V") +
      format +
      "data" +
      [0xFFFFFFFF].pack("V")
  end
end