synops/spacetimedb/src/lib.rs
vegard ac8f8c508d Fullfører oppgave 16.7: Stemmeeffekter med robot og monster voice
Robotstemme: Ring-modulasjon via OscillatorNode som modulerer
GainNode.gain — gir metallisk, Dalek-aktig effekt. Justerbar
frekvens (30–300 Hz) og modulasjonsdybde (0–100%).

Monsterstemme: Egenutviklet AudioWorkletProcessor med phase vocoder
for sanntids pitch-shifting. Bruker overlap-add med 2048-sample FFT
og 4x overlap for ~42ms latens ved 48kHz. Pitch-faktor 0.5x–2.0x.

UI: Effektvelger-knapper (Robot/Monster) i FX-seksjon per kanal,
med fargekodede parametersliders som vises når effekten er aktiv.
On/off-state synkroniseres via STDB toggle_effect, parametere er
per-klient (ulike brukere kan ha forskjellige monitorinnstillinger).

STDB: Lagt til set_effect_param reducer for fremtidig param-synk
(krever spacetime CLI for publish — ikke deployet ennå).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 05:34:59 +00:00

713 lines
20 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Synops SpacetimeDB-modul
//
// Speiler PG-skjema (nodes + edges) som sanntids-cache.
// PG er autoritativ; SpacetimeDB er varm cache for frontend.
// Maskinrommet gjør warmup (PG → ST) ved oppstart og toveissynk deretter.
//
// Ref: docs/retninger/datalaget.md, docs/primitiver/nodes.md, docs/primitiver/edges.md
use spacetimedb::{reducer, Table, ReducerContext, Timestamp};
// =============================================================================
// Tabeller
// =============================================================================
/// Speiler PG nodes-tabellen. UUID-er representeres som String.
/// visibility og node_kind er strenger (PG-enums har ikke STDB-ekvivalent).
/// metadata er JSON-streng (PG JSONB).
#[spacetimedb::table(accessor = node, public)]
pub struct Node {
#[primary_key]
pub id: String,
pub node_kind: String,
pub title: String,
pub content: String,
pub visibility: String,
pub metadata: String,
pub created_at: Timestamp,
pub created_by: String,
}
/// Speiler PG node_access-tabellen (materialisert tilgangsmatrise).
/// subject_id = bruker/team, object_id = noden det gis tilgang til.
/// Brukes av frontend for visibility-filtrering.
#[spacetimedb::table(accessor = node_access, public)]
pub struct NodeAccess {
#[primary_key]
pub id: String, // "{subject_id}:{object_id}" — kompositt-nøkkel som streng
#[index(btree)]
pub subject_id: String,
#[index(btree)]
pub object_id: String,
pub access: String, // reader, member, admin, owner
pub via_edge: String,
}
/// Speiler PG edges-tabellen.
#[spacetimedb::table(accessor = edge, public)]
pub struct Edge {
#[primary_key]
pub id: String,
#[index(btree)]
pub source_id: String,
#[index(btree)]
pub target_id: String,
pub edge_type: String,
pub metadata: String,
pub system: bool,
pub created_at: Timestamp,
pub created_by: String,
}
// =============================================================================
// Livssyklus
// =============================================================================
#[reducer(init)]
pub fn init(_ctx: &ReducerContext) {
log::info!("Synops-modul initialisert");
}
#[reducer(client_connected)]
pub fn client_connected(ctx: &ReducerContext) {
log::info!("Klient tilkoblet: {}", ctx.sender().to_hex());
}
#[reducer(client_disconnected)]
pub fn client_disconnected(ctx: &ReducerContext) {
log::info!("Klient frakoblet: {}", ctx.sender().to_hex());
}
// =============================================================================
// Node CRUD
// =============================================================================
#[reducer]
pub fn create_node(
ctx: &ReducerContext,
id: String,
node_kind: String,
title: String,
content: String,
visibility: String,
metadata: String,
created_by: String,
) -> Result<(), String> {
if id.is_empty() {
return Err("id kan ikke være tom".into());
}
if ctx.db.node().id().find(&id).is_some() {
return Err(format!("Node med id {} finnes allerede", id));
}
ctx.db.node().insert(Node {
id,
node_kind,
title,
content,
visibility,
metadata,
created_at: ctx.timestamp,
created_by,
});
Ok(())
}
#[reducer]
pub fn update_node(
ctx: &ReducerContext,
id: String,
node_kind: String,
title: String,
content: String,
visibility: String,
metadata: String,
) -> Result<(), String> {
let existing = ctx.db.node().id().find(&id)
.ok_or_else(|| format!("Node {} ikke funnet", id))?;
ctx.db.node().id().update(Node {
node_kind,
title,
content,
visibility,
metadata,
..existing
});
Ok(())
}
#[reducer]
pub fn delete_node(ctx: &ReducerContext, id: String) -> Result<(), String> {
// Slett tilhørende edges først (speiler PG ON DELETE CASCADE)
let source_edges: Vec<_> = ctx.db.edge().source_id().filter(&id).collect();
for e in source_edges {
ctx.db.edge().id().delete(&e.id);
}
let target_edges: Vec<_> = ctx.db.edge().target_id().filter(&id).collect();
for e in target_edges {
ctx.db.edge().id().delete(&e.id);
}
ctx.db.node().id().delete(&id);
Ok(())
}
// =============================================================================
// NodeAccess CRUD
// =============================================================================
/// Upsert: opprett eller oppdater tilgang. id = "{subject_id}:{object_id}".
#[reducer]
pub fn upsert_node_access(
ctx: &ReducerContext,
subject_id: String,
object_id: String,
access: String,
via_edge: String,
) -> Result<(), String> {
let id = format!("{subject_id}:{object_id}");
if let Some(existing) = ctx.db.node_access().id().find(&id) {
ctx.db.node_access().id().update(NodeAccess {
access,
via_edge,
..existing
});
} else {
ctx.db.node_access().insert(NodeAccess {
id,
subject_id,
object_id,
access,
via_edge,
});
}
Ok(())
}
/// Slett en spesifikk tilgangsrad.
#[reducer]
pub fn delete_node_access(
ctx: &ReducerContext,
subject_id: String,
object_id: String,
) -> Result<(), String> {
let id = format!("{subject_id}:{object_id}");
ctx.db.node_access().id().delete(&id);
Ok(())
}
/// Slett all tilgang for et gitt subject (brukes ved fjerning av bruker/team).
#[reducer]
pub fn delete_node_access_for_subject(
ctx: &ReducerContext,
subject_id: String,
) -> Result<(), String> {
let entries: Vec<_> = ctx.db.node_access().subject_id().filter(&subject_id).collect();
for entry in entries {
ctx.db.node_access().id().delete(&entry.id);
}
Ok(())
}
// =============================================================================
// Edge CRUD
// =============================================================================
#[reducer]
pub fn create_edge(
ctx: &ReducerContext,
id: String,
source_id: String,
target_id: String,
edge_type: String,
metadata: String,
system: bool,
created_by: String,
) -> Result<(), String> {
if id.is_empty() {
return Err("id kan ikke være tom".into());
}
if ctx.db.edge().id().find(&id).is_some() {
return Err(format!("Edge med id {} finnes allerede", id));
}
ctx.db.edge().insert(Edge {
id,
source_id,
target_id,
edge_type,
metadata,
system,
created_at: ctx.timestamp,
created_by,
});
Ok(())
}
#[reducer]
pub fn update_edge(
ctx: &ReducerContext,
id: String,
edge_type: String,
metadata: String,
) -> Result<(), String> {
let existing = ctx.db.edge().id().find(&id)
.ok_or_else(|| format!("Edge {} ikke funnet", id))?;
ctx.db.edge().id().update(Edge {
edge_type,
metadata,
..existing
});
Ok(())
}
#[reducer]
pub fn delete_edge(ctx: &ReducerContext, id: String) -> Result<(), String> {
ctx.db.edge().id().delete(&id);
Ok(())
}
// =============================================================================
// Mixer-kanaler (delt mixer-kontroll via SpacetimeDB)
// =============================================================================
/// Delt mixer-state per kanal i et live-rom.
/// Transient — finnes bare mens rommet er aktivt.
/// Alle deltakere abonnerer og ser endringer i sanntid.
#[spacetimedb::table(accessor = mixer_channel, public)]
pub struct MixerChannel {
#[primary_key]
pub id: String, // "{room_id}:{target_user_id}"
#[index(btree)]
pub room_id: String,
#[index(btree)]
pub target_user_id: String,
pub gain: f64, // 0.01.5
pub is_muted: bool,
pub active_effects: String, // JSON: {"fat_bottom": true, "robot": false, ...}
pub role: String, // "editor" | "viewer" — tilgangskontroll
pub updated_by: String,
pub updated_at: Timestamp,
}
// =============================================================================
// Live-rom (sanntidslyd via LiveKit)
// =============================================================================
/// Aktive LiveKit-rom knyttet til kommunikasjonsnoder.
/// Transient — finnes bare mens rommet er aktivt.
#[spacetimedb::table(accessor = live_room, public)]
pub struct LiveRoom {
#[primary_key]
pub room_id: String, // "communication_{uuid}"
#[index(btree)]
pub communication_id: String,
pub is_active: bool,
pub started_at: Timestamp,
pub participant_count: u32,
}
/// Deltakere i aktive LiveKit-rom.
#[spacetimedb::table(accessor = room_participant, public)]
pub struct RoomParticipant {
#[primary_key]
pub id: String, // "{room_id}:{user_id}"
#[index(btree)]
pub room_id: String,
#[index(btree)]
pub user_id: String,
pub display_name: String,
pub role: String, // "publisher" | "subscriber"
pub joined_at: Timestamp,
}
#[reducer]
pub fn create_live_room(
ctx: &ReducerContext,
room_id: String,
communication_id: String,
) -> Result<(), String> {
if room_id.is_empty() {
return Err("room_id kan ikke være tom".into());
}
// Idempotent — hvis rommet allerede finnes, oppdater
if let Some(existing) = ctx.db.live_room().room_id().find(&room_id) {
ctx.db.live_room().room_id().update(LiveRoom {
is_active: true,
..existing
});
return Ok(());
}
ctx.db.live_room().insert(LiveRoom {
room_id,
communication_id,
is_active: true,
started_at: ctx.timestamp,
participant_count: 0,
});
Ok(())
}
#[reducer]
pub fn add_room_participant(
ctx: &ReducerContext,
room_id: String,
user_id: String,
display_name: String,
role: String,
) -> Result<(), String> {
let id = format!("{room_id}:{user_id}");
// Idempotent — oppdater hvis allerede finnes
if let Some(existing) = ctx.db.room_participant().id().find(&id) {
ctx.db.room_participant().id().update(RoomParticipant {
display_name,
role,
..existing
});
return Ok(());
}
ctx.db.room_participant().insert(RoomParticipant {
id,
room_id: room_id.clone(),
user_id,
display_name,
role,
joined_at: ctx.timestamp,
});
// Oppdater deltakertelling
if let Some(room) = ctx.db.live_room().room_id().find(&room_id) {
ctx.db.live_room().room_id().update(LiveRoom {
participant_count: room.participant_count + 1,
..room
});
}
Ok(())
}
#[reducer]
pub fn remove_room_participant(
ctx: &ReducerContext,
room_id: String,
user_id: String,
) -> Result<(), String> {
let id = format!("{room_id}:{user_id}");
ctx.db.room_participant().id().delete(&id);
// Oppdater deltakertelling
if let Some(room) = ctx.db.live_room().room_id().find(&room_id) {
let new_count = room.participant_count.saturating_sub(1);
ctx.db.live_room().room_id().update(LiveRoom {
participant_count: new_count,
..room
});
}
Ok(())
}
#[reducer]
pub fn close_live_room(
ctx: &ReducerContext,
room_id: String,
) -> Result<(), String> {
// Fjern alle deltakere
let participants: Vec<_> = ctx.db.room_participant().room_id().filter(&room_id).collect();
for p in participants {
ctx.db.room_participant().id().delete(&p.id);
}
// Fjern alle mixer-kanaler for dette rommet
let mixer_channels: Vec<_> = ctx.db.mixer_channel().room_id().filter(&room_id).collect();
for mc in mixer_channels {
ctx.db.mixer_channel().id().delete(&mc.id);
}
// Marker rommet som inaktivt
if let Some(room) = ctx.db.live_room().room_id().find(&room_id) {
ctx.db.live_room().room_id().update(LiveRoom {
is_active: false,
participant_count: 0,
..room
});
}
Ok(())
}
// =============================================================================
// Mixer-kontroll reducers
// =============================================================================
/// Opprett eller oppdater en mixer-kanal med standardverdier.
#[reducer]
pub fn create_mixer_channel(
ctx: &ReducerContext,
room_id: String,
target_user_id: String,
updated_by: String,
) -> Result<(), String> {
let id = format!("{room_id}:{target_user_id}");
// Idempotent — oppdater hvis allerede finnes
if let Some(existing) = ctx.db.mixer_channel().id().find(&id) {
ctx.db.mixer_channel().id().update(MixerChannel {
updated_by,
updated_at: ctx.timestamp,
..existing
});
return Ok(());
}
ctx.db.mixer_channel().insert(MixerChannel {
id,
room_id,
target_user_id,
gain: 1.0,
is_muted: false,
active_effects: "{}".to_string(),
role: "editor".to_string(),
updated_by,
updated_at: ctx.timestamp,
});
Ok(())
}
/// Sett gain (volum) for en mixer-kanal. Clampes til 0.01.5.
#[reducer]
pub fn set_gain(
ctx: &ReducerContext,
room_id: String,
target_user_id: String,
gain: f64,
updated_by: String,
) -> Result<(), String> {
let id = format!("{room_id}:{target_user_id}");
let clamped = gain.clamp(0.0, 1.5);
let existing = ctx.db.mixer_channel().id().find(&id)
.ok_or_else(|| format!("Mixer-kanal {} ikke funnet", id))?;
// Tilgangskontroll: viewer kan ikke endre
if existing.role == "viewer" && existing.target_user_id != updated_by {
return Err("Viewer kan ikke endre mixer-innstillinger".into());
}
ctx.db.mixer_channel().id().update(MixerChannel {
gain: clamped,
updated_by,
updated_at: ctx.timestamp,
..existing
});
Ok(())
}
/// Sett mute-status for en mixer-kanal.
#[reducer]
pub fn set_mute(
ctx: &ReducerContext,
room_id: String,
target_user_id: String,
is_muted: bool,
updated_by: String,
) -> Result<(), String> {
let id = format!("{room_id}:{target_user_id}");
let existing = ctx.db.mixer_channel().id().find(&id)
.ok_or_else(|| format!("Mixer-kanal {} ikke funnet", id))?;
if existing.role == "viewer" && existing.target_user_id != updated_by {
return Err("Viewer kan ikke endre mixer-innstillinger".into());
}
ctx.db.mixer_channel().id().update(MixerChannel {
is_muted,
updated_by,
updated_at: ctx.timestamp,
..existing
});
Ok(())
}
/// Toggle en effekt for en mixer-kanal. Effektnavnet slås av/på i active_effects JSON.
#[reducer]
pub fn toggle_effect(
ctx: &ReducerContext,
room_id: String,
target_user_id: String,
effect_name: String,
updated_by: String,
) -> Result<(), String> {
let id = format!("{room_id}:{target_user_id}");
let existing = ctx.db.mixer_channel().id().find(&id)
.ok_or_else(|| format!("Mixer-kanal {} ikke funnet", id))?;
if existing.role == "viewer" && existing.target_user_id != updated_by {
return Err("Viewer kan ikke endre mixer-innstillinger".into());
}
// Parse, toggle, serialize tilbake
// Enkel JSON-håndtering uten serde (STDB-moduler er lette)
let mut effects = existing.active_effects.clone();
if effects.contains(&format!("\"{}\":true", effect_name)) {
effects = effects.replace(
&format!("\"{}\":true", effect_name),
&format!("\"{}\":false", effect_name),
);
} else if effects.contains(&format!("\"{}\":false", effect_name)) {
effects = effects.replace(
&format!("\"{}\":false", effect_name),
&format!("\"{}\":true", effect_name),
);
} else {
// Effekten finnes ikke — legg til som aktiv
if effects == "{}" {
effects = format!("{{\"{}\":true}}", effect_name);
} else {
// Sett inn før siste }
effects = effects.trim_end_matches('}').to_string()
+ &format!(",\"{}\":true}}", effect_name);
}
}
ctx.db.mixer_channel().id().update(MixerChannel {
active_effects: effects,
updated_by,
updated_at: ctx.timestamp,
..existing
});
Ok(())
}
/// Sett en numerisk effektparameter i active_effects JSON.
/// Brukes for stemmeeffekter (robot_freq, robot_depth, monster_pitch) som har
/// parameterverdier, ikke bare av/på. Nøkkelen settes til den gitte verdien.
#[reducer]
pub fn set_effect_param(
ctx: &ReducerContext,
room_id: String,
target_user_id: String,
param_name: String,
value: f64,
updated_by: String,
) -> Result<(), String> {
let id = format!("{room_id}:{target_user_id}");
let existing = ctx.db.mixer_channel().id().find(&id)
.ok_or_else(|| format!("Mixer-kanal {} ikke funnet", id))?;
if existing.role == "viewer" && existing.target_user_id != updated_by {
return Err("Viewer kan ikke endre mixer-innstillinger".into());
}
let mut effects = existing.active_effects.clone();
let param_pattern = format!("\"{}\":", param_name);
if effects.contains(&param_pattern) {
// Replace existing value — find the key and replace up to next comma or }
if let Some(start) = effects.find(&param_pattern) {
let value_start = start + param_pattern.len();
// Find end of value (next comma or closing brace)
let rest = &effects[value_start..];
let value_end = rest.find(',').unwrap_or_else(|| rest.find('}').unwrap_or(rest.len()));
effects = format!(
"{}{}{}",
&effects[..value_start],
value,
&effects[value_start + value_end..]
);
}
} else {
// Add new param
if effects == "{}" {
effects = format!("{{\"{}\":{}}}", param_name, value);
} else {
effects = effects.trim_end_matches('}').to_string()
+ &format!(",\"{}\":{}}}", param_name, value);
}
}
ctx.db.mixer_channel().id().update(MixerChannel {
active_effects: effects,
updated_by,
updated_at: ctx.timestamp,
..existing
});
Ok(())
}
/// Slett en mixer-kanal (når deltaker forlater rommet).
#[reducer]
pub fn delete_mixer_channel(
ctx: &ReducerContext,
room_id: String,
target_user_id: String,
) -> Result<(), String> {
let id = format!("{room_id}:{target_user_id}");
ctx.db.mixer_channel().id().delete(&id);
Ok(())
}
/// Sett rolle (editor/viewer) for en mixer-kanal.
/// Bare eier/admin kan endre rolle.
#[reducer]
pub fn set_mixer_role(
ctx: &ReducerContext,
room_id: String,
target_user_id: String,
role: String,
_updated_by: String,
) -> Result<(), String> {
if role != "editor" && role != "viewer" {
return Err(format!("Ugyldig rolle: {}. Bruk 'editor' eller 'viewer'", role));
}
let id = format!("{room_id}:{target_user_id}");
let existing = ctx.db.mixer_channel().id().find(&id)
.ok_or_else(|| format!("Mixer-kanal {} ikke funnet", id))?;
ctx.db.mixer_channel().id().update(MixerChannel {
role,
updated_at: ctx.timestamp,
..existing
});
Ok(())
}
// =============================================================================
// Warmup/vedlikehold
// =============================================================================
/// Tøm alle noder, edges, node_access og transiente data (brukes ved restart/warmup)
#[reducer]
pub fn clear_all(ctx: &ReducerContext) -> Result<(), String> {
let all_access: Vec<_> = ctx.db.node_access().iter().collect();
for a in all_access {
ctx.db.node_access().id().delete(&a.id);
}
let all_edges: Vec<_> = ctx.db.edge().iter().collect();
for e in all_edges {
ctx.db.edge().id().delete(&e.id);
}
let all_nodes: Vec<_> = ctx.db.node().iter().collect();
for n in all_nodes {
ctx.db.node().id().delete(&n.id);
}
// Rydd opp transiente data
let all_mixer: Vec<_> = ctx.db.mixer_channel().iter().collect();
for m in all_mixer {
ctx.db.mixer_channel().id().delete(&m.id);
}
log::info!("Alle noder, edges, node_access og mixer_channels slettet (clear_all)");
Ok(())
}