From 525695cc7dc6548f6dbbdd7eac533ff5a8884456 Mon Sep 17 00:00:00 2001 From: IllusionMan1212 Date: Fri, 18 Jul 2025 02:35:53 +0200 Subject: [PATCH] more experiments; basic one-way voip; pulse instead of alsa for capturing; capture multiple audio streams using pulse --- .gitignore | 3 +- pulse/pulse.odin | 304 ++++++++++++++++++++++++++++ main.odin => recorder/recorder.odin | 2 +- voip/voip.odin | 91 +++++++++ 4 files changed, 398 insertions(+), 2 deletions(-) create mode 100644 pulse/pulse.odin rename main.odin => recorder/recorder.odin (99%) create mode 100644 voip/voip.odin diff --git a/.gitignore b/.gitignore index bb64745..8d04f40 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ -!*.odin +*.wav +*.bin diff --git a/pulse/pulse.odin b/pulse/pulse.odin new file mode 100644 index 0000000..a71e7f9 --- /dev/null +++ b/pulse/pulse.odin @@ -0,0 +1,304 @@ +package main + +import "base:runtime" +import "base:intrinsics" + +import "core:fmt" +import "core:os/os2" +import "core:c" +import "core:log" +import "core:encoding/endian" +import "core:strings" +import "core:time" +import "core:sys/posix" + +import "shared:pulse" + +BITS_PER_SAMPLE :: 16 +CHANNELS :: 1 +SAMPLE_RATE :: 44100 +SAMPLE_FORMAT: pulse.pa_sample_format : .S16LE +WAV_HEADER_SIZE :: 44 + +SHOULD_EXIT := false +SHOULD_DELETE_FILES := false +WE_ARE_READY := false +logger: log.Logger + +InputDevice :: struct { + name: string, + description: string, + index: u32, + sample_spec: pulse.pa_sample_spec, +} + +State :: enum { + Initial, + GettingSourceInfo, + StartCapture, + Capturing, +} + +ReadCallbackData :: struct { + file: ^os2.File, + audio_data_size: uint, +} + +state_cb :: proc "c" (ctx: ^pulse.pa_context, userdata: rawptr) { + context = runtime.default_context() + state := pulse.context_get_state(ctx) + + #partial switch state { + case .READY: + WE_ARE_READY = true + case .FAILED: + os2.exit(1) + case .TERMINATED: + SHOULD_EXIT = true + } +} + +source_info_cb :: proc "c" (ctx: ^pulse.pa_context, source_info: ^pulse.pa_source_info, end_of_list: b32, userdata: rawptr) { + context = runtime.default_context() + context.logger = logger + sources := cast(^[dynamic]InputDevice)(userdata) + + if end_of_list { + return + } + + // Skip all monitor devices + if source_info.monitor_of_sink != 0xFFFFFFFF { + return + } + + log.info("Found input device:", source_info.description) + + if source_info.mute == 1 { + log.warnf("Attempting to unmute input device \"%s\". Make sure it did unmute, we don't log any errors if it failed.", source_info.description) + pulse.context_set_source_mute_by_name(ctx, source_info.name, false, nil, nil) + } + + append(sources, InputDevice{ + name = strings.clone_from_cstring(source_info.name), + description = strings.clone_from_cstring(source_info.description), + index = source_info.index, + sample_spec = source_info.sample_spec, + }) +} + +read_callback :: proc "c" (stream: ^pulse.pa_stream, nbytes: c.size_t, userdata: rawptr) { + context = runtime.default_context() + context.logger = logger + cb_data := transmute(^ReadCallbackData)userdata + + log.assert(cb_data.file != nil, "File is nil") + log.assert(stream != nil, "Stream is nil") + log.assert(nbytes > 0, "size is 0") + + size: c.size_t + data: rawptr + + err := pulse.stream_peek(stream, &data, &size) + log.assert(err == 0, fmt.tprintfln("Err non-zero: %v", pulse.strerror(err))) + + writ, writ_err := os2.write_ptr(cb_data.file, data, auto_cast size) + log.assert(writ_err == nil, fmt.tprint("Failed to write to file:", writ_err)) + cb_data.audio_data_size += auto_cast writ + + err = pulse.stream_drop(stream) + log.assert(err == 0, fmt.tprintfln("Err non-zero: %v", pulse.strerror(err))) +} + +prepare_output_files :: proc(file_name1, file_name2: string, four_byte_slice, two_byte_slice: []byte) -> (^os2.File, ^os2.File) { + output_file, open_err := os2.open(file_name1, {.Write, .Create, .Trunc}, 0o644) + if open_err != nil { + log.panic("open_err:", open_err) + } + + filtered_file, filtered_err := os2.open(file_name2, {.Write, .Create, .Trunc}, 0o644) + if filtered_err != nil { + log.panic("filtered_open:", filtered_err) + } + + os2.write(output_file, []byte{'R', 'I', 'F', 'F'}) // Riff file + os2.write(output_file, []byte{0,0,0,0}) // File size - 8 bytes. To be filled in later + os2.write(output_file, []byte{'W', 'A', 'V', 'E'}) // WAVE data + os2.write(output_file, []byte{'f', 'm', 't', ' '}) // Format chunk marker with NUL byte + endian.put_i32(four_byte_slice, .Little, 16) + os2.write(output_file, four_byte_slice) // Chunk size: 16 for PCM + endian.put_i16(two_byte_slice, .Little, 1) + os2.write(output_file, two_byte_slice) // Audio format: 1 for PCM + os2.write(output_file, two_byte_slice) // Number of channels + endian.put_i32(four_byte_slice, .Little, SAMPLE_RATE) + os2.write(output_file, four_byte_slice) // Sample rate + endian.put_i32(four_byte_slice, .Little, SAMPLE_RATE * CHANNELS * BITS_PER_SAMPLE / 8) + os2.write(output_file, four_byte_slice) // Byte rate + endian.put_i16(two_byte_slice, .Little, CHANNELS * BITS_PER_SAMPLE / 8) + os2.write(output_file, two_byte_slice) // Block align + endian.put_i16(two_byte_slice, .Little, BITS_PER_SAMPLE) + os2.write(output_file, two_byte_slice) // Bits per sample + os2.write(output_file, []byte{'d', 'a', 't', 'a'}) // Data chunk header + os2.write(output_file, []byte{0,0,0,0}) // Length of data. To be filled in later + + os2.write(filtered_file, []byte{'R', 'I', 'F', 'F'}) // Riff file + os2.write(filtered_file, []byte{0,0,0,0}) // File size - 8 bytes. To be filled in later + os2.write(filtered_file, []byte{'W', 'A', 'V', 'E'}) // WAVE data + os2.write(filtered_file, []byte{'f', 'm', 't', ' '}) // Format chunk marker with NUL byte + endian.put_i32(four_byte_slice, .Little, 16) + os2.write(filtered_file, four_byte_slice) // Chunk size: 16 for PCM + endian.put_i16(two_byte_slice, .Little, 1) + os2.write(filtered_file, two_byte_slice) // Audio format: 1 for PCM + os2.write(filtered_file, two_byte_slice) // Number of channels + endian.put_i32(four_byte_slice, .Little, SAMPLE_RATE) + os2.write(filtered_file, four_byte_slice) // Sample rate + endian.put_i32(four_byte_slice, .Little, SAMPLE_RATE * CHANNELS * BITS_PER_SAMPLE / 8) + os2.write(filtered_file, four_byte_slice) // Byte rate + endian.put_i16(two_byte_slice, .Little, CHANNELS * BITS_PER_SAMPLE / 8) + os2.write(filtered_file, two_byte_slice) // Block align + endian.put_i16(two_byte_slice, .Little, BITS_PER_SAMPLE) + os2.write(filtered_file, two_byte_slice) // Bits per sample + os2.write(filtered_file, []byte{'d', 'a', 't', 'a'}) // Data chunk header + os2.write(filtered_file, []byte{0,0,0,0}) // Length of data. To be filled in later + + return output_file, filtered_file +} + +main :: proc() { + logger = log.create_console_logger() + context.logger = logger + + ml := pulse.mainloop_new() + log.assert(ml != nil, "mainloop is a nil pointer") + defer pulse.mainloop_free(ml) + + api := pulse.mainloop_get_api(ml) + log.assert(api != nil, "mainloop_api is a nil pointer") + + ctx := pulse.context_new(api, nil) + log.assert(ctx != nil, "pa_context is a nil pointer") + + err := pulse.context_connect(ctx, nil, .NOFLAGS, nil) + log.assert(err == 0, fmt.tprintfln("Err non-zero: %v", pulse.strerror(err))) + defer pulse.context_unref(ctx) + defer pulse.context_disconnect(ctx) + + pulse.context_set_state_callback(ctx, state_cb, nil) + + state := State.Initial + op: ^pulse.pa_operation + + sources := make([dynamic]InputDevice, 0) + stream: ^pulse.pa_stream + filtered_stream: ^pulse.pa_stream + defer if stream != nil { + pulse.stream_disconnect(stream) + pulse.stream_unref(stream) + } + defer if filtered_stream != nil { + pulse.stream_disconnect(filtered_stream) + pulse.stream_unref(filtered_stream) + } + + action: posix.sigaction_t + action.sa_handler = proc "c" (signal: posix.Signal) { + context = runtime.default_context() + fmt.println("\nCaught SIGINT") + intrinsics.atomic_store(&SHOULD_EXIT, true) + } + posix.sigaction(.SIGINT, &action, nil) + + four_byte_slice := make([]byte, 4) + two_byte_slice := make([]byte, 2) + defer delete(four_byte_slice) + defer delete(two_byte_slice) + + now := time.now() + year, month, day := time.date(now) + hour, minute, second := time.clock_from_time(now) + + file_name1 := fmt.tprintf("capture-%v-%02d-%02dT%02d:%02d:%02d.wav", year, int(month), day, hour, minute, second) + file_name2 := fmt.tprintf("capture-filtered-%v-%02d-%02dT%02d:%02d:%02d.wav", year, int(month), day, hour, minute, second) + + output_file, filtered_file := prepare_output_files(file_name1, file_name2, four_byte_slice, two_byte_slice) + defer { + os2.close(output_file) + os2.close(filtered_file) + } + + read_cb_data := ReadCallbackData{ + file = output_file, + audio_data_size = 0, + } + filtered_cb_data := ReadCallbackData{ + file = filtered_file, + audio_data_size = 0, + } + + loop: for { + if !WE_ARE_READY { + pulse.mainloop_iterate(ml, true, nil) + continue + } + + if SHOULD_EXIT { + break loop + } + + switch state { + case .Initial: + op = pulse.context_get_source_info_list(ctx, source_info_cb, &sources) + state = .GettingSourceInfo + case .GettingSourceInfo: + if pulse.operation_get_state(op) == .DONE { + pulse.operation_unref(op) + if len(sources) < 2 { + SHOULD_DELETE_FILES = true + break loop + } + log.debug(sources) + state = .StartCapture + } + case .StartCapture: + log.info("Starting Capture") + stream = pulse.stream_new(ctx, "Default Capture", &{ rate = SAMPLE_RATE, format = SAMPLE_FORMAT, channels = 1 }, nil) + filtered_stream = pulse.stream_new(ctx, "Filtered Capture", &{ rate = SAMPLE_RATE, format = SAMPLE_FORMAT, channels = 1 }, nil) + pulse.stream_set_read_callback(stream, read_callback, &read_cb_data) + pulse.stream_set_read_callback(filtered_stream, read_callback, &filtered_cb_data) + err = pulse.stream_connect_record(stream, strings.clone_to_cstring(sources[0].name), nil, {}) + log.assert(err == 0, fmt.tprintfln("Err non-zero: %v", pulse.strerror(err))) + if !strings.contains(sources[1].description, "Filtered") { + log.warn("The device chosen as the \"Filtered\" device doesn't contain \"Filtered\"in its description") + } + + err = pulse.stream_connect_record(filtered_stream, strings.clone_to_cstring(sources[1].name), nil, {}) + log.assert(err == 0, fmt.tprintfln("Err non-zero: %v", pulse.strerror(err))) + + state = .Capturing + case .Capturing: + // Placeholder state just so we leave the StartCapture state. + } + + pulse.mainloop_iterate(ml, state != .StartCapture, nil) + } + + endian.put_i32(four_byte_slice, .Little, i32(read_cb_data.audio_data_size + WAV_HEADER_SIZE - 8)) + os2.write_at(output_file, four_byte_slice, 4) // Overall size - 8 bytes + + endian.put_i32(four_byte_slice, .Little, i32(read_cb_data.audio_data_size)) + os2.write_at(output_file, four_byte_slice, 40) // Data size + + endian.put_i32(four_byte_slice, .Little, i32(filtered_cb_data.audio_data_size + WAV_HEADER_SIZE - 8)) + os2.write_at(filtered_file, four_byte_slice, 4) // Overall size - 8 bytes + + endian.put_i32(four_byte_slice, .Little, i32(filtered_cb_data.audio_data_size)) + os2.write_at(filtered_file, four_byte_slice, 40) // Data size + + if SHOULD_DELETE_FILES { + log.error("NoiseTorch might not be running or there's no microphone plugged in") + os2.remove(file_name1) + os2.remove(file_name2) + } + + log.info("Exiting...") +} diff --git a/main.odin b/recorder/recorder.odin similarity index 99% rename from main.odin rename to recorder/recorder.odin index 26bb201..7bcd047 100644 --- a/main.odin +++ b/recorder/recorder.odin @@ -17,7 +17,7 @@ FRAMES :: 128 FORMAT: alsa.pcm_format_t : .S16_LE BITS_PER_SAMPLE :: 16 CHANNELS :: 1 -SAMPLE_RATE :: 22050 +SAMPLE_RATE :: 44100 WAV_HEADER_SIZE :: 44 diff --git a/voip/voip.odin b/voip/voip.odin new file mode 100644 index 0000000..a767932 --- /dev/null +++ b/voip/voip.odin @@ -0,0 +1,91 @@ +package main + +import "core:strings" +import "core:thread" +import "core:fmt" +import "core:net" +import "core:os/os2" + +import "shared:alsa" + +DEVICE :: "default" +FRAMES :: 128 +FORMAT: alsa.pcm_format_t : .S16_LE +BITS_PER_SAMPLE :: 16 +CHANNELS :: 1 +SAMPLE_RATE :: 22050 +UDP_DATAGRAM_MAX_LEN :: 65535 + +main :: proc() { + handle: ^alsa.pcm_t + + alsa_err := alsa.pcm_open(&handle, DEVICE, .CAPTURE, .BLOCK) + if alsa_err < 0 { + fmt.eprintln("Failed to open default capture device:", alsa_err) + os2.exit(1) + } + defer alsa.pcm_close(handle) + + alsa_err = alsa.pcm_set_params(handle, FORMAT, .RW_INTERLEAVED, CHANNELS, SAMPLE_RATE, 1, 500000) + if alsa_err < 0 { + fmt.eprintln("Failed to set the HW params:", alsa_err) + os2.exit(1) + } + + alsa_err = alsa.pcm_prepare(handle) + if alsa_err < 0 { + fmt.eprintln("Failed to prepare audio device for capture:", alsa_err) + os2.exit(1) + } + + sock, err := net.make_bound_udp_socket(net.IP4_Address{0, 0, 0, 0}, 50550) + //sock, err := net.create_socket(.IP4, .UDP) + if err != nil { + fmt.eprintln("Failed to create a UDP socket:", err) + os2.exit(1) + } + fmt.println("Listening on 0.0.0.0:50550") + + samples := make([]u8, FRAMES * CHANNELS * alsa.pcm_format_width(FORMAT) / 8) + + thread.run_with_data(&sock, proc(sock: rawptr) { + sock := transmute(^net.UDP_Socket)sock + recv_buf := make([]u8, UDP_DATAGRAM_MAX_LEN) + + for { + read_bytes, recv_endpoint, err := net.recv_udp(sock^, recv_buf) + if err != nil { + if err == .Excess_Truncated { + continue + } + fmt.eprintln("Failed to receive data from UDP socket:", err) + os2.exit(1) + } + if read_bytes > 0 { + fmt.printf("Got this message from \"%v:%v\": ", recv_endpoint.address, recv_endpoint.port) + fmt.printfln("\"%v\"", strings.trim_space(string(recv_buf))) + //fmt.println(read_bytes) + //fmt.println(recv_endpoint) + } + } + }) + + for { + read := alsa.pcm_readi(handle, raw_data(samples), FRAMES) + if read != FRAMES { + fmt.eprintfln("Expected to read %v frame, got %v frames instead", FRAMES, read) + os2.exit(1) + } + + endpoint := net.Endpoint{ + address = net.IP4_Address{10, 0, 0, 5}, + port = 1234, + } + + _, err := net.send_udp(sock, samples, endpoint) + if err != nil { + fmt.eprintln("Failed to send data to UDP socket:", err) + os2.exit(1) + } + } +}