Recording somebody, and sending it as a voice message
The receiving half landed already; this is the half that could not be done without writing an Ogg container, because a call puts Opus packets straight on the wire and nothing here had ever needed one. The pieces were nearly all present: cpal captures the microphone for calls, and libopus is linked for them too - opus2 was already in the tree as songbird's own dependency, so asking for it by name costs one line of lockfile and no new C. What is new is `oggopus`, which is the framing and three rules from RFC 7845, and `voicenote`, which holds the one recording a machine with one microphone can have. The container is hand-written rather than taken from a crate, which is only defensible because the result is checkable by something nobody here wrote: ffprobe reads the output as ogg/opus, mono, 48kHz, 2.000000s, and says nothing on stderr. The checksum has known answers computed by a second implementation in Python - a self-consistent checksum test proves only that the code agrees with itself, and Ogg's CRC is not the common one, so a wrong one fails as "not an Ogg file" rather than as a checksum problem. My first expected value was wrong and the Rust was right. The waveform takes the peak of each bucket rather than the mean: a mean over a fifth of a second of speech is nearly silence, which is what makes some clients' waveforms look flat. Three RPCs rather than one, because it is three moments - somebody starts talking, then decides whether what they said is worth sending. Progress is polled rather than pushed; it is only wanted while a timer is on screen. Verified against real Discord, with permission, by sending one to a DM between two accounts the same person owns. It came back stored with flags=8192 - so Discord kept it as a voice message and not as an .ogg somebody attached - with duration_secs and waveform intact, and content_type audio/ogg, which is Discord's own check agreeing about the container. Then read back through `extract_attachments`, the parser the window actually draws from: voice=true, duration 2.0, 344 bytes of waveform. Both probes are #[ignore]d and take the token and channel from the environment, like the Sneedchat ones, rather than from anywhere in this repository. Refs #186. Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
Generated
+1
@@ -3789,6 +3789,7 @@ dependencies = [
|
||||
"matrix-sdk-common",
|
||||
"matrix-sdk-crypto",
|
||||
"matrix-sdk-sqlite",
|
||||
"opus2",
|
||||
"qrcode",
|
||||
"rand 0.8.7",
|
||||
"reqwest",
|
||||
|
||||
@@ -94,6 +94,10 @@ js_int = "=0.2.2"
|
||||
songbird = { version = "0.6", default-features = false, features = ["driver", "rustls", "tungstenite", "receive", "gateway"] }
|
||||
cpal = "0.18.2"
|
||||
symphonia = { version = "0.5", default-features = false, features = ["pcm"] }
|
||||
# The Opus encoder, for recording a voice message. Already in the tree as
|
||||
# songbird's own dependency and already built against the same libopus - this
|
||||
# only asks for it by name so it can be used directly.
|
||||
opus2 = "0.4"
|
||||
async-trait = "0.1.92"
|
||||
|
||||
# Two calls, both in main.rs: see keep_the_heap_honest() there for why a chat
|
||||
|
||||
@@ -301,6 +301,206 @@ pub async fn send_attachment(state: &AppState, buffer_id: &str, token: &str, bod
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Sending a recording as a voice message rather than as a sound file.
|
||||
///
|
||||
/// Three things make Discord treat it as one, and all three are required: the
|
||||
/// IS_VOICE_MESSAGE flag on the message, and `duration_secs` and `waveform` on
|
||||
/// the attachment. Without them the same bytes arrive as an .ogg somebody
|
||||
/// attached - which is what every other client would then show, because that
|
||||
/// is what it would be.
|
||||
///
|
||||
/// The attachment metadata travels in `payload_json` alongside the file, keyed
|
||||
/// by the index of the file part. That is the same single multipart POST the
|
||||
/// ordinary attachment path uses; the separate upload-then-attach flow exists
|
||||
/// for files too large for one request, which a voice message is not.
|
||||
///
|
||||
/// A voice message carries no text: Discord rejects one with content, and
|
||||
/// there is nowhere in its own client to type any.
|
||||
pub async fn send_voice_message(
|
||||
state: &AppState,
|
||||
buffer_id: &str,
|
||||
token: &str,
|
||||
file_path: &std::path::Path,
|
||||
duration_secs: f64,
|
||||
waveform: &str,
|
||||
) -> Result<()> {
|
||||
const IS_VOICE_MESSAGE: u64 = 1 << 13;
|
||||
|
||||
let channel_id = state
|
||||
.runtime
|
||||
.get_discord_channel(buffer_id)
|
||||
.ok_or_else(|| anyhow!("no known Discord channel for this buffer"))?;
|
||||
let bytes = tokio::fs::read(file_path)
|
||||
.await
|
||||
.with_context(|| format!("reading {}", file_path.display()))?;
|
||||
// The name Discord's own client uses. Its clients key on the flag rather
|
||||
// than on this, but a file that arrives anywhere else should say what it
|
||||
// is - and an unnamed part is rejected outright.
|
||||
let file_name = "voice-message.ogg";
|
||||
let payload = json!({
|
||||
"content": "",
|
||||
"flags": IS_VOICE_MESSAGE,
|
||||
"attachments": [{
|
||||
"id": "0",
|
||||
"filename": file_name,
|
||||
"duration_secs": duration_secs,
|
||||
"waveform": waveform,
|
||||
}],
|
||||
});
|
||||
let form = reqwest::multipart::Form::new()
|
||||
.text("payload_json", payload.to_string())
|
||||
.part(
|
||||
"files[0]",
|
||||
reqwest::multipart::Part::bytes(bytes)
|
||||
.file_name(file_name)
|
||||
.mime_str("audio/ogg")
|
||||
.context("audio/ogg is a valid mime type")?,
|
||||
);
|
||||
let resp = send_write(
|
||||
http_client()
|
||||
.post(format!("{API_BASE}/channels/{channel_id}/messages"))
|
||||
.header("Authorization", token)
|
||||
.multipart(form),
|
||||
)
|
||||
.await
|
||||
.context("uploading the voice message")?;
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let text = resp.text().await.unwrap_or_default();
|
||||
bail!("Discord API error {status}: {text}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod live_probe {
|
||||
//! Sending a real voice message to a real conversation, run by hand.
|
||||
//!
|
||||
//! The encoder can be checked offline and is - ffprobe agrees the file is
|
||||
//! Ogg/Opus of the right length. What cannot be checked offline is whether
|
||||
//! Discord accepts it *as a voice message* rather than as a sound file
|
||||
//! somebody attached, because that answer only exists on their side.
|
||||
//!
|
||||
//! `#[ignore]`d, and it takes the token and the channel from the
|
||||
//! environment rather than from anywhere in this repository:
|
||||
//! MOHO_DISCORD_TOKEN=… MOHO_DISCORD_CHANNEL=… \
|
||||
//! cargo test --release -- --ignored --nocapture voice_message_probe
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn voice_message_probe() {
|
||||
let _ = rustls::crypto::ring::default_provider().install_default();
|
||||
let token = std::env::var("MOHO_DISCORD_TOKEN").expect("MOHO_DISCORD_TOKEN");
|
||||
let channel = std::env::var("MOHO_DISCORD_CHANNEL").expect("MOHO_DISCORD_CHANNEL");
|
||||
|
||||
// Two seconds of a tone that rises and falls, so the waveform that
|
||||
// arrives is one a person can recognise as this recording rather than
|
||||
// as a flat bar.
|
||||
let samples: Vec<f32> = (0..crate::oggopus::RATE * 2)
|
||||
.map(|i| {
|
||||
let t = i as f32 / crate::oggopus::RATE as f32;
|
||||
(t * 440.0 * std::f32::consts::TAU).sin() * (t * std::f32::consts::PI / 2.0).sin() * 0.4
|
||||
})
|
||||
.collect();
|
||||
let duration = crate::oggopus::duration_secs(&samples);
|
||||
let waveform = {
|
||||
use base64::Engine;
|
||||
base64::engine::general_purpose::STANDARD.encode(crate::oggopus::waveform(&samples, 256))
|
||||
};
|
||||
let bytes = crate::oggopus::encode(&samples).expect("encoding");
|
||||
println!("{} bytes of ogg, {duration:.2}s", bytes.len());
|
||||
|
||||
const IS_VOICE_MESSAGE: u64 = 1 << 13;
|
||||
let payload = serde_json::json!({
|
||||
"content": "",
|
||||
"flags": IS_VOICE_MESSAGE,
|
||||
"attachments": [{ "id": "0", "filename": "voice-message.ogg", "duration_secs": duration, "waveform": waveform }],
|
||||
});
|
||||
let form = reqwest::multipart::Form::new()
|
||||
.text("payload_json", payload.to_string())
|
||||
.part(
|
||||
"files[0]",
|
||||
reqwest::multipart::Part::bytes(bytes)
|
||||
.file_name("voice-message.ogg")
|
||||
.mime_str("audio/ogg")
|
||||
.unwrap(),
|
||||
);
|
||||
let resp = super::http_client()
|
||||
.post(format!("{}/channels/{channel}/messages", super::API_BASE))
|
||||
.header("Authorization", &token)
|
||||
.multipart(form)
|
||||
.send()
|
||||
.await
|
||||
.expect("posting");
|
||||
let status = resp.status();
|
||||
let body: serde_json::Value = resp.json().await.expect("a JSON answer");
|
||||
println!("HTTP {status}");
|
||||
assert!(status.is_success(), "Discord refused it: {body}");
|
||||
|
||||
// The answer is the message Discord stored, so this is its own
|
||||
// verification: the flag it kept, and what it did with the metadata.
|
||||
println!("flags={} attachments={}", body["flags"], serde_json::to_string_pretty(&body["attachments"]).unwrap());
|
||||
assert_eq!(
|
||||
body["flags"].as_u64().unwrap_or(0) & IS_VOICE_MESSAGE,
|
||||
IS_VOICE_MESSAGE,
|
||||
"stored without the voice-message flag, so it is a sound file: {body}"
|
||||
);
|
||||
let att = &body["attachments"][0];
|
||||
assert!(att["duration_secs"].as_f64().is_some(), "no duration came back: {att}");
|
||||
assert!(att["waveform"].as_str().is_some(), "no waveform came back: {att}");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod receive_probe {
|
||||
//! The other half of the round trip: reading back what Discord stored and
|
||||
//! running it through the parser the window draws from.
|
||||
//!
|
||||
//! Read-only, and separate from the send probe so it can be pointed at any
|
||||
//! conversation that already has a voice message in it:
|
||||
//! MOHO_DISCORD_TOKEN=… MOHO_DISCORD_CHANNEL=… \
|
||||
//! cargo test --release -- --ignored --nocapture voice_message_read_probe
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn voice_message_read_probe() {
|
||||
let _ = rustls::crypto::ring::default_provider().install_default();
|
||||
let token = std::env::var("MOHO_DISCORD_TOKEN").expect("MOHO_DISCORD_TOKEN");
|
||||
let channel = std::env::var("MOHO_DISCORD_CHANNEL").expect("MOHO_DISCORD_CHANNEL");
|
||||
|
||||
let messages: serde_json::Value = super::http_client()
|
||||
.get(format!("{}/channels/{channel}/messages?limit=10", super::API_BASE))
|
||||
.header("Authorization", &token)
|
||||
.send()
|
||||
.await
|
||||
.expect("fetching")
|
||||
.json()
|
||||
.await
|
||||
.expect("a JSON answer");
|
||||
|
||||
let spoken = messages
|
||||
.as_array()
|
||||
.expect("a list of messages")
|
||||
.iter()
|
||||
.find(|m| m["flags"].as_u64().unwrap_or(0) & (1 << 13) != 0)
|
||||
.expect("no voice message in the last ten - send one first");
|
||||
|
||||
let attachments = crate::backend::discord::messages::extract_attachments(spoken);
|
||||
let att = attachments.first().expect("a voice message has an attachment");
|
||||
println!(
|
||||
"kind={} voice={} duration={:?} waveform={} bytes",
|
||||
att.kind,
|
||||
att.voice,
|
||||
att.duration_secs,
|
||||
att.waveform.as_deref().map(str::len).unwrap_or(0)
|
||||
);
|
||||
assert!(att.voice, "the parser did not read the flag off a real message");
|
||||
assert!(att.duration_secs.unwrap_or(0.0) > 0.0, "no length came through");
|
||||
assert!(att.waveform.is_some(), "no waveform came through");
|
||||
assert_eq!(att.kind, "audio");
|
||||
}
|
||||
}
|
||||
|
||||
/// PATCH .../messages/{id} - editing your own message. Discord scopes
|
||||
/// this to the message author (enforced server-side; there's no separate
|
||||
/// permission check needed here).
|
||||
|
||||
@@ -9,6 +9,7 @@ mod ignores;
|
||||
mod ipc;
|
||||
mod model;
|
||||
mod net;
|
||||
mod oggopus;
|
||||
mod profile;
|
||||
mod rpc;
|
||||
mod runtime;
|
||||
@@ -16,6 +17,7 @@ mod secure;
|
||||
mod state;
|
||||
mod store;
|
||||
mod upload;
|
||||
mod voicenote;
|
||||
|
||||
use accounts::AccountStore;
|
||||
use anyhow::{bail, Context, Result};
|
||||
|
||||
+340
@@ -0,0 +1,340 @@
|
||||
//! Turning captured microphone audio into the file a voice message is.
|
||||
//!
|
||||
//! Discord's own client records Ogg/Opus, and so does everything that plays
|
||||
//! one back. The pieces were nearly all here already - cpal captures the
|
||||
//! microphone for calls, and libopus is linked for them too - but nothing in
|
||||
//! this daemon had ever written an Ogg container, because a call puts Opus
|
||||
//! packets straight onto the wire with no container at all.
|
||||
//!
|
||||
//! So this is the missing part, and it is small: Ogg is a framing format for
|
||||
//! packets, and Opus-in-Ogg is three rules on top of it (RFC 7845) - a header
|
||||
//! packet, a tags packet, then the audio, with the granule position counted in
|
||||
//! 48kHz samples regardless of what the encoder was actually fed.
|
||||
//!
|
||||
//! Written here rather than taken from a crate because it is this much code
|
||||
//! and the alternative is a dependency whose only use would be these eighty
|
||||
//! lines. What makes that a reasonable trade is that the result is checkable:
|
||||
//! the tests hand the output to ffprobe, which is a decoder nobody here wrote.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use opus2::{Application, Channels, Encoder};
|
||||
|
||||
/// The rate everything speaks in: Opus is defined at 48kHz, the capture path
|
||||
/// already resamples to it, and an Ogg granule position is counted in it.
|
||||
pub const RATE: u32 = 48_000;
|
||||
|
||||
/// 20ms of audio per packet.
|
||||
///
|
||||
/// What every voice application uses, and what Discord's own recordings carry.
|
||||
/// Smaller packets cost proportionally more header; larger ones lose more when
|
||||
/// one goes missing, which matters for a call and not for a file, but there is
|
||||
/// no reason to differ from what every decoder is best exercised on.
|
||||
const FRAME_MS: u32 = 20;
|
||||
const FRAME_SAMPLES: usize = (RATE as usize / 1000) * FRAME_MS as usize;
|
||||
|
||||
/// Encodes mono 48kHz samples into an Ogg/Opus file.
|
||||
///
|
||||
/// Mono because it is somebody talking into one microphone: stereo would
|
||||
/// double the size to say the same thing twice.
|
||||
pub fn encode(samples: &[f32]) -> Result<Vec<u8>> {
|
||||
let mut encoder = Encoder::new(RATE, Channels::Mono, Application::Voip)
|
||||
.map_err(|e| anyhow::anyhow!("starting the Opus encoder: {e}"))?;
|
||||
|
||||
let mut ogg = Ogg::new(rand_serial());
|
||||
// The identification header, RFC 7845 section 5.1. The pre-skip is the
|
||||
// encoder's own latency, which a player trims from the front; libopus at
|
||||
// 48kHz reports 312 samples and is asked rather than assumed.
|
||||
let pre_skip = encoder.get_lookahead().unwrap_or(312) as u16;
|
||||
let mut head = Vec::with_capacity(19);
|
||||
head.extend_from_slice(b"OpusHead");
|
||||
head.push(1); // version
|
||||
head.push(1); // channels
|
||||
head.extend_from_slice(&pre_skip.to_le_bytes());
|
||||
head.extend_from_slice(&RATE.to_le_bytes()); // the original rate, for information
|
||||
head.extend_from_slice(&0u16.to_le_bytes()); // output gain
|
||||
head.push(0); // channel mapping family 0: mono or stereo, no table
|
||||
ogg.page(&[&head], 0, true, false);
|
||||
|
||||
// The comment header, which is required even when there is nothing to say.
|
||||
let vendor = b"moho";
|
||||
let mut tags = Vec::new();
|
||||
tags.extend_from_slice(b"OpusTags");
|
||||
tags.extend_from_slice(&(vendor.len() as u32).to_le_bytes());
|
||||
tags.extend_from_slice(vendor);
|
||||
tags.extend_from_slice(&0u32.to_le_bytes()); // no user comments
|
||||
ogg.page(&[&tags], 0, false, false);
|
||||
|
||||
// The audio itself, one page per packet. A page per packet is more header
|
||||
// than a long recording needs, and is what keeps this simple enough to be
|
||||
// obviously right; a minute of speech costs about 1.4KB of framing.
|
||||
let mut buffer = vec![0u8; 4000];
|
||||
let mut granule: u64 = 0;
|
||||
let frames: Vec<&[f32]> = samples.chunks(FRAME_SAMPLES).collect();
|
||||
for (i, frame) in frames.iter().enumerate() {
|
||||
// The last frame is padded rather than dropped: a partial frame is
|
||||
// still something somebody said, and Opus only encodes whole ones.
|
||||
let mut whole;
|
||||
let input: &[f32] = if frame.len() == FRAME_SAMPLES {
|
||||
frame
|
||||
} else {
|
||||
whole = frame.to_vec();
|
||||
whole.resize(FRAME_SAMPLES, 0.0);
|
||||
&whole
|
||||
};
|
||||
let n = encoder
|
||||
.encode_float(input, &mut buffer)
|
||||
.map_err(|e| anyhow::anyhow!("encoding {FRAME_MS}ms of audio: {e}"))?;
|
||||
granule += FRAME_SAMPLES as u64;
|
||||
ogg.page(&[&buffer[..n]], granule, false, i + 1 == frames.len());
|
||||
}
|
||||
// A recording of nothing still has to be a valid file: end the stream even
|
||||
// when no audio packet was ever written.
|
||||
if frames.is_empty() {
|
||||
ogg.page(&[&[]], 0, false, true);
|
||||
}
|
||||
Ok(ogg.done())
|
||||
}
|
||||
|
||||
/// How long the recording runs, in seconds, as the service wants it.
|
||||
pub fn duration_secs(samples: &[f32]) -> f64 {
|
||||
samples.len() as f64 / RATE as f64
|
||||
}
|
||||
|
||||
/// The picture of the sound that travels with the message.
|
||||
///
|
||||
/// Discord takes up to 256 bytes, one amplitude per bucket, and draws them as
|
||||
/// bars. Peak rather than mean within a bucket: a mean over a fifth of a
|
||||
/// second of speech is close to silence, and produces the flat line that makes
|
||||
/// some clients' waveforms look broken.
|
||||
pub fn waveform(samples: &[f32], buckets: usize) -> Vec<u8> {
|
||||
if samples.is_empty() || buckets == 0 {
|
||||
return Vec::new();
|
||||
}
|
||||
let buckets = buckets.min(samples.len());
|
||||
let step = samples.len() as f64 / buckets as f64;
|
||||
(0..buckets)
|
||||
.map(|i| {
|
||||
let from = (i as f64 * step) as usize;
|
||||
let to = (((i + 1) as f64 * step) as usize).max(from + 1).min(samples.len());
|
||||
let peak = samples[from..to].iter().fold(0.0f32, |m, s| m.max(s.abs()));
|
||||
(peak.clamp(0.0, 1.0) * 255.0).round() as u8
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A stream serial number, which only has to differ between streams in one
|
||||
/// file. There is always exactly one stream here, so anything does - but a
|
||||
/// fixed number would make two files concatenated by accident look like one
|
||||
/// stream with a broken sequence, which is a confusing thing to debug.
|
||||
fn rand_serial() -> u32 {
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.subsec_nanos()).unwrap_or(1) | 1
|
||||
}
|
||||
|
||||
/// The Ogg framing itself.
|
||||
struct Ogg {
|
||||
out: Vec<u8>,
|
||||
serial: u32,
|
||||
sequence: u32,
|
||||
}
|
||||
|
||||
impl Ogg {
|
||||
fn new(serial: u32) -> Self {
|
||||
Self { out: Vec::new(), serial, sequence: 0 }
|
||||
}
|
||||
|
||||
/// One page carrying whole packets.
|
||||
///
|
||||
/// Every packet here fits in a page - an Opus frame at any sane bitrate is
|
||||
/// far under the 255 * 255 bytes a page can hold - so this deliberately
|
||||
/// does not implement continuation across pages. A packet that would not
|
||||
/// fit cannot be produced by the encoder above, and pretending to handle a
|
||||
/// case that cannot arise would be untested code in a file format parser.
|
||||
fn page(&mut self, packets: &[&[u8]], granule: u64, first: bool, last: bool) {
|
||||
let mut segments: Vec<u8> = Vec::new();
|
||||
let mut body: Vec<u8> = Vec::new();
|
||||
for packet in packets {
|
||||
let mut left = packet.len();
|
||||
while left >= 255 {
|
||||
segments.push(255);
|
||||
left -= 255;
|
||||
}
|
||||
segments.push(left as u8);
|
||||
body.extend_from_slice(packet);
|
||||
}
|
||||
|
||||
let mut header = Vec::with_capacity(27 + segments.len());
|
||||
header.extend_from_slice(b"OggS");
|
||||
header.push(0); // stream structure version
|
||||
header.push(if first { 0x02 } else if last { 0x04 } else { 0x00 });
|
||||
header.extend_from_slice(&granule.to_le_bytes());
|
||||
header.extend_from_slice(&self.serial.to_le_bytes());
|
||||
header.extend_from_slice(&self.sequence.to_le_bytes());
|
||||
header.extend_from_slice(&0u32.to_le_bytes()); // checksum, filled in below
|
||||
header.push(segments.len() as u8);
|
||||
header.extend_from_slice(&segments);
|
||||
|
||||
let mut page = header;
|
||||
page.extend_from_slice(&body);
|
||||
let crc = crc32(&page);
|
||||
page[22..26].copy_from_slice(&crc.to_le_bytes());
|
||||
|
||||
self.out.extend_from_slice(&page);
|
||||
self.sequence += 1;
|
||||
}
|
||||
|
||||
fn done(self) -> Vec<u8> {
|
||||
self.out
|
||||
}
|
||||
}
|
||||
|
||||
/// Ogg's own CRC32, which is not the common one.
|
||||
///
|
||||
/// The polynomial is the same 0x04C11DB7, but with no input or output
|
||||
/// reflection and a zero initial and final value - so a stock crc32 gives a
|
||||
/// number every decoder rejects. This is the one detail of the format most
|
||||
/// worth writing down, because a wrong checksum fails as "not an Ogg file"
|
||||
/// rather than as a checksum problem.
|
||||
fn crc32(data: &[u8]) -> u32 {
|
||||
let mut crc: u32 = 0;
|
||||
for &byte in data {
|
||||
crc ^= (byte as u32) << 24;
|
||||
for _ in 0..8 {
|
||||
crc = if crc & 0x8000_0000 != 0 { (crc << 1) ^ 0x04C1_1DB7 } else { crc << 1 };
|
||||
}
|
||||
}
|
||||
crc
|
||||
}
|
||||
|
||||
/// Reads a whole recording out of the capture format into what `encode` wants.
|
||||
///
|
||||
/// The capture path hands over interleaved stereo at 48kHz because that is
|
||||
/// what a call needs; a voice message is mono, so the two channels are averaged
|
||||
/// rather than one being thrown away - dropping a channel loses half the sound
|
||||
/// on a microphone that happens to be wired to the other one.
|
||||
pub fn to_mono(interleaved: &[f32], channels: u16) -> Vec<f32> {
|
||||
if channels <= 1 {
|
||||
return interleaved.to_vec();
|
||||
}
|
||||
interleaved
|
||||
.chunks(channels as usize)
|
||||
.map(|frame| frame.iter().sum::<f32>() / frame.len() as f32)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Writes the encoded recording somewhere it can be uploaded from.
|
||||
pub fn write_temp(bytes: &[u8]) -> Result<std::path::PathBuf> {
|
||||
let dir = std::env::temp_dir().join("moho-voice");
|
||||
std::fs::create_dir_all(&dir).with_context(|| format!("creating {}", dir.display()))?;
|
||||
let path = dir.join(format!("voice-message-{}.ogg", rand_serial()));
|
||||
std::fs::write(&path, bytes).with_context(|| format!("writing {}", path.display()))?;
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A second of a 440Hz tone, which is something a decoder can check the
|
||||
/// length of and a person can listen to if this ever needs debugging.
|
||||
fn tone(seconds: f64) -> Vec<f32> {
|
||||
let n = (RATE as f64 * seconds) as usize;
|
||||
(0..n).map(|i| (i as f32 * 440.0 * std::f32::consts::TAU / RATE as f32).sin() * 0.5).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_file_begins_like_an_ogg_opus_file() {
|
||||
let bytes = encode(&tone(0.2)).expect("encoding");
|
||||
assert_eq!(&bytes[0..4], b"OggS", "not an Ogg stream");
|
||||
// The identification header is the first packet of the first page.
|
||||
assert_eq!(&bytes[28..36], b"OpusHead");
|
||||
assert!(bytes.windows(8).any(|w| w == b"OpusTags"), "the comment header is required");
|
||||
}
|
||||
|
||||
/// The checksum is the detail most likely to be wrong and least likely to
|
||||
/// say so: a decoder reports a bad one as "not an Ogg file".
|
||||
#[test]
|
||||
fn the_checksum_is_oggs_own() {
|
||||
// Known answers, computed from the polynomial by a second
|
||||
// implementation written in Python rather than taken from this one -
|
||||
// a self-consistent checksum test proves only that the code agrees
|
||||
// with itself, which is exactly the failure being guarded against.
|
||||
assert_eq!(crc32(b"OggS"), 0x5fb0_a94f);
|
||||
assert_eq!(crc32(b"a"), 0xa864_db20);
|
||||
assert_eq!(crc32(&[]), 0);
|
||||
}
|
||||
|
||||
/// The test that matters for a container written by hand: hand it to a
|
||||
/// decoder nobody here wrote and see whether it agrees about what it is
|
||||
/// and how long it lasts.
|
||||
///
|
||||
/// Ignored by default because it shells out to ffprobe, which is not
|
||||
/// something a test suite should require:
|
||||
/// cargo test --release -- --ignored ffprobe
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn ffprobe_agrees_that_this_is_two_seconds_of_opus() {
|
||||
let bytes = encode(&tone(2.0)).expect("encoding");
|
||||
let path = write_temp(&bytes).expect("writing");
|
||||
let out = std::process::Command::new("ffprobe")
|
||||
.args(["-v", "error", "-show_entries", "format=format_name,duration:stream=codec_name,channels,sample_rate", "-of", "default=nw=1", "-i"])
|
||||
.arg(&path)
|
||||
.output()
|
||||
.expect("running ffprobe");
|
||||
let text = String::from_utf8_lossy(&out.stdout).to_string();
|
||||
let errors = String::from_utf8_lossy(&out.stderr).to_string();
|
||||
println!("{text}");
|
||||
assert!(out.status.success(), "ffprobe refused the file: {errors}");
|
||||
assert!(errors.is_empty(), "ffprobe complained: {errors}");
|
||||
assert!(text.contains("codec_name=opus"), "not opus: {text}");
|
||||
assert!(text.contains("format_name=ogg"), "not ogg: {text}");
|
||||
assert!(text.contains("channels=1"), "not mono: {text}");
|
||||
assert!(text.contains("sample_rate=48000"), "wrong rate: {text}");
|
||||
let duration: f64 = text
|
||||
.lines()
|
||||
.find_map(|l| l.strip_prefix("duration="))
|
||||
.and_then(|v| v.parse().ok())
|
||||
.expect("a duration");
|
||||
assert!((duration - 2.0).abs() < 0.05, "ffprobe reads {duration}s, not 2s");
|
||||
let _ = std::fs::remove_file(path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_recording_of_nothing_is_still_a_file() {
|
||||
let bytes = encode(&[]).expect("encoding silence");
|
||||
assert_eq!(&bytes[0..4], b"OggS");
|
||||
assert_eq!(duration_secs(&[]), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_length_is_what_was_recorded() {
|
||||
assert!((duration_secs(&tone(2.5)) - 2.5).abs() < 0.001);
|
||||
}
|
||||
|
||||
/// Peak per bucket, not mean: speech averaged over a fifth of a second is
|
||||
/// nearly silence, which is what makes a waveform look broken.
|
||||
#[test]
|
||||
fn the_waveform_follows_the_sound() {
|
||||
let mut samples = vec![0.0f32; 4800];
|
||||
samples[2400] = 1.0;
|
||||
let bars = waveform(&samples, 4);
|
||||
assert_eq!(bars.len(), 4);
|
||||
assert_eq!(bars[0], 0, "silence should read as silence");
|
||||
assert_eq!(bars[2], 255, "a peak should read as a peak");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_waveform_of_nothing_is_nothing() {
|
||||
assert!(waveform(&[], 256).is_empty());
|
||||
assert!(waveform(&[0.5], 0).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_channels_become_one_without_losing_a_side() {
|
||||
// Everything on the right channel: averaging keeps it, picking the
|
||||
// first channel would produce silence.
|
||||
let stereo = vec![0.0, 1.0, 0.0, 1.0];
|
||||
assert_eq!(to_mono(&stereo, 2), vec![0.5, 0.5]);
|
||||
assert_eq!(to_mono(&[0.25, 0.75], 1), vec![0.25, 0.75]);
|
||||
}
|
||||
}
|
||||
@@ -1040,6 +1040,80 @@ pub async fn dispatch(
|
||||
}
|
||||
}
|
||||
|
||||
// Recording somebody speaking, and sending it as the thing Discord
|
||||
// calls a voice message rather than as a sound file.
|
||||
//
|
||||
// Three calls rather than one because it is three moments: a person
|
||||
// starts talking, then decides whether what they said is worth
|
||||
// sending. The microphone is process-wide - there is one - so none of
|
||||
// these take an account.
|
||||
"startVoiceMessage" => {
|
||||
let Some(buffer_id) = p_str_opt(params, "bufferId") else {
|
||||
return (None, Some("startVoiceMessage requires \"bufferId\"".to_string()));
|
||||
};
|
||||
if state.runtime.get_buffer(buffer_id).is_none() {
|
||||
return (None, Some("no such buffer".to_string()));
|
||||
}
|
||||
// The device the calls settings already chose, so there is not a
|
||||
// second answer to "which microphone" to get wrong.
|
||||
let device = state.voice_prefs.get().input;
|
||||
match crate::voicenote::start(buffer_id, device.as_deref().filter(|d| !d.is_empty())) {
|
||||
Ok(()) => (Some(ok_node()), None),
|
||||
Err(e) => (None, Some(format!("{e:#}"))),
|
||||
}
|
||||
}
|
||||
|
||||
/// How long it has been running and whether the microphone is hearing
|
||||
/// anything - polled by the window rather than pushed, because it is
|
||||
/// only wanted while somebody is watching a timer.
|
||||
"voiceMessageProgress" => match crate::voicenote::progress() {
|
||||
None => (Some(serde_json::json!({ "recording": false })), None),
|
||||
Some(p) => (
|
||||
Some(serde_json::json!({
|
||||
"recording": true,
|
||||
"bufferId": p.buffer_id,
|
||||
"seconds": p.seconds,
|
||||
"level": p.level,
|
||||
})),
|
||||
None,
|
||||
),
|
||||
},
|
||||
|
||||
"cancelVoiceMessage" => (Some(serde_json::json!({ "wasRecording": crate::voicenote::cancel() })), None),
|
||||
|
||||
"sendVoiceMessage" => {
|
||||
let finished = match crate::voicenote::finish() {
|
||||
Ok(f) => f,
|
||||
Err(e) => return (None, Some(format!("{e:#}"))),
|
||||
};
|
||||
let Some(buffer) = state.runtime.get_buffer(&finished.buffer_id) else {
|
||||
let _ = std::fs::remove_file(&finished.path);
|
||||
return (None, Some("the conversation this was recorded for is gone".to_string()));
|
||||
};
|
||||
let Some(cfg) = state.accounts.get_discord(&buffer.account_id) else {
|
||||
let _ = std::fs::remove_file(&finished.path);
|
||||
return (None, Some("voice messages are a Discord thing for now".to_string()));
|
||||
};
|
||||
let result = backend::discord::send_voice_message(
|
||||
state,
|
||||
&finished.buffer_id,
|
||||
&cfg.token,
|
||||
&finished.path,
|
||||
finished.duration_secs,
|
||||
&finished.waveform,
|
||||
)
|
||||
.await;
|
||||
// Sent or not, the recording has served its purpose on disk.
|
||||
let _ = std::fs::remove_file(&finished.path);
|
||||
match result {
|
||||
Ok(()) => (
|
||||
Some(serde_json::json!({ "durationSecs": finished.duration_secs })),
|
||||
None,
|
||||
),
|
||||
Err(e) => (None, Some(format!("{e:#}"))),
|
||||
}
|
||||
}
|
||||
|
||||
"addToDiscordGroupDm" | "removeFromDiscordGroupDm" => {
|
||||
let (account_id, buffer_id, user_id) = match (
|
||||
p_str_opt(params, "accountId"),
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
//! Recording somebody speaking, so it can be sent as a voice message.
|
||||
//!
|
||||
//! Held here rather than in a backend because none of it is Discord's: a
|
||||
//! recording is a microphone, some samples and a length, and the only part
|
||||
//! that knows which service it is going to is the call that sends it.
|
||||
//!
|
||||
//! Process-wide, because there is one microphone. Two recordings at once is
|
||||
//! not a thing to support - it is a thing to refuse, clearly, rather than to
|
||||
//! half-handle by recording the same audio into two buffers.
|
||||
//!
|
||||
//! The capture path is the one calls already use, so a voice message is
|
||||
//! recorded through the same device choice, the same resampling and the same
|
||||
//! level meter as a call, and needs no second answer to "which microphone".
|
||||
|
||||
use crate::audio::{self, Capture, TARGET_CHANNELS};
|
||||
use crate::oggopus;
|
||||
use anyhow::{bail, Result};
|
||||
use std::sync::{Arc, Mutex, OnceLock};
|
||||
use std::time::Instant;
|
||||
|
||||
/// How long a recording may run before it stops itself.
|
||||
///
|
||||
/// Discord's own limit, and a backstop besides: a recording is held in memory
|
||||
/// until it is sent, and a forgotten one should not grow there all afternoon.
|
||||
/// Five minutes of 48kHz mono is about 57MB before encoding.
|
||||
const MAX_SECONDS: f64 = 5.0 * 60.0;
|
||||
|
||||
struct Recording {
|
||||
/// Dropping this stops the microphone, so it is held for the duration.
|
||||
_capture: Capture,
|
||||
samples: Arc<Mutex<Vec<f32>>>,
|
||||
/// Where it will be sent, so stopping needs no second answer.
|
||||
buffer_id: String,
|
||||
started: Instant,
|
||||
level: Arc<Mutex<f32>>,
|
||||
}
|
||||
|
||||
fn slot() -> &'static Mutex<Option<Recording>> {
|
||||
static SLOT: OnceLock<Mutex<Option<Recording>>> = OnceLock::new();
|
||||
SLOT.get_or_init(|| Mutex::new(None))
|
||||
}
|
||||
|
||||
/// Opens the microphone and starts collecting.
|
||||
///
|
||||
/// The captured audio is folded to mono as it arrives rather than at the end:
|
||||
/// it halves what is held, and a voice message is mono either way.
|
||||
pub fn start(buffer_id: &str, device_id: Option<&str>) -> Result<()> {
|
||||
let mut held = slot().lock().unwrap();
|
||||
if let Some(existing) = held.as_ref() {
|
||||
bail!("already recording a message for {}", existing.buffer_id);
|
||||
}
|
||||
let samples: Arc<Mutex<Vec<f32>>> = Arc::new(Mutex::new(Vec::new()));
|
||||
let sink_samples = samples.clone();
|
||||
let capture = audio::start_capture(device_id, move |pcm: &[f32]| {
|
||||
let mono = oggopus::to_mono(pcm, TARGET_CHANNELS);
|
||||
let mut held = sink_samples.lock().unwrap();
|
||||
// The cap is enforced where the samples arrive rather than by a timer:
|
||||
// it is the memory that matters, and this is the only place that knows
|
||||
// how much there is.
|
||||
let room = (MAX_SECONDS * oggopus::RATE as f64) as usize;
|
||||
if held.len() < room {
|
||||
let take = (room - held.len()).min(mono.len());
|
||||
held.extend_from_slice(&mono[..take]);
|
||||
}
|
||||
})?;
|
||||
let level = capture.level.clone();
|
||||
*held = Some(Recording {
|
||||
_capture: capture,
|
||||
samples,
|
||||
buffer_id: buffer_id.to_string(),
|
||||
started: Instant::now(),
|
||||
level,
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// What a recording in progress looks like from outside.
|
||||
pub struct Progress {
|
||||
pub buffer_id: String,
|
||||
pub seconds: f64,
|
||||
/// Peak level since the last look, so a window can show that the
|
||||
/// microphone is actually hearing something.
|
||||
pub level: f32,
|
||||
}
|
||||
|
||||
pub fn progress() -> Option<Progress> {
|
||||
let held = slot().lock().unwrap();
|
||||
held.as_ref().map(|recording| Progress {
|
||||
buffer_id: recording.buffer_id.clone(),
|
||||
seconds: recording.started.elapsed().as_secs_f64(),
|
||||
level: *recording.level.lock().unwrap(),
|
||||
})
|
||||
}
|
||||
|
||||
/// A finished recording, encoded and ready to be sent.
|
||||
pub struct Finished {
|
||||
pub buffer_id: String,
|
||||
/// The file itself, on disk, because that is what the upload paths take.
|
||||
pub path: std::path::PathBuf,
|
||||
pub duration_secs: f64,
|
||||
/// Discord's own encoding: one amplitude byte per bucket, base64'd.
|
||||
pub waveform: String,
|
||||
pub samples: usize,
|
||||
}
|
||||
|
||||
/// Stops the microphone and turns what was said into a file.
|
||||
///
|
||||
/// The length comes from the samples rather than from the clock: the two
|
||||
/// disagree by whatever the device buffered, and the samples are what a player
|
||||
/// will actually find in the file.
|
||||
pub fn finish() -> Result<Finished> {
|
||||
let recording = slot().lock().unwrap().take().ok_or_else(|| anyhow::anyhow!("nothing is being recorded"))?;
|
||||
let samples = std::mem::take(&mut *recording.samples.lock().unwrap());
|
||||
if samples.is_empty() {
|
||||
bail!("the microphone produced no audio");
|
||||
}
|
||||
let duration_secs = oggopus::duration_secs(&samples);
|
||||
let waveform = base64_of(&oggopus::waveform(&samples, 256));
|
||||
let bytes = oggopus::encode(&samples)?;
|
||||
let path = oggopus::write_temp(&bytes)?;
|
||||
Ok(Finished { buffer_id: recording.buffer_id, path, duration_secs, waveform, samples: samples.len() })
|
||||
}
|
||||
|
||||
/// Throws the recording away and closes the microphone.
|
||||
pub fn cancel() -> bool {
|
||||
slot().lock().unwrap().take().is_some()
|
||||
}
|
||||
|
||||
/// Base64, of the flavour every service means when it says base64.
|
||||
fn base64_of(bytes: &[u8]) -> String {
|
||||
use base64::Engine;
|
||||
base64::engine::general_purpose::STANDARD.encode(bytes)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Nothing may be finished that was never started - and saying so is the
|
||||
/// difference between an error a window can show and a panic.
|
||||
#[test]
|
||||
fn finishing_nothing_is_an_error_rather_than_a_panic() {
|
||||
// Whatever else has run, this test's precondition is an empty slot.
|
||||
cancel();
|
||||
assert!(finish().is_err());
|
||||
assert!(!cancel(), "cancelling nothing should say so");
|
||||
}
|
||||
|
||||
/// The waveform travels as base64 and has to survive the trip.
|
||||
#[test]
|
||||
fn the_waveform_encodes_the_way_the_service_reads_it() {
|
||||
assert_eq!(base64_of(&[0, 128, 255]), "AID/");
|
||||
assert_eq!(base64_of(&[]), "");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user