diff --git a/Cargo.lock b/Cargo.lock index ae593da8..1b03558c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4117,8 +4117,6 @@ checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" [[package]] name = "kira" version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "065632c16b39f4c00bc5514de0698474d1b6c36120743a154c78ca27ffc1ffe1" dependencies = [ "atomic-arena", "glam 0.33.2", diff --git a/Cargo.toml b/Cargo.toml index cd531bfb..6269cd90 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -58,6 +58,9 @@ sha1 = "0.10.6" # authority. `third_party/lua-src/BENILLA.md` has the current state and the verifying command. [patch.crates-io] lua-src = { path = "third_party/lua-src" } +# kira 0.12.1 with one patch: spatial gains and the volume tween evaluated per CHUNK and +# interpolated, not per frame (decision 1945; `third_party/kira/BENILLA.md`). +kira = { path = "third_party/kira" } # Optimize *dependencies* even in dev builds. avian/parry collision math (and wgpu/bevy render) is # unusably slow at opt-level 0 — a plain `cargo run` crawls to <1 fps once physics is doing real work. diff --git a/crates/benilla-app/assets/ui/OptionsFrame.xml b/crates/benilla-app/assets/ui/OptionsFrame.xml index 9ee7e028..2de6e879 100644 --- a/crates/benilla-app/assets/ui/OptionsFrame.xml +++ b/crates/benilla-app/assets/ui/OptionsFrame.xml @@ -209,6 +209,17 @@ RegisterForSave("LOCK_ACTIONBAR") ALWAYS_SHOW_MULTIBARS = "0" RegisterForSave("ALWAYS_SHOW_MULTIBARS") + -- The quest pair's two flags, likewise UIOptionsFrame_Init's (stock l.110-111, l.122-123), + -- here since the quest windows went stock (1944). QUEST_FADING_DISABLE is the reference's + -- "0". AUTO_QUEST_WATCH is the string "1" ON PURPOSE (decision 1136): the reference assigns + -- the NUMBER 1 while every consumer — stock QuestLogFrame.lua:64/68 and the checkbox's own + -- load — compares against the string, so on a fresh 1.12 install the row paints unchecked + -- and the auto-watch is dead until the box is clicked; the string ships the advertised + -- default as the real one. A recorded deviation (1804's rule), not a quiet one. + QUEST_FADING_DISABLE = "0" + RegisterForSave("QUEST_FADING_DISABLE") + AUTO_QUEST_WATCH = "1" + RegisterForSave("AUTO_QUEST_WATCH") UIOptionsFrameCheckButtons = { }; ------------------- Basic Options ------------------- diff --git a/crates/benilla-app/assets/ui/QuestFrame.xml b/crates/benilla-app/assets/ui/QuestFrame.xml deleted file mode 100644 index 26e5d700..00000000 --- a/crates/benilla-app/assets/ui/QuestFrame.xml +++ /dev/null @@ -1,1366 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - BenillaQuestGreetingPanel_OnShow() - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - QuestMoneyFrame_OnLoad() - - - - - - - - - BenillaQuestDetailPanel_OnShow() - BenillaQuestDetailPanel_OnUpdate(this, arg1) - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - SmallMoneyFrame_OnLoad(); MoneyFrame_SetType("STATIC"); - - - - - - - - - - - - - - - BenillaQuestProgressPanel_OnShow() - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - QuestMoneyFrame_OnLoad() - - - - - - - - - - - - - - - - - - BenillaQuestRewardPanel_OnShow() - - - - QuestFrame_OnLoad() - QuestFrame_OnEvent(event) - BenillaQuestFrame_OnUpdate() - - PlaySound("igQuestListOpen") - - getglobal("QuestFrameGreetingPanel"):Hide() - getglobal("QuestFrameDetailPanel"):Hide() - getglobal("QuestFrameProgressPanel"):Hide() - getglobal("QuestFrameRewardPanel"):Hide() - CloseQuest() - PlaySound("igQuestListClose") - - - - diff --git a/crates/benilla-app/assets/ui/QuestLogFrame.xml b/crates/benilla-app/assets/ui/QuestLogFrame.xml deleted file mode 100644 index 2082fcc8..00000000 --- a/crates/benilla-app/assets/ui/QuestLogFrame.xml +++ /dev/null @@ -1,1999 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - getglobal("QuestLogTrackTracking"):SetVertexColor(1.0, 0, 0) - - - GameTooltip:SetOwner(self, "ANCHOR_RIGHT") - GameTooltip:SetText("Shift-click a quest to add or remove a quest from your quest watch list.", nil, nil, nil, nil, 1) - - - GameTooltip:Hide() - - - - - - - - - - - - - - - - - - - - - - - FauxScrollFrame_OnVerticalScroll(QUESTLOG_QUEST_HEIGHT, BenillaQuestLogList_Update) - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - QuestMoneyFrame_OnLoad() - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - BenillaQuestLogFrame_OnLoad(self) - BenillaQuestLogFrame_OnEvent(self) - - QuestLog_OnUpdate(arg1) - - BenillaQuestLogFrame_OnMouseWheel(arg1) - - if UpdateMicroButtons then UpdateMicroButtons() end PlaySound("igQuestLogOpen") BenillaQuestLogFrame_Update() - if UpdateMicroButtons then UpdateMicroButtons() end PlaySound("igQuestLogClose") - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - BenillaQuestWatchFrame_OnLoad(self) - BenillaQuestWatchFrame_OnEvent(self) - BenillaQuestWatchFrame_OnUpdate(self, arg1) - - - diff --git a/crates/benilla-app/assets/ui/QuestShareFrame.xml b/crates/benilla-app/assets/ui/QuestShareFrame.xml index 2000a050..cc510130 100644 --- a/crates/benilla-app/assets/ui/QuestShareFrame.xml +++ b/crates/benilla-app/assets/ui/QuestShareFrame.xml @@ -6,7 +6,7 @@ registration and the one-line arm) — and it fixes the dialog key, the field names and the one verb by name, so those are reproduced exactly. - WHY THIS IS ITS OWN FILE, next to QuestFrame.xml and QuestLogFrame.xml rather than inside + WHY THIS IS ITS OWN FILE, next to the (stock, since 1944) QuestFrame.xml and QuestLogFrame.xml rather than inside either. It belongs to neither window. The confirm has no window at all — it is raised by `crate::ui_quest_share` off `SMSG_QUEST_CONFIRM_ACCEPT`, exactly as DuelFrame.xml's two dialogs are raised off the duel packets, and answering it sends a packet neither quest diff --git a/crates/benilla-app/assets/ui/UiPanels.xml b/crates/benilla-app/assets/ui/UiPanels.xml index 32e4f5d0..bbdb08ee 100644 --- a/crates/benilla-app/assets/ui/UiPanels.xml +++ b/crates/benilla-app/assets/ui/UiPanels.xml @@ -2000,6 +2000,40 @@ hideOnEscape = 1, } + -- The quest log's abandon confirm and its with-items twin — ref StaticPopup.lua l.749-775, + -- in the reference's own home, which is this file (the same move RENAME_GUILD made). They + -- lived in our QuestLogFrame.xml until 1944 took that window stock; stock's Abandon button + -- calls `StaticPopup_Show("ABANDON_QUEST", GetAbandonQuestName())` and registers nothing. + -- The wording is the GlobalStrings key by name, as this file spells its dialogs (the + -- reference wraps the same globals in TEXT()); the with-items twin is declared as the + -- reference declares it, whatever `GetAbandonQuestItems()` answers. + StaticPopupDialogs["ABANDON_QUEST"] = { + text = ABANDON_QUEST_CONFIRM, + button1 = YES, + button2 = NO, + OnAccept = function() + AbandonQuest() + PlaySound("igQuestLogAbandonQuest") + end, + timeout = 0, + whileDead = 1, + exclusive = 1, + hideOnEscape = 1, + } + StaticPopupDialogs["ABANDON_QUEST_WITH_ITEMS"] = { + text = ABANDON_QUEST_CONFIRM_WITH_ITEMS, + button1 = YES, + button2 = NO, + OnAccept = function() + AbandonQuest() + PlaySound("igQuestLogAbandonQuest") + end, + timeout = 0, + whileDead = 1, + exclusive = 1, + hideOnEscape = 1, + } + -- ref UIParent.lua l.324-343 + l.356-360, the four arms on one driver. Each equip arm -- hides the OTHER dialog first — the two questions are mutually exclusive by construction -- (one item, one destination), and the hide is what cancels the record the new question diff --git a/crates/benilla-app/assets/ui/benilla.toc b/crates/benilla-app/assets/ui/benilla.toc index 57244593..6f087c4c 100644 --- a/crates/benilla-app/assets/ui/benilla.toc +++ b/crates/benilla-app/assets/ui/benilla.toc @@ -976,10 +976,16 @@ CraftFrame.xml # every verb the stock file calls at runtime — not just at load — was checked by hand against # the bindings before this line changed. Interface\FrameXML\LootFrame.xml -# The questgiver window (decision 0088): four sub-panels over the Era quest API. Loaded after -# MerchantFrame (grouping; the BenillaMoney coin helpers now live in MoneyFrame.xml) and GossipFrame (shared parchment -# art conventions); its title/panel-button labels use the auto-centred ButtonText slot. -QuestFrame.xml +# ══ THE QUESTGIVER WINDOW — the reference's own, off the player's chain (1751, 1944) ══ +# Stock QuestFrame.xml sources QuestFrame.lua and INCLUDES QuestFrameTemplates.xml (the panel, +# item and title-button templates the quest log below inherits too), so one row brings the +# three-file unit 1913 measured. Its `QuestFrameItems_Update` paints all three windows' reward +# grids — the shared function that made the quest log un-migratable alone until the money went +# stock (1937). Needs, all above: Fonts (QuestFont/QuestTitleFont), MoneyFrame (the two +# MoneyFrameTemplate frames, resolved at LOAD), UiPanels, UIPanelTemplates. Its two saved-variable +# flags (QUEST_FADING_DISABLE, AUTO_QUEST_WATCH) are declared where the reference declares them — +# the options window's init (OptionsFrame.xml, far above). +Interface\FrameXML\QuestFrame.xml # The two guild-charter windows (decision 1672): the guild registrar — the window the "How do I # form a guild?" gossip row opens — and the charter itself, the sheet nine people sign. **Both are # the reference's own files off the player's chain now** (1751, windows 12 and 13). Loaded here, @@ -1002,11 +1008,13 @@ Interface\FrameXML\GuildRegistrarFrame.xml # close/action buttons `inherits=` at LOAD), and the QuestGreeting parchment family it shares with # GuildRegistrarFrame directly above. Interface\FrameXML\PetitionFrame.xml -# The quest log window (decision 0088 arc, the quest-log slice): durable player state, not an -# NPC session — loaded after MerchantFrame (grouping; the coin helpers are in MoneyFrame.xml now) and -# alongside QuestFrame (same arc); the 'L' binding (ui_script/input.rs) calls its -# ToggleQuestLog(). -QuestLogFrame.xml +# ══ THE QUEST LOG — the reference's own, off the player's chain (1751, 1944) ══ +# Stock QuestLogFrame.xml sources QuestLogFrame.lua; the 'L' binding calls its ToggleQuestLog(), +# and its reward hovers are GameTooltip:SetQuestLogItem / SetQuestLogRewardSpell (engine, 1944). +# After QuestFrame.xml (QuestItemTemplate, QuestMoneyFrame_OnLoad, QuestFrameItems_Update), +# MicroMenu.xml (QuestLog_OnShow calls UpdateMicroButtons unguarded) and the same kit as the +# giver; the reference's toc has the pair at l.53-54. +Interface\FrameXML\QuestLogFrame.xml # The timed-quest countdown window under the minimap (decision 1150): the reference's own # file split. After MinimapCluster (its anchor), UIParent (the manage pass that re-seats it, # and SecondsToTime), GameTooltip (its row hover), and QuestLogFrame — whose window a row diff --git a/crates/benilla-app/src/capture/probes/live_fps.rs b/crates/benilla-app/src/capture/probes/live_fps.rs index 3020335f..a066fd3b 100644 --- a/crates/benilla-app/src/capture/probes/live_fps.rs +++ b/crates/benilla-app/src/capture/probes/live_fps.rs @@ -52,6 +52,11 @@ impl Plugin for LiveFpsPlugin { samples: Vec::new(), cpu_samples: Vec::new(), cpu_prev: None, + churn_samples: Vec::new(), + entities_prev: 0, + decodes_prev: 0, + load_samples: Vec::new(), + paced_samples: Vec::new(), cpu_at_start: None, sys_at_start: None, occluded_now: false, @@ -87,6 +92,22 @@ struct LiveFps { /// frame was CPU work or a wait is the first question about a stutter. cpu_samples: Vec, cpu_prev: Option, + /// Per frame, beside `cpu_samples`: entities added net of removed, mesh assets added, + /// model materials added — the three spike signatures a crowd produces (a spell kit + /// spawning, a body dressing, a first-seen material realizing). Printed for the worst + /// frames only. + churn_samples: Vec<(i32, u32, u32, u32)>, + entities_prev: usize, + decodes_prev: u32, + /// Live particles and the GPU meter's freshest reading per frame — the two "same work, + /// more of it" signatures the churn triple cannot carry. + load_samples: Vec<(u32, f32)>, + /// The PACED clock's delta per frame (`Time`, `frame_pace`): under vsync with + /// pipelined rendering the raw delta is mis-split into long/short pairs that sum to exact + /// refresh multiples, so `samples`' tail counts presentation jitter as frames. The pacer + /// snaps a cadenced delta to whole refreshes; a paced delta of two periods is a frame the + /// display actually skipped. + paced_samples: Vec, /// Process CPU seconds at the first sampled frame ([`crate::perf::process_cpu_secs`]) — the /// baseline for the window's `cpu_ms`/`cpu_pct`. cpu_at_start: Option, @@ -144,6 +165,10 @@ type ScreenParams<'w, 's> = ( ), With, >, + // Asset adds per frame, for the worst-frame annotation (`LiveFps::churn_samples`). + MessageReader<'w, 's, AssetEvent>, + MessageReader<'w, 's, AssetEvent>, + Res<'w, Time>, ); #[derive(SystemParam)] @@ -196,6 +221,18 @@ fn drive_live_fps( &screen.5, &screen.6, ); + let (mesh_events, mat_events) = (&mut screen.7, &mut screen.8); + let paced_ms = screen.9.delta_secs() * 1000.0; + // Read every frame (a reader that only reads inside the window would report the whole + // backlog on its first sampled frame). + let mesh_added = mesh_events + .read() + .filter(|e| matches!(e, AssetEvent::Added { .. })) + .count() as u32; + let mat_added = mat_events + .read() + .filter(|e| matches!(e, AssetEvent::Added { .. })) + .count() as u32; // Drain every frame so the state is current whichever phase we're in — the window can be // occluded before sampling ever starts (a detached launch spawns behind whatever is open). for o in occlusions.read() { @@ -276,12 +313,31 @@ fn drive_live_fps( }; probe.cpu_prev = cpu_now; probe.cpu_samples.push(cpu_ms); + let n_entities = entities.iter().count(); + let d_entities = if probe.churn_samples.is_empty() { + 0 + } else { + n_entities as i32 - probe.entities_prev as i32 + }; + probe.entities_prev = n_entities; + let decodes = crate::sound::kit_decodes(); + let d_decodes = decodes.saturating_sub(probe.decodes_prev); + probe.decodes_prev = decodes; + probe + .churn_samples + .push((d_entities, mesh_added, mat_added, d_decodes)); + let mut gpu_now = 0.0; if let Some(gpu) = gpu { let ns = gpu.0.load(std::sync::atomic::Ordering::Relaxed); if ns > 0 { - gpu_samples.push(ns as f32 / 1.0e6); + gpu_now = ns as f32 / 1.0e6; + gpu_samples.push(gpu_now); } } + probe + .load_samples + .push((census.live_particles() as u32, gpu_now)); + probe.paced_samples.push(paced_ms); if probe.samples.len() < probe.frames { return; } @@ -382,14 +438,25 @@ fn drive_live_fps( c.sort_by(f32::total_cmp); let cat = |q: f32| c[(((c.len() - 1) as f32) * q).round() as usize]; let over = probe.samples.iter().filter(|&&f| f > 16.9).count(); + // Real skipped frames: paced deltas of two refreshes or more. + let period = 1000.0 / 60.0; + let dropped = probe + .paced_samples + .iter() + .filter(|&&f| f > 1.5 * period) + .count(); + let paced_max = probe.paced_samples.iter().copied().fold(0.0f32, f32::max); let mut worst: Vec = (0..probe.samples.len()).collect(); worst.sort_by(|&a, &b| probe.samples[b].total_cmp(&probe.samples[a])); let worst = worst .iter() .take(6) .map(|&i| { + let (de, dm, dmat, dsnd) = + probe.churn_samples.get(i).copied().unwrap_or((0, 0, 0, 0)); + let (np, g) = probe.load_samples.get(i).copied().unwrap_or((0, 0.0)); format!( - "{i}:{:.1}/{:.1}", + "{i}:{:.1}/{:.1}(e{de:+},m+{dm},mat+{dmat},snd+{dsnd},p{np},g{g:.1})", probe.samples[i], probe.cpu_samples.get(i).copied().unwrap_or(0.0) ) @@ -398,7 +465,7 @@ fn drive_live_fps( .join(","); format!( " cpu_ms={per_frame_ms:.2} cpu_pct={:.0} cpu_p95={:.2} cpu_p99={:.2} \ - frames_over={over} worst=[{worst}]", + frames_over={over} dropped={dropped} paced_max={paced_max:.1} worst=[{worst}]", per_frame_ms / mean as f64 * 100.0, cat(0.95), cat(0.99), @@ -590,8 +657,21 @@ fn print_vis_census(seen: &benilla_world::world_census::CensusReport) { .map(|(name, vis, gated)| format!("{name}={vis}/gated{gated}")) .collect::>() .join(" "); + let resident = seen + .kinds + .iter() + .zip(seen.resident.iter()) + .map(|((name, _, _), n)| format!("{name}={n}")) + .collect::>() + .join(" "); + let why = seen + .why + .iter() + .map(|(w, n)| format!("{w}={n}")) + .collect::>() + .join(" "); println!( - "VIS_CENSUS visible-submeshes {line} | tagged={} hidden={} exempt={} no_aabb={}", + "VIS_CENSUS visible-submeshes {line} | resident {resident} | why {why} | tagged={} hidden={} exempt={} no_aabb={}", seen.tagged, seen.hidden, seen.exempt, seen.no_aabb ); // The escapees always print: a tagged, bounded object the cull left un-hidden is a defect by diff --git a/crates/benilla-app/src/go_templates.rs b/crates/benilla-app/src/go_templates.rs index d44abbc1..16656e9d 100644 --- a/crates/benilla-app/src/go_templates.rs +++ b/crates/benilla-app/src/go_templates.rs @@ -56,6 +56,11 @@ pub(crate) struct GoTemplate { /// `Some` with a nonzero `page_id` is what makes a right-click *read* it; a type-9 template /// with no page (vanilla ships a handful) opens nothing at all, exactly like the reference. pub(crate) text_page: Option, + /// The `PageTextMaterial.dbc` id `GetQuestBackgroundMaterial` answers for a quest sourced from + /// this object — by `GAMEOBJECT_TYPE_ID`: QUESTGIVER (2) and GOOBER (9) read `data[2]`, CHEST + /// (10) reads `data[9]`, every other type answers none (`0x5f5950`, wow-re + /// quest-material-reward-spell-bindings.md §1). + pub(crate) quest_material: Option, } /// A MO_TRANSPORT template's path tuple (`gameobject_template.data0..2`, decision 0438). @@ -134,6 +139,11 @@ impl GameObjectTemplates { page_id: data[0].max(0) as u32, material: data[2].max(0) as u32, }); + let quest_material = match type_id { + 2 | 9 => Some(data[2].max(0) as u32), + 10 => Some(data[9].max(0) as u32), + _ => None, + }; self.templates.insert( entry, GoTemplate { @@ -143,6 +153,7 @@ impl GameObjectTemplates { meeting_stone_area, mo_transport, text_page, + quest_material, }, ); } @@ -183,6 +194,29 @@ mod tests { assert_eq!(go_lock_slot(19), None); // MAILBOX — no lock (opens by USE) } + /// The quest-giver material arm — by type: QUESTGIVER (2) and GOOBER (9) read `data[2]`, + /// CHEST (10) reads `data[9]`, anything else none (`0x5f5950`, wow-re + /// quest-material-reward-spell-bindings.md §1). + #[test] + fn insert_captures_the_quest_material_by_type() { + let mut t = GameObjectTemplates::default(); + let mut data = [0i32; 24]; + data[2] = 2; + data[9] = 3; + t.insert(100, 2, "Wanted Poster".into(), &data); + t.insert(101, 9, "Book".into(), &data); + t.insert(102, 10, "Chest".into(), &data); + t.insert(103, 15, "Boat".into(), &data); + assert_eq!(t.templates[&100].quest_material, Some(2)); + assert_eq!(t.templates[&101].quest_material, Some(2)); + assert_eq!( + t.templates[&102].quest_material, + Some(3), + "a chest reads data[9]" + ); + assert_eq!(t.templates[&103].quest_material, None); + } + #[test] fn insert_captures_mo_transport_tuple() { let mut t = GameObjectTemplates::default(); diff --git a/crates/benilla-app/src/lib.rs b/crates/benilla-app/src/lib.rs index d33a95dc..07659941 100644 --- a/crates/benilla-app/src/lib.rs +++ b/crates/benilla-app/src/lib.rs @@ -791,11 +791,11 @@ pub fn run(build: BuildId) -> AppExit { .add_plugins(UiLootPlugin) .add_plugins(UiLootRollPlugin) // The questgiver window (decision 0088): fills from the net drain's QuestGiver and drives - // QuestFrame.xml's four sub-panels over the Era quest API. + // the stock questgiver window's four sub-panels over the Era quest API (1944). .add_plugins(UiQuestPlugin) // The quest-log window (decision 0088's deferred second slice): fills from the self player's // PLAYER_QUEST_LOG descriptor slots + the SMSG_QUEST_QUERY_RESPONSE template cache, and drives - // QuestLogFrame.xml over the Era quest-log API. + // the stock quest log over the Era quest-log API (1944). .add_plugins(UiQuestLogPlugin) // The party quest-share (decision 1733): the verdict lines on a quest we pushed, and the // escort-quest confirm. Neither is bound to a window, so it is its own plugin rather than a diff --git a/crates/benilla-app/src/sound/kit.rs b/crates/benilla-app/src/sound/kit.rs index 987acbeb..31d8b19e 100644 --- a/crates/benilla-app/src/sound/kit.rs +++ b/crates/benilla-app/src/sound/kit.rs @@ -1052,10 +1052,16 @@ impl SoundKits { .with_context(|| format!("reading {path}"))?; let data = mixer::sfx_from_bytes(bytes)?; self.cache.insert(key, data.clone()); + DECODES.fetch_add(1, std::sync::atomic::Ordering::Relaxed); Ok(data) } } +/// First-play decodes so far — a chain read plus a decode on the main thread, once per +/// distinct kit file per session. `FPS_PROBE` reads it per frame to annotate a tail frame +/// (a raid's buff wave meets dozens of distinct spell sounds in its first minute). +pub(crate) static DECODES: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0); + /// Startup: load the kit catalog off the chain (absent → no resource; every play site tolerates /// that, the same optional-catalog rule as `Creatures`). pub(super) fn load_sound_kits(mut commands: Commands, assets: Option>) { diff --git a/crates/benilla-app/src/sound/mod.rs b/crates/benilla-app/src/sound/mod.rs index e8a38b07..9a2a18ea 100644 --- a/crates/benilla-app/src/sound/mod.rs +++ b/crates/benilla-app/src/sound/mod.rs @@ -50,6 +50,10 @@ mod water; mod weather; mod zone; pub(crate) use emote::EmoteSounds; +/// First-play kit decodes so far (`kit::DECODES`) — the probe's tail annotation. +pub(crate) fn kit_decodes() -> u32 { + kit::DECODES.load(std::sync::atomic::Ordering::Relaxed) +} pub(crate) use glue::GlueSound; pub(crate) use greeting::NpcGreetingRequest; pub(crate) use message::MessageSounds; diff --git a/crates/benilla-app/src/ui_item_text.rs b/crates/benilla-app/src/ui_item_text.rs index 28db88d0..bf54a382 100644 --- a/crates/benilla-app/src/ui_item_text.rs +++ b/crates/benilla-app/src/ui_item_text.rs @@ -207,6 +207,33 @@ struct Readable { page_head: u32, } +/// The material a quest sourced from `guid` paints on — `GetQuestBackgroundMaterial 0x502230`, +/// whose body is `ItemTextGetMaterial`'s: the source object by guid, an item's template +/// `PageMaterial` or a GameObject's template data by type, the id named through +/// `PageTextMaterial.dbc`; a creature, an uncached template or an id with no row is `None`, which +/// is FrameXML's "Parchment". A GameObject whose template is not cached yet is asked for. +pub(crate) fn object_material( + guid: u64, + items: &mut Items, + go_templates: &mut GameObjectTemplates, + materials: Option<&PageMaterials>, + commands: &NetCommands, +) -> Option { + let name = |id: u32| materials.and_then(|m| m.0.name(id)).map(str::to_string); + if benilla_protocol::guid::is_gameobject(guid) { + return match go_templates.get(guid) { + Some(go) => go.quest_material.and_then(name), + None => { + go_templates.request(guid, commands); + None + } + }; + } + let entry = items.object(guid)?.object_entry()?; + let t = items.template(entry, guid, commands)?; + name(t.page_material) +} + /// Resolve the object's title + material + page head, whether it is an item or a GameObject. The /// reference does exactly this — one guid lookup with typemask 1, then virtual getters — so a /// single resolve serves both sources. @@ -498,3 +525,78 @@ mod tests { assert!(matches!(&sess.source, ReadSource::Pages { visited } if visited.is_empty())); } } + +#[cfg(test)] +mod quest_material_tests { + use super::*; + + fn go_guid(entry: u64) -> u64 { + (u64::from(benilla_protocol::guid::HIGH_GAMEOBJECT) << 48) | (entry << 24) | 1 + } + + /// `GetQuestBackgroundMaterial`'s resolve, the reader's twin: a GameObject source answers its + /// template data by type through `PageTextMaterial.dbc`; a creature source answers nothing + /// (FrameXML's Parchment); an uncached GameObject is asked for and answers nothing meanwhile. + #[test] + fn a_quest_sourced_from_an_object_paints_the_objects_material() { + let mut items = Items::default(); + let mut gos = GameObjectTemplates::default(); + let (tx, rx) = crossbeam_channel::unbounded(); + let commands = NetCommands(tx); + let catalog = PageMaterials(benilla_formats::PageTextMaterialCatalog::from_rows(&[ + (1, "Parchment"), + (2, "Stone"), + (3, "Marble"), + ])); + let mut data = [0i32; 24]; + data[2] = 2; + data[9] = 3; + gos.insert(100, 2, "Wanted Poster".into(), &data); + gos.insert(102, 10, "Chest".into(), &data); + assert_eq!( + object_material( + go_guid(100), + &mut items, + &mut gos, + Some(&catalog), + &commands + ) + .as_deref(), + Some("Stone") + ); + assert_eq!( + object_material( + go_guid(102), + &mut items, + &mut gos, + Some(&catalog), + &commands + ) + .as_deref(), + Some("Marble"), + "a chest reads data[9]" + ); + // A creature quest-giver: neither template, nothing asked. + let creature = (u64::from(benilla_protocol::guid::HIGH_UNIT) << 48) | (6 << 24) | 1; + assert_eq!( + object_material(creature, &mut items, &mut gos, Some(&catalog), &commands), + None + ); + assert!(rx.try_recv().is_err(), "a creature source asks for nothing"); + // An uncached object: nothing yet, and the template is asked for. + assert_eq!( + object_material( + go_guid(777), + &mut items, + &mut gos, + Some(&catalog), + &commands + ), + None + ); + assert!( + rx.try_recv().is_ok(), + "an uncached object's template is asked for" + ); + } +} diff --git a/crates/benilla-app/src/ui_quest.rs b/crates/benilla-app/src/ui_quest.rs index 79dffcfb..aeb711ee 100644 --- a/crates/benilla-app/src/ui_quest.rs +++ b/crates/benilla-app/src/ui_quest.rs @@ -22,14 +22,14 @@ use benilla_protocol::messages::{ use bevy::prelude::*; use benilla_ui::script::{ - QuestAction, QuestItemView, QuestPanel, QuestState, ScriptValue, UiScript, + QuestAction, QuestItemView, QuestPanel, QuestRewardSpell, QuestState, UiScript, }; use crate::entities::ItemDisplays; use crate::items::Items; use crate::names::NameCache; use crate::net::{ClientCommand, Guid, GuidIndex, NetCommands, ObjectStore, SelfPlayer}; -use crate::ui_action::{show_messages, ui_error_text, MessageSink, Shown, UiError}; +use crate::ui_action::{show_messages, ui_error_text, MessageSink, Shown, Spells, UiError}; use crate::ui_script::UiInput; use crate::ui_session::{close_npc_session_out_of_range, npc_switched, NpcSession}; @@ -397,12 +397,35 @@ fn resolve_items( /// Build the Lua-facing snapshot from the open view — `None` when no window is open. Every /// server-authored text runs the shared chat-macro substitution (`$N`/`$B`/`$G` — /// [`crate::npc_text`]): the wire delivers quest text un-expanded, the client substitutes. +/// The quest's reward spell as the getters answer it: `rewSpell` off the packet, resolved to the +/// spell's name and icon through the spell catalog (`Spell.dbc`). Zero — no spell — is `None`; an +/// id the catalog cannot place still counts (the id is real), with nothing to paint. +pub(crate) fn reward_spell_view( + spell_id: u32, + spells: Option<&Spells>, +) -> Option { + if spell_id == 0 { + return None; + } + let d = spells.and_then(|s| s.catalog.get(spell_id)); + Some(QuestRewardSpell { + spell_id, + name: d.map(|d| d.name.clone()), + texture: d.and_then(|d| d.icon.clone()), + // `isTradeskillSpell` is `Spell.dbc` col 6 `Attributes` bit 5 (`0x20`) — `0x501e59`, + // wow-re quest-material-reward-spell-bindings.md §3 (1161 of 22357 rows set; craft-cast + // spells set, `Pattern:` teaching spells clear). + tradeskill: d.is_some_and(|d| d.attributes & 0x20 != 0), + }) +} + fn snapshot( giver: &QuestGiver, items: &mut Items, icons: Option<&ItemDisplays>, commands: &NetCommands, macros: &crate::npc_text::MacroContext, + spells: Option<&Spells>, ) -> Option { let sub = |t: &str| crate::npc_text::substitute(t, macros); Some(match giver.view.as_ref()? { @@ -432,6 +455,7 @@ fn snapshot( choices: resolve_items(&d.choices, items, icons, commands), rewards: resolve_items(&d.rewards, items, icons, commands), reward_money: d.money.max(0) as u32, + reward_spell: reward_spell_view(d.reward_spell, spells), ..Default::default() }, QuestView::Progress(p) => QuestState { @@ -450,6 +474,7 @@ fn snapshot( choices: resolve_items(&o.choices, items, icons, commands), rewards: resolve_items(&o.rewards, items, icons, commands), reward_money: o.money.max(0) as u32, + reward_spell: reward_spell_view(o.reward_spell, spells), ..Default::default() }, }) @@ -479,6 +504,9 @@ fn feed_quest( mut names: ResMut, states: Res, self_q: Query<(&ObjectStore, &Guid), With>, + spells: Option>, + mut go_templates: ResMut, + materials: Option>, mut sink: MessageSink, mut last: Local>>, mut last_name: Local>>, @@ -518,7 +546,24 @@ fn feed_quest( subject: player.as_ref(), states: &states, }, + spells.as_deref(), ); + // The panel's background material is the SOURCE object's — an item's page material, a + // GameObject's template data — never a creature's, and never on the wire (1946; wow-re + // quest-material-reward-spell-bindings.md §1). Resolved the way the reader window resolves its + // own, since the two bindings share a body. + let fresh = fresh.map(|mut st| { + st.background_material = giver.npc.and_then(|source| { + crate::ui_item_text::object_material( + source, + &mut items, + &mut go_templates, + materials.as_deref(), + &commands, + ) + }); + st + }); let npc_name = giver .npc .and_then(|g| names.resolve(g, &commands).map(str::to_string)); @@ -531,7 +576,9 @@ fn feed_quest( return; } script.set_quest(fresh.clone()); - let name_arg = || vec![ScriptValue::Str(npc_name.clone().unwrap_or_default())]; + // The reference's QUEST_GREETING/DETAIL/PROGRESS/COMPLETE carry no argument: the window + // reads `UnitName("npc")` for its title (stock QuestFrame.lua:63), and the "npc" unit is + // the session's (ui_unit.rs). Ours used to pass the name as arg1 (1944 dropped it). match (&*last, &fresh) { (_, Some(f)) if switched => { // A different giver → close the old panel, open the new (both kits play). QUEST_FINISHED @@ -539,7 +586,7 @@ fn feed_quest( // drain the pending actions so it does NOT clear the giver we just re-opened. Safe: a // switch is net-driven, so no user action is queued this frame to lose. script.fire_event("QUEST_FINISHED", vec![]); - script.fire_event(panel_event(f.panel), name_arg()); + script.fire_event(panel_event(f.panel), vec![]); let _ = script.take_quest_actions(); } (_, Some(f)) => { @@ -551,7 +598,7 @@ fn feed_quest( } else { panel_event(f.panel) }; - script.fire_event(event, name_arg()); + script.fire_event(event, vec![]); } (Some(_), None) => script.fire_event("QUEST_FINISHED", vec![]), (None, None) => {} @@ -838,6 +885,7 @@ mod tests { subject: Some(&player), states: &crate::world_state::WorldStates::default(), }, + None, ) .expect("open"); assert_eq!(snap.panel, QuestPanel::Detail); @@ -882,6 +930,7 @@ mod tests { subject: None, states: &crate::world_state::WorldStates::default(), }, + None, ) .unwrap(); assert_eq!(snap.panel, QuestPanel::Progress); diff --git a/crates/benilla-app/src/ui_quest_log.rs b/crates/benilla-app/src/ui_quest_log.rs index 527aa20b..563cff05 100644 --- a/crates/benilla-app/src/ui_quest_log.rs +++ b/crates/benilla-app/src/ui_quest_log.rs @@ -53,6 +53,7 @@ use crate::entities::ItemDisplays; use crate::items::Items; use crate::names::NameCache; use crate::net::{ClientCommand, Guid, NetCommands, ObjectStore, SelfPlayer}; +use crate::ui_action::Spells; use crate::ui_script::UiInput; use crate::ui_unit::UnitFeed; @@ -330,6 +331,7 @@ fn build_detail( icons: Option<&ItemDisplays>, commands: &NetCommands, macros: &crate::npc_text::MacroContext, + spells: Option<&Spells>, ) -> QuestLogDetail { let (required_money, reward_money) = money_split(template.money); let rewards = template @@ -354,6 +356,7 @@ fn build_detail( reward_money, choices, rewards, + reward_spell: crate::ui_quest::reward_spell_view(template.reward_spell, spells), } } @@ -486,6 +489,7 @@ fn feed_quest_log( header_names: Option>, tag_names: Option>, states: Res, + spells: Option>, mut last: Local>, mut prior_quest_ids: Local>>>, ) { @@ -702,9 +706,16 @@ fn feed_quest_log( .filter(|e| !e.is_header) .map(|e| e.quest_id) .and_then(|quest_id| { - quest_log - .template(quest_id, &commands) - .map(|t| build_detail(t, &mut items, icons.as_deref(), &commands, ¯os)) + quest_log.template(quest_id, &commands).map(|t| { + build_detail( + t, + &mut items, + icons.as_deref(), + &commands, + ¯os, + spells.as_deref(), + ) + }) }); let fresh = QuestLogState { @@ -745,8 +756,6 @@ fn feed_quest_log( script.set_quest_log(fresh.clone()); script.fire_event("QUEST_LOG_UPDATE", vec![]); if !progressed.is_empty() { - // Post-push = post-prune: the current watch list, the byte law's index space. - let watched = script.quest_log_watched(); for quest in progressed { for line in quest.changed_lines { script.fire_event("UI_INFO_MESSAGE", vec![ScriptValue::Str(line)]); @@ -759,13 +768,13 @@ fn feed_quest_log( }; script.fire_event("UI_INFO_MESSAGE", vec![ScriptValue::Str(msg)]); } - let watch_index = watched - .iter() - .position(|&id| id == quest.quest_id) - .map_or(0, |p| p as i64 + 1); - script.fire_event("QUEST_WATCH_UPDATE", vec![ScriptValue::Int(watch_index)]); + // QUEST_WATCH_UPDATE's arg1 is the quest's LOG index: stock QuestLog_OnEvent hands it + // to AutoQuestWatch_Update(questIndex), whose end is AddQuestWatch(questIndex) + // (QuestLogFrame.lua:68, 752-769). Ours used to pass the watch-list position and + // fire a BENILLA_QUEST_PROGRESS twin carrying the log index; the twin went with the + // window (1944). script.fire_event( - "BENILLA_QUEST_PROGRESS", + "QUEST_WATCH_UPDATE", vec![ScriptValue::Int(i64::from(quest.index))], ); } @@ -805,8 +814,6 @@ fn advanced(now: &QuestLogObjectiveView, was: &QuestLogObjectiveView) -> bool { /// One quest whose objectives advanced between log states — the announce unit of /// [`feed_quest_log`]. struct QuestProgress { - /// The quest id (stable identity — the watch list is id-keyed). - quest_id: u32, /// The quest's 1-based index in the NEW log (the Era API's quest-log index space). index: u32, /// The quest title (the COMPLETE toast's `%s`; empty falls to the UNKNOWN form). @@ -846,7 +853,6 @@ fn quests_with_progressed_objectives( .collect(); let completed = e.complete == 1 && prev.complete != 1; (!changed_lines.is_empty() || completed).then_some(QuestProgress { - quest_id: e.quest_id, index: i as u32 + 1, title: e.title.clone(), changed_lines, diff --git a/crates/benilla-app/src/ui_script/bottom_hud_tests.rs b/crates/benilla-app/src/ui_script/bottom_hud_tests.rs index 96f484a6..3d9da8a7 100644 --- a/crates/benilla-app/src/ui_script/bottom_hud_tests.rs +++ b/crates/benilla-app/src/ui_script/bottom_hud_tests.rs @@ -77,7 +77,7 @@ fn shipped_xml() -> Vec<(String, String)> { }) .collect(); out.sort(); - assert!(out.len() >= 40, "only {} xml files swept", out.len()); + assert!(out.len() >= 38, "only {} xml files swept", out.len()); out } diff --git a/crates/benilla-app/src/ui_script/durability_tests.rs b/crates/benilla-app/src/ui_script/durability_tests.rs index 383ae05f..29216d43 100644 --- a/crates/benilla-app/src/ui_script/durability_tests.rs +++ b/crates/benilla-app/src/ui_script/durability_tests.rs @@ -389,6 +389,7 @@ fn the_quest_tracker_stacks_below_the_durability_guy() { for f in [ "Interface\\FrameXML\\Fonts.xml", "MoneyFrame.xml", + "Interface\\FrameXML\\GlobalStrings.lua", "UiPanels.xml", r"Interface\FrameXML\UIPanelTemplates.lua", r"Interface\FrameXML\UIPanelTemplates.xml", @@ -397,9 +398,11 @@ fn the_quest_tracker_stacks_below_the_durability_guy() { "MinimapCluster.xml", "ScrollTemplates.xml", "Interface\\FrameXML\\DurabilityFrame.xml", - "Interface\\FrameXML\\GlobalStrings.lua", - "QuestFrame.xml", - "QuestLogFrame.xml", + "Interface\\FrameXML\\BasicControls.xml", + "Interface\\FrameXML\\ItemButtonTemplate.xml", + "Interface\\FrameXML\\QuestFrame.xml", + "MicroMenu.xml", + "Interface\\FrameXML\\QuestLogFrame.xml", ] { load_xml(&s, f); } diff --git a/crates/benilla-app/src/ui_script/errors_tests.rs b/crates/benilla-app/src/ui_script/errors_tests.rs index eba2c091..09ff622f 100644 --- a/crates/benilla-app/src/ui_script/errors_tests.rs +++ b/crates/benilla-app/src/ui_script/errors_tests.rs @@ -111,6 +111,7 @@ fn an_error_toast_draws_over_an_open_panel_window() { s.set_screen_size(1024.0, 768.0); load_xml(&s, "Interface\\FrameXML\\Fonts.xml"); load_xml(&s, "MoneyFrame.xml"); + load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua"); load_xml(&s, "UiPanels.xml"); load_xml(&s, "GameTooltip.xml"); load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua"); @@ -122,9 +123,12 @@ fn an_error_toast_draws_over_an_open_panel_window() { // pane's UIPanelScrollFrameTemplate). A MISSING template is a loader *warning*, so an // under-loaded list passes and then dies on the first FauxScrollFrame_Update. load_xml(&s, "ScrollTemplates.xml"); - load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua"); - load_xml(&s, "QuestFrame.xml"); - load_xml(&s, "QuestLogFrame.xml"); + load_xml(&s, "Interface\\FrameXML\\BasicControls.xml"); + load_xml(&s, "UIParent.xml"); + load_xml(&s, "Interface\\FrameXML\\ItemButtonTemplate.xml"); + load_xml(&s, "Interface\\FrameXML\\QuestFrame.xml"); + load_xml(&s, "MicroMenu.xml"); + load_xml(&s, "Interface\\FrameXML\\QuestLogFrame.xml"); // A left-slot panel open, and the toast raised after it — the order that must not decide. s.eval::<()>("ShowUIPanel(QuestLogFrame)").unwrap(); diff --git a/crates/benilla-app/src/ui_script/friends_tests.rs b/crates/benilla-app/src/ui_script/friends_tests.rs index 7cb9001c..1056c9ca 100644 --- a/crates/benilla-app/src/ui_script/friends_tests.rs +++ b/crates/benilla-app/src/ui_script/friends_tests.rs @@ -17,6 +17,7 @@ fn setup() -> UiScript { s.set_screen_size(1024.0, 768.0); load_xml(&s, "Interface\\FrameXML\\Fonts.xml"); load_xml(&s, "MoneyFrame.xml"); + load_xml(&s, r"Interface\FrameXML\GlobalStrings.lua"); load_xml(&s, "UiPanels.xml"); load_xml(&s, "GameTooltip.xml"); load_xml(&s, "Interface\\FrameXML\\UIDropDownMenu.xml"); @@ -44,7 +45,6 @@ fn setup() -> UiScript { // drags in the whole unit-frame cluster — and it must be the REAL frames: loading only // `PartyFrame.lua` for the two names gets "attempt to index a nil value" the first time one // iterates. The manifest already seats these far above RaidFrame (1874). - load_xml(&s, r"Interface\FrameXML\GlobalStrings.lua"); // The reference's own `BasicControls.xml`, which is manifest entry 3 — far above the pane // (1874). It is here for the error/message pair and `TEXT`. NOTE: unlike `raid_tests`, this // harness does not load `UIParent.xml`, so `UIParentLoadAddOn` — which 1881 moved there out diff --git a/crates/benilla-app/src/ui_script/guild_tests.rs b/crates/benilla-app/src/ui_script/guild_tests.rs index 298e31ab..e9f4e52d 100644 --- a/crates/benilla-app/src/ui_script/guild_tests.rs +++ b/crates/benilla-app/src/ui_script/guild_tests.rs @@ -206,6 +206,7 @@ fn setup() -> UiScript { load_xml(&s, "Interface\\FrameXML\\Fonts.xml"); load_xml(&s, "Interface\\FrameXML\\BasicControls.xml"); load_xml(&s, "MoneyFrame.xml"); + load_xml(&s, r"Interface\FrameXML\GlobalStrings.lua"); load_xml(&s, "UiPanels.xml"); load_xml(&s, "GameTooltip.xml"); load_xml(&s, "Interface\\FrameXML\\UIDropDownMenu.xml"); @@ -227,7 +228,6 @@ fn setup() -> UiScript { // drags in the whole unit-frame cluster — and it must be the REAL frames: loading only // `PartyFrame.lua` for the two names gets "attempt to index a nil value" the first time one // iterates. The manifest already seats these far above RaidFrame (1874). - load_xml(&s, r"Interface\FrameXML\GlobalStrings.lua"); load_xml(&s, r"Interface\FrameXML\TextStatusBar.lua"); load_xml(&s, r"Interface\FrameXML\TextStatusBar.xml"); load_xml(&s, r"Interface\FrameXML\BuffFrame.xml"); diff --git a/crates/benilla-app/src/ui_script/help_frame_tests.rs b/crates/benilla-app/src/ui_script/help_frame_tests.rs index e6ce90cd..9c591cc2 100644 --- a/crates/benilla-app/src/ui_script/help_frame_tests.rs +++ b/crates/benilla-app/src/ui_script/help_frame_tests.rs @@ -30,6 +30,7 @@ fn setup() -> UiScript { // row, whose OnLoad calls `SmallMoneyFrame_OnLoad` — the TOC's own order (1580's // talent-wipe fixture hit this first). "MoneyFrame.xml", + "Interface\\FrameXML\\GlobalStrings.lua", "UiPanels.xml", // The stock file's `HelpFrame_OnShow` calls `UpdateMicroButtons()` before // `GetGMStatus()`, so without the micro row the OnShow raises and the status ask never @@ -39,7 +40,6 @@ fn setup() -> UiScript { "Interface\\FrameXML\\TextStatusBar.lua", "Interface\\FrameXML\\TextStatusBar.xml", "UIParent.xml", - "Interface\\FrameXML\\GlobalStrings.lua", "Interface\\FrameXML\\MainMenuBar.xml", "Interface\\FrameXML\\ActionBarFrame.xml", "Interface\\FrameXML\\BonusActionBarFrame.xml", diff --git a/crates/benilla-app/src/ui_script/options_tests.rs b/crates/benilla-app/src/ui_script/options_tests.rs index 5ad18478..51cda487 100644 --- a/crates/benilla-app/src/ui_script/options_tests.rs +++ b/crates/benilla-app/src/ui_script/options_tests.rs @@ -718,6 +718,7 @@ fn interface_harness() -> UiScript { &[ "Interface\\FrameXML\\Fonts.xml", "MoneyFrame.xml", + "Interface\\FrameXML\\GlobalStrings.lua", "UiPanels.xml", r"Interface\FrameXML\UIPanelTemplates.lua", r"Interface\FrameXML\UIPanelTemplates.xml", @@ -750,7 +751,6 @@ fn interface_harness() -> UiScript { "Interface\\FrameXML\\ColorPickerFrame.xml", "Cooldown.xml", "Interface\\FrameXML\\ActionButtonTemplate.xml", - "Interface\\FrameXML\\GlobalStrings.lua", "Interface\\FrameXML\\MainMenuBar.xml", "Interface\\FrameXML\\ActionBarFrame.xml", "Interface\\FrameXML\\BonusActionBarFrame.xml", @@ -765,8 +765,11 @@ fn interface_harness() -> UiScript { "ScrollTemplates.xml", "Interface\\FrameXML\\MerchantFrame.xml", "Interface\\FrameXML\\GlobalStrings.lua", - "QuestFrame.xml", - "QuestLogFrame.xml", + "Interface\\FrameXML\\BasicControls.xml", + "Interface\\FrameXML\\ItemButtonTemplate.xml", + "Interface\\FrameXML\\QuestFrame.xml", + "MicroMenu.xml", + "Interface\\FrameXML\\QuestLogFrame.xml", ], ); harness_on(s) diff --git a/crates/benilla-app/src/ui_script/pet_bar_tests.rs b/crates/benilla-app/src/ui_script/pet_bar_tests.rs index ac57e4dd..5c9ae6f6 100644 --- a/crates/benilla-app/src/ui_script/pet_bar_tests.rs +++ b/crates/benilla-app/src/ui_script/pet_bar_tests.rs @@ -12,6 +12,7 @@ use super::test_ui::load_ui as load_xml; fn load_pet_bar(s: &UiScript) { for file in [ "MoneyFrame.xml", + "Interface\\FrameXML\\GlobalStrings.lua", "UiPanels.xml", "Interface\\FrameXML\\Fonts.xml", r"Interface\FrameXML\UIPanelTemplates.lua", @@ -21,7 +22,6 @@ fn load_pet_bar(s: &UiScript) { "Interface\\FrameXML\\ActionButtonTemplate.xml", "Interface\\FrameXML\\TextStatusBar.lua", "Interface\\FrameXML\\TextStatusBar.xml", - "Interface\\FrameXML\\GlobalStrings.lua", "Interface\\FrameXML\\MainMenuBar.xml", "GameTooltip.xml", "Interface\\FrameXML\\ActionBarFrame.xml", @@ -369,6 +369,7 @@ fn pet_bar_row(with_multibar: bool) -> (usize, f32) { s.set_screen_size(1024.0, 768.0); for file in [ "MoneyFrame.xml", + "Interface\\FrameXML\\GlobalStrings.lua", "UiPanels.xml", "Interface\\FrameXML\\Fonts.xml", r"Interface\FrameXML\UIPanelTemplates.lua", @@ -378,7 +379,6 @@ fn pet_bar_row(with_multibar: bool) -> (usize, f32) { "Interface\\FrameXML\\ActionButtonTemplate.xml", "Interface\\FrameXML\\TextStatusBar.lua", "Interface\\FrameXML\\TextStatusBar.xml", - "Interface\\FrameXML\\GlobalStrings.lua", "Interface\\FrameXML\\MainMenuBar.xml", "GameTooltip.xml", "Interface\\FrameXML\\ActionBarFrame.xml", @@ -677,6 +677,7 @@ fn load_pet_bar_with_tooltip(s: &UiScript) { for file in [ "Interface\\FrameXML\\Fonts.xml", "MoneyFrame.xml", + "Interface\\FrameXML\\GlobalStrings.lua", "UiPanels.xml", r"Interface\FrameXML\UIPanelTemplates.lua", r"Interface\FrameXML\UIPanelTemplates.xml", @@ -686,7 +687,6 @@ fn load_pet_bar_with_tooltip(s: &UiScript) { "Interface\\FrameXML\\ActionButtonTemplate.xml", "Interface\\FrameXML\\TextStatusBar.lua", "Interface\\FrameXML\\TextStatusBar.xml", - "Interface\\FrameXML\\GlobalStrings.lua", "Interface\\FrameXML\\MainMenuBar.xml", "Interface\\FrameXML\\ActionBarFrame.xml", "Interface\\FrameXML\\BonusActionBarFrame.xml", diff --git a/crates/benilla-app/src/ui_script/petition_tests.rs b/crates/benilla-app/src/ui_script/petition_tests.rs index 0580d442..fa905ee6 100644 --- a/crates/benilla-app/src/ui_script/petition_tests.rs +++ b/crates/benilla-app/src/ui_script/petition_tests.rs @@ -102,7 +102,10 @@ fn setup() -> UiScript { // without this the rows build with no art at all and nothing goes red — which is exactly the // failure `load_ui_strict` exists to turn into a red test. load_xml(&s, "ScrollTemplates.xml"); - load_xml(&s, "QuestFrame.xml"); + load_xml(&s, "UIParent.xml"); + load_xml(&s, "Interface\\FrameXML\\ItemButtonTemplate.xml"); + load_xml(&s, "Interface\\FrameXML\\QuestFrame.xml"); + load_xml(&s, "Interface\\FrameXML\\QuestLogFrame.xml"); // `ChatFrameEditBox`, which the reference's own purchase button indexes on every click to // decide where focus goes after the name box closes — a nil there raises before the charter is // bought. Ours guarded it; the reference does not. diff --git a/crates/benilla-app/src/ui_script/quest_tests.rs b/crates/benilla-app/src/ui_script/quest_tests.rs index a5af95e1..319a1269 100644 --- a/crates/benilla-app/src/ui_script/quest_tests.rs +++ b/crates/benilla-app/src/ui_script/quest_tests.rs @@ -19,6 +19,7 @@ fn questgiver_show_hide_plays_open_and_close_kits() { s.set_screen_size(1024.0, 768.0); load_xml(&s, "Interface\\FrameXML\\Fonts.xml"); load_xml(&s, "MoneyFrame.xml"); + load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua"); load_xml(&s, "UiPanels.xml"); load_xml(&s, "GameTooltip.xml"); load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua"); @@ -27,8 +28,11 @@ fn questgiver_show_hide_plays_open_and_close_kits() { // MerchantFrame.xml (the same documented cross-window dep the bag tests load). load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml"); load_xml(&s, "ScrollTemplates.xml"); // the shared scroll kit the window rides - load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua"); - load_xml(&s, "QuestFrame.xml"); + load_xml(&s, "Interface\\FrameXML\\BasicControls.xml"); + load_xml(&s, "UIParent.xml"); + load_xml(&s, "Interface\\FrameXML\\ItemButtonTemplate.xml"); + load_xml(&s, "Interface\\FrameXML\\QuestFrame.xml"); + load_xml(&s, "Interface\\FrameXML\\QuestLogFrame.xml"); // Hidden at load: no open sound (never transitions on startup). assert!( @@ -82,14 +86,18 @@ fn panel_events_show_exactly_one_child_panel_and_hide_the_others() { s.set_screen_size(1024.0, 768.0); load_xml(&s, "Interface\\FrameXML\\Fonts.xml"); load_xml(&s, "MoneyFrame.xml"); + load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua"); load_xml(&s, "UiPanels.xml"); load_xml(&s, "GameTooltip.xml"); load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua"); load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml"); load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml"); load_xml(&s, "ScrollTemplates.xml"); // the shared scroll kit the window rides - load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua"); - load_xml(&s, "QuestFrame.xml"); + load_xml(&s, "Interface\\FrameXML\\BasicControls.xml"); + load_xml(&s, "UIParent.xml"); + load_xml(&s, "Interface\\FrameXML\\ItemButtonTemplate.xml"); + load_xml(&s, "Interface\\FrameXML\\QuestFrame.xml"); + load_xml(&s, "Interface\\FrameXML\\QuestLogFrame.xml"); let panels = [ "QuestFrameGreetingPanel", @@ -106,10 +114,15 @@ fn panel_events_show_exactly_one_child_panel_and_hide_the_others() { active_titles: vec!["Report to Goldshire".into()], ..QuestState::default() })); - s.fire_event( - "QUEST_GREETING", - vec![ScriptValue::Str("Deputy Willem".into())], + s.set_unit( + "npc", + Some(benilla_ui::script::UnitState { + exists: true, + name: Some("Deputy Willem".into()), + ..Default::default() + }), ); + s.fire_event("QUEST_GREETING", vec![]); assert!(s.errors().is_empty(), "script errors: {:?}", s.errors()); for name in panels { assert_eq!( @@ -132,10 +145,15 @@ fn panel_events_show_exactly_one_child_panel_and_hide_the_others() { title: "A Threat Within".into(), ..QuestState::default() })); - s.fire_event( - "QUEST_DETAIL", - vec![ScriptValue::Str("Deputy Willem".into())], + s.set_unit( + "npc", + Some(benilla_ui::script::UnitState { + exists: true, + name: Some("Deputy Willem".into()), + ..Default::default() + }), ); + s.fire_event("QUEST_DETAIL", vec![]); assert!(s.errors().is_empty(), "script errors: {:?}", s.errors()); for name in panels { assert_eq!( @@ -163,14 +181,18 @@ fn detail_panel_reward_grid_follows_the_refs_two_per_row_layout() { s.set_screen_size(1024.0, 768.0); load_xml(&s, "Interface\\FrameXML\\Fonts.xml"); load_xml(&s, "MoneyFrame.xml"); + load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua"); load_xml(&s, "UiPanels.xml"); load_xml(&s, "GameTooltip.xml"); load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua"); load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml"); load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml"); load_xml(&s, "ScrollTemplates.xml"); // the shared scroll kit the window rides - load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua"); - load_xml(&s, "QuestFrame.xml"); + load_xml(&s, "Interface\\FrameXML\\BasicControls.xml"); + load_xml(&s, "UIParent.xml"); + load_xml(&s, "Interface\\FrameXML\\ItemButtonTemplate.xml"); + load_xml(&s, "Interface\\FrameXML\\QuestFrame.xml"); + load_xml(&s, "Interface\\FrameXML\\QuestLogFrame.xml"); let choice = |name: &str, quality: u32| benilla_ui::script::QuestItemView { item_id: 0, @@ -191,10 +213,15 @@ fn detail_panel_reward_grid_follows_the_refs_two_per_row_layout() { reward_money: 150, ..QuestState::default() })); - s.fire_event( - "QUEST_DETAIL", - vec![ScriptValue::Str("Marshal McBride".into())], + s.set_unit( + "npc", + Some(benilla_ui::script::UnitState { + exists: true, + name: Some("Marshal McBride".into()), + ..Default::default() + }), ); + s.fire_event("QUEST_DETAIL", vec![]); assert!(s.errors().is_empty(), "script errors: {:?}", s.errors()); assert!(s.eval::("return QuestDetailItem1:IsShown()").unwrap()); @@ -208,7 +235,7 @@ fn detail_panel_reward_grid_follows_the_refs_two_per_row_layout() { assert_eq!( s.eval::("return QuestDetailItemChooseText:GetText()") .unwrap(), - "You may choose one of these rewards:" + "You will be able to choose one of these rewards:" ); assert_eq!( s.eval::("return QuestDetailItemReceiveText:GetText()") @@ -235,8 +262,11 @@ fn detail_panel_reward_grid_follows_the_refs_two_per_row_layout() { // arm at all: an unmodified click must not raise and must not set an itemChoice. s.run("QuestDetailItem1:Click()").ok(); assert!(s.errors().is_empty(), "script errors: {:?}", s.errors()); + // The reward panel's `itemChoice` is set only by its own OnShow (stock QuestFrame.lua:104, + // `= 0`) and a reward-row click; on the detail panel it is simply unset. assert_eq!( - s.eval::("return QuestFrame.itemChoice").unwrap(), + s.eval::("return QuestFrameRewardPanel.itemChoice or 0") + .unwrap(), 0, "a detail-panel row never selects a reward" ); @@ -250,14 +280,18 @@ fn reward_panel_choice_click_selects_and_completes_with_zero_based_index() { s.set_screen_size(1024.0, 768.0); load_xml(&s, "Interface\\FrameXML\\Fonts.xml"); load_xml(&s, "MoneyFrame.xml"); + load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua"); load_xml(&s, "UiPanels.xml"); load_xml(&s, "GameTooltip.xml"); load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua"); load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml"); load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml"); load_xml(&s, "ScrollTemplates.xml"); // the shared scroll kit the window rides - load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua"); - load_xml(&s, "QuestFrame.xml"); + load_xml(&s, "Interface\\FrameXML\\BasicControls.xml"); + load_xml(&s, "UIParent.xml"); + load_xml(&s, "Interface\\FrameXML\\ItemButtonTemplate.xml"); + load_xml(&s, "Interface\\FrameXML\\QuestFrame.xml"); + load_xml(&s, "Interface\\FrameXML\\QuestLogFrame.xml"); let choice = |name: &str| benilla_ui::script::QuestItemView { item_id: 0, @@ -275,10 +309,15 @@ fn reward_panel_choice_click_selects_and_completes_with_zero_based_index() { choices: vec![choice("Worn Sword"), choice("Worn Mace")], ..QuestState::default() })); - s.fire_event( - "QUEST_COMPLETE", - vec![ScriptValue::Str("Marshal McBride".into())], + s.set_unit( + "npc", + Some(benilla_ui::script::UnitState { + exists: true, + name: Some("Marshal McBride".into()), + ..Default::default() + }), ); + s.fire_event("QUEST_COMPLETE", vec![]); assert!(s.errors().is_empty(), "script errors: {:?}", s.errors()); assert!( @@ -317,24 +356,33 @@ fn greeting_goodbye_button_closes_the_window() { s.set_screen_size(1024.0, 768.0); load_xml(&s, "Interface\\FrameXML\\Fonts.xml"); load_xml(&s, "MoneyFrame.xml"); + load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua"); load_xml(&s, "UiPanels.xml"); load_xml(&s, "GameTooltip.xml"); load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua"); load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml"); load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml"); load_xml(&s, "ScrollTemplates.xml"); // the shared scroll kit the window rides - load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua"); - load_xml(&s, "QuestFrame.xml"); + load_xml(&s, "Interface\\FrameXML\\BasicControls.xml"); + load_xml(&s, "UIParent.xml"); + load_xml(&s, "Interface\\FrameXML\\ItemButtonTemplate.xml"); + load_xml(&s, "Interface\\FrameXML\\QuestFrame.xml"); + load_xml(&s, "Interface\\FrameXML\\QuestLogFrame.xml"); s.set_quest(Some(QuestState { panel: QuestPanel::Greeting, greeting: "Hello".into(), ..QuestState::default() })); - s.fire_event( - "QUEST_GREETING", - vec![ScriptValue::Str("Deputy Willem".into())], + s.set_unit( + "npc", + Some(benilla_ui::script::UnitState { + exists: true, + name: Some("Deputy Willem".into()), + ..Default::default() + }), ); + s.fire_event("QUEST_GREETING", vec![]); assert!(s.errors().is_empty(), "script errors: {:?}", s.errors()); assert!(s.eval::("return QuestFrame:IsVisible()").unwrap()); @@ -362,14 +410,18 @@ fn detail_panel_action_buttons_resolve_to_real_onscreen_rects() { s.set_screen_size(1024.0, 768.0); load_xml(&s, "Interface\\FrameXML\\Fonts.xml"); load_xml(&s, "MoneyFrame.xml"); + load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua"); load_xml(&s, "UiPanels.xml"); load_xml(&s, "GameTooltip.xml"); load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua"); load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml"); load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml"); load_xml(&s, "ScrollTemplates.xml"); // the shared scroll kit the window rides - load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua"); - load_xml(&s, "QuestFrame.xml"); + load_xml(&s, "Interface\\FrameXML\\BasicControls.xml"); + load_xml(&s, "UIParent.xml"); + load_xml(&s, "Interface\\FrameXML\\ItemButtonTemplate.xml"); + load_xml(&s, "Interface\\FrameXML\\QuestFrame.xml"); + load_xml(&s, "Interface\\FrameXML\\QuestLogFrame.xml"); // Planted, not shipped: instant text is OFF out of the box (see the doc comment). s.eval::<()>(r#"QUEST_FADING_DISABLE = "1""#).unwrap(); s.set_quest(Some(QuestState { @@ -378,10 +430,15 @@ fn detail_panel_action_buttons_resolve_to_real_onscreen_rects() { body: "Kill kobolds.".into(), ..QuestState::default() })); - s.fire_event( - "QUEST_DETAIL", - vec![ScriptValue::Str("Deputy Willem".into())], + s.set_unit( + "npc", + Some(benilla_ui::script::UnitState { + exists: true, + name: Some("Deputy Willem".into()), + ..Default::default() + }), ); + s.fire_event("QUEST_DETAIL", vec![]); assert!(s.errors().is_empty(), "script errors: {:?}", s.errors()); assert!(s .eval::("return QuestFrameAcceptButton:IsShown()") @@ -478,14 +535,18 @@ fn write_on_still_fades_when_instant_text_is_off() { s.set_screen_size(1024.0, 768.0); load_xml(&s, "Interface\\FrameXML\\Fonts.xml"); load_xml(&s, "MoneyFrame.xml"); + load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua"); load_xml(&s, "UiPanels.xml"); load_xml(&s, "GameTooltip.xml"); load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua"); load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml"); load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml"); load_xml(&s, "ScrollTemplates.xml"); // the shared scroll kit the window rides - load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua"); - load_xml(&s, "QuestFrame.xml"); + load_xml(&s, "Interface\\FrameXML\\BasicControls.xml"); + load_xml(&s, "UIParent.xml"); + load_xml(&s, "Interface\\FrameXML\\ItemButtonTemplate.xml"); + load_xml(&s, "Interface\\FrameXML\\QuestFrame.xml"); + load_xml(&s, "Interface\\FrameXML\\QuestLogFrame.xml"); s.eval::<()>(r#"QUEST_FADING_DISABLE = "0""#).unwrap(); s.set_quest(Some(QuestState { panel: QuestPanel::Detail, @@ -493,10 +554,15 @@ fn write_on_still_fades_when_instant_text_is_off() { body: "Kill kobolds.".into(), ..QuestState::default() })); - s.fire_event( - "QUEST_DETAIL", - vec![ScriptValue::Str("Deputy Willem".into())], + s.set_unit( + "npc", + Some(benilla_ui::script::UnitState { + exists: true, + name: Some("Deputy Willem".into()), + ..Default::default() + }), ); + s.fire_event("QUEST_DETAIL", vec![]); assert!(s.errors().is_empty(), "script errors: {:?}", s.errors()); // Mid-write (reveal edge at char 4 of 13): the quill scratches, Accept stays dead. @@ -543,14 +609,18 @@ fn npc_name_reaches_the_title_bar_on_open_and_on_refresh() { s.set_screen_size(1024.0, 768.0); load_xml(&s, "Interface\\FrameXML\\Fonts.xml"); load_xml(&s, "MoneyFrame.xml"); + load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua"); load_xml(&s, "UiPanels.xml"); load_xml(&s, "GameTooltip.xml"); load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua"); load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml"); load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml"); load_xml(&s, "ScrollTemplates.xml"); // the shared scroll kit the window rides - load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua"); - load_xml(&s, "QuestFrame.xml"); + load_xml(&s, "Interface\\FrameXML\\BasicControls.xml"); + load_xml(&s, "UIParent.xml"); + load_xml(&s, "Interface\\FrameXML\\ItemButtonTemplate.xml"); + load_xml(&s, "Interface\\FrameXML\\QuestFrame.xml"); + load_xml(&s, "Interface\\FrameXML\\QuestLogFrame.xml"); s.set_quest(Some(QuestState { panel: QuestPanel::Detail, @@ -558,10 +628,15 @@ fn npc_name_reaches_the_title_bar_on_open_and_on_refresh() { body: "Kill things.".into(), ..QuestState::default() })); - s.fire_event( - "QUEST_DETAIL", - vec![ScriptValue::Str("Marshal McBride".into())], + s.set_unit( + "npc", + Some(benilla_ui::script::UnitState { + exists: true, + name: Some("Marshal McBride".into()), + ..Default::default() + }), ); + s.fire_event("QUEST_DETAIL", vec![]); assert!(s.errors().is_empty(), "script errors: {:?}", s.errors()); assert_eq!( s.eval::("return getglobal('QuestFrameNpcNameText'):GetText() or ''") @@ -608,16 +683,24 @@ fn npc_name_reaches_the_title_bar_on_open_and_on_refresh() { fn greeting_panel_title_rows_grow_to_their_wrapped_titles() { let mut s = UiScript::new().unwrap(); s.set_screen_size(1024.0, 768.0); + // Stock sizes each row at show time — `SetHeight(GetTextHeight() + 2)` right after SetText + // (QuestFrame.lua:245) — which the reference can do because its text measure is synchronous. + // So is the app's (measure.rs); the harness installs the same seam's stand-in. + s.set_text_measurer(Box::new(super::FixedWidthFont(7.0))); load_xml(&s, "Interface\\FrameXML\\Fonts.xml"); load_xml(&s, "MoneyFrame.xml"); + load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua"); load_xml(&s, "UiPanels.xml"); load_xml(&s, "GameTooltip.xml"); load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua"); load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml"); load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml"); load_xml(&s, "ScrollTemplates.xml"); // the shared scroll kit the window rides - load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua"); - load_xml(&s, "QuestFrame.xml"); + load_xml(&s, "Interface\\FrameXML\\BasicControls.xml"); + load_xml(&s, "UIParent.xml"); + load_xml(&s, "Interface\\FrameXML\\ItemButtonTemplate.xml"); + load_xml(&s, "Interface\\FrameXML\\QuestFrame.xml"); + load_xml(&s, "Interface\\FrameXML\\QuestLogFrame.xml"); s.set_quest(Some(QuestState { panel: QuestPanel::Greeting, @@ -629,10 +712,15 @@ fn greeting_panel_title_rows_grow_to_their_wrapped_titles() { ], ..QuestState::default() })); - s.fire_event( - "QUEST_GREETING", - vec![ScriptValue::Str("Deputy Willem".into())], + s.set_unit( + "npc", + Some(benilla_ui::script::UnitState { + exists: true, + name: Some("Deputy Willem".into()), + ..Default::default() + }), ); + s.fire_event("QUEST_GREETING", vec![]); assert!(s.errors().is_empty(), "script errors: {:?}", s.errors()); let answer_measures = |s: &mut UiScript| { @@ -665,7 +753,7 @@ fn greeting_panel_title_rows_grow_to_their_wrapped_titles() { }; let (t1, b1, h1) = row(1); let (t2, b2, h2) = row(2); - assert!(h1 >= 28.0 && h2 >= 28.0, "both titles wrap: {h1}, {h2}"); + assert!(h1 >= 24.0 && h2 >= 24.0, "both titles wrap: {h1}, {h2}"); assert!( (t1 - b1 - (h1 + 2.0)).abs() < 0.5, "row 1 is its wrapped title + 2: got {}, title {h1}", @@ -698,14 +786,18 @@ fn reward_rows_preview_and_post_without_selecting_the_choice() { s.set_screen_size(1024.0, 768.0); load_xml(&s, "Interface\\FrameXML\\Fonts.xml"); load_xml(&s, "MoneyFrame.xml"); + load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua"); load_xml(&s, "UiPanels.xml"); load_xml(&s, "GameTooltip.xml"); load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua"); load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml"); load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml"); load_xml(&s, "ScrollTemplates.xml"); - load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua"); - load_xml(&s, "QuestFrame.xml"); + load_xml(&s, "Interface\\FrameXML\\BasicControls.xml"); + load_xml(&s, "UIParent.xml"); + load_xml(&s, "Interface\\FrameXML\\ItemButtonTemplate.xml"); + load_xml(&s, "Interface\\FrameXML\\QuestFrame.xml"); + load_xml(&s, "Interface\\FrameXML\\QuestLogFrame.xml"); load_xml(&s, "UIParent.xml"); // BenillaChatEdit_InsertLink lives here load_xml(&s, "DressUpFrame.xml"); // DressUpItemLink lives here load_xml(&s, "Interface\\FrameXML\\UIMenu.xml"); // the kit its menus build from @@ -737,10 +829,15 @@ fn reward_rows_preview_and_post_without_selecting_the_choice() { )], ..QuestState::default() })); - s.fire_event( - "QUEST_COMPLETE", - vec![ScriptValue::Str("Marshal McBride".into())], + s.set_unit( + "npc", + Some(benilla_ui::script::UnitState { + exists: true, + name: Some("Marshal McBride".into()), + ..Default::default() + }), ); + s.fire_event("QUEST_COMPLETE", vec![]); assert!(s.errors().is_empty(), "script errors: {:?}", s.errors()); // The regression this fork could break: a PLAIN click still picks the choice (the ref's third @@ -748,7 +845,8 @@ fn reward_rows_preview_and_post_without_selecting_the_choice() { // move it if they leaked into the select arm. s.run("QuestRewardItem2:Click()").unwrap(); assert_eq!( - s.eval::("return QuestFrame.itemChoice").unwrap(), + s.eval::("return QuestFrameRewardPanel.itemChoice") + .unwrap(), 2, "a plain click still selects the reward choice" ); @@ -772,7 +870,8 @@ fn reward_rows_preview_and_post_without_selecting_the_choice() { "shift-click posted choice 1's link" ); assert_eq!( - s.eval::("return QuestFrame.itemChoice").unwrap(), + s.eval::("return QuestFrameRewardPanel.itemChoice") + .unwrap(), 2, "the shift arm returns — it must NOT also select the clicked choice" ); @@ -803,7 +902,8 @@ fn reward_rows_preview_and_post_without_selecting_the_choice() { "ctrl-click opened the room wearing choice 1" ); assert_eq!( - s.eval::("return QuestFrame.itemChoice").unwrap(), + s.eval::("return QuestFrameRewardPanel.itemChoice") + .unwrap(), 2, "the ctrl arm returns — it must NOT also select the clicked choice" ); diff --git a/crates/benilla-app/src/ui_script/quest_timer_tests.rs b/crates/benilla-app/src/ui_script/quest_timer_tests.rs index 67b27fff..6f18c5d7 100644 --- a/crates/benilla-app/src/ui_script/quest_timer_tests.rs +++ b/crates/benilla-app/src/ui_script/quest_timer_tests.rs @@ -37,8 +37,11 @@ fn harness() -> UiScript { load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua"); load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml"); load_xml(&s, "ScrollTemplates.xml"); - load_xml(&s, "QuestFrame.xml"); - load_xml(&s, "QuestLogFrame.xml"); + load_xml(&s, "Interface\\FrameXML\\BasicControls.xml"); + load_xml(&s, "Interface\\FrameXML\\ItemButtonTemplate.xml"); + load_xml(&s, "Interface\\FrameXML\\QuestFrame.xml"); + load_xml(&s, "MicroMenu.xml"); + load_xml(&s, "Interface\\FrameXML\\QuestLogFrame.xml"); load_xml(&s, "Interface\\FrameXML\\QuestTimerFrame.xml"); s } diff --git a/crates/benilla-app/src/ui_script/questlog_tests.rs b/crates/benilla-app/src/ui_script/questlog_tests.rs index 221b20e9..15bf7fd8 100644 --- a/crates/benilla-app/src/ui_script/questlog_tests.rs +++ b/crates/benilla-app/src/ui_script/questlog_tests.rs @@ -71,6 +71,7 @@ fn eight_entries() -> QuestLogState { // lands, so the fixture carries that exact shape. link: Some(HAMMER_LINK.into()), }], + reward_spell: None, }), } } @@ -85,6 +86,7 @@ fn shipped_questlog_frame_loads_clean() { let s = UiScript::new().unwrap(); load_xml(&s, "Interface\\FrameXML\\Fonts.xml"); load_xml(&s, "MoneyFrame.xml"); + load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua"); load_xml(&s, "UiPanels.xml"); load_xml(&s, "GameTooltip.xml"); load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua"); @@ -95,9 +97,12 @@ fn shipped_questlog_frame_loads_clean() { // an under-loaded list passes load_xml and then loses the wheel, the arrows and the bar // silently. load_xml(&s, "ScrollTemplates.xml"); - load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua"); - load_xml(&s, "QuestFrame.xml"); - load_xml(&s, "QuestLogFrame.xml"); + load_xml(&s, "Interface\\FrameXML\\BasicControls.xml"); + load_xml(&s, "UIParent.xml"); + load_xml(&s, "Interface\\FrameXML\\ItemButtonTemplate.xml"); + load_xml(&s, "Interface\\FrameXML\\QuestFrame.xml"); + load_xml(&s, "MicroMenu.xml"); + load_xml(&s, "Interface\\FrameXML\\QuestLogFrame.xml"); } /// The whole contract in one end-to-end drive: open plays the kit and renders row 1 + the count + @@ -111,6 +116,7 @@ fn shipped_questlog_frame_drives_end_to_end() { s.set_screen_size(1024.0, 768.0); load_xml(&s, "Interface\\FrameXML\\Fonts.xml"); load_xml(&s, "MoneyFrame.xml"); + load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua"); load_xml(&s, "UiPanels.xml"); load_xml(&s, "GameTooltip.xml"); load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua"); @@ -121,9 +127,12 @@ fn shipped_questlog_frame_drives_end_to_end() { // an under-loaded list passes load_xml and then loses the wheel, the arrows and the bar // silently. load_xml(&s, "ScrollTemplates.xml"); - load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua"); - load_xml(&s, "QuestFrame.xml"); - load_xml(&s, "QuestLogFrame.xml"); + load_xml(&s, "Interface\\FrameXML\\BasicControls.xml"); + load_xml(&s, "UIParent.xml"); + load_xml(&s, "Interface\\FrameXML\\ItemButtonTemplate.xml"); + load_xml(&s, "Interface\\FrameXML\\QuestFrame.xml"); + load_xml(&s, "MicroMenu.xml"); + load_xml(&s, "Interface\\FrameXML\\QuestLogFrame.xml"); s.set_quest_log(eight_entries()); @@ -147,7 +156,7 @@ fn shipped_questlog_frame_drives_end_to_end() { // Row 1 carries entry 1's title (indented), the count line reads "Quests: |cffffffff8/20|r", and nothing having been // selected, the first non-header entry auto-selects (pin §2's SetFirstValidSelection). assert!(s - .eval::("return QuestLogTitle1Text:GetText()") + .eval::("return QuestLogTitle1NormalText:GetText()") .unwrap() .contains("Quest 1")); assert_eq!( @@ -194,10 +203,12 @@ fn shipped_questlog_frame_drives_end_to_end() { .unwrap(), "You will receive:" ); - assert_eq!( - s.eval::("return QuestLogItemChooseText:GetText()") + // …hidden, not blanked: stock QuestFrameItems_Update Show()s/Hide()s the choose text + // (QuestFrame.lua:454-473) and leaves its string alone. + assert!( + !s.eval::("return QuestLogItemChooseText:IsShown()") .unwrap(), - "" + "no choices: the choose text is hidden" ); // No choices, so the pool's first slot IS the fixed reward and the SECOND is the first // unused one. (One pool now — the ref's shape; there is no separate "choice row 1".) @@ -220,7 +231,7 @@ fn shipped_questlog_frame_drives_end_to_end() { }) .expect("a 'Quest 3' row text quad"); let before = s - .eval::("return QuestLogTitle1Text:GetText()") + .eval::("return QuestLogTitle1NormalText:GetText()") .unwrap(); s.mouse_wheel(wx, wy, -1.0); assert!(s.errors().is_empty(), "wheel errors: {:?}", s.errors()); @@ -230,7 +241,7 @@ fn shipped_questlog_frame_drives_end_to_end() { // The migration reverts our step; how far a notch should travel is a look call, recorded in // 1846 rather than re-authored back in. assert_ne!( - s.eval::("return QuestLogTitle1Text:GetText()") + s.eval::("return QuestLogTitle1NormalText:GetText()") .unwrap(), before, "wheel-down scrolled the list" @@ -240,7 +251,7 @@ fn shipped_questlog_frame_drives_end_to_end() { // Abandon, No path: marks the selection, shows the registry's ABANDON_QUEST entry on the // shared StaticPopup engine (decision 0308 §3), but No drains nothing and hides it. - s.run("BenillaQuestLogAbandonButton_OnClick()").unwrap(); + s.run("QuestLogFrameAbandonButton:Click()").unwrap(); assert!(s.eval::("return StaticPopup1:IsShown()").unwrap()); assert_eq!( s.eval::("return GetAbandonQuestName()").unwrap(), @@ -269,7 +280,7 @@ fn shipped_questlog_frame_drives_end_to_end() { ); // Abandon, Yes path: the pinned index (1, the selection at click time) drains, plus the kit. - s.run("BenillaQuestLogAbandonButton_OnClick()").unwrap(); + s.run("QuestLogFrameAbandonButton:Click()").unwrap(); s.run("StaticPopup_OnClick(StaticPopup1, 1)").unwrap(); assert_eq!(s.take_quest_log_abandons(), vec![1]); // Show → igMainMenuOpen; the ref OnClick runs OnAccept FIRST (the abandon kit), THEN hides @@ -306,8 +317,12 @@ fn shift_click_toggles_the_watch_checkbox_and_the_tracker_hud() { let _data = benilla_formats::wow_data_or_skip!(); let mut s = UiScript::new().unwrap(); s.set_screen_size(1024.0, 768.0); + // Stock seats the watch check at update time off `questNormalText:GetWidth()` (QuestLogFrame.lua:205-215), + // synchronously — the app's measure seam is synchronous too (measure.rs); its stand-in here. + s.set_text_measurer(Box::new(super::FixedWidthFont(6.0))); load_xml(&s, "Interface\\FrameXML\\Fonts.xml"); load_xml(&s, "MoneyFrame.xml"); + load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua"); load_xml(&s, "UiPanels.xml"); load_xml(&s, "GameTooltip.xml"); load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua"); @@ -318,11 +333,23 @@ fn shift_click_toggles_the_watch_checkbox_and_the_tracker_hud() { // an under-loaded list passes load_xml and then loses the wheel, the arrows and the bar // silently. load_xml(&s, "ScrollTemplates.xml"); - load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua"); - load_xml(&s, "QuestFrame.xml"); - load_xml(&s, "QuestLogFrame.xml"); + load_xml(&s, "Interface\\FrameXML\\BasicControls.xml"); + load_xml(&s, "UIParent.xml"); + load_xml(&s, "Interface\\FrameXML\\ItemButtonTemplate.xml"); + load_xml(&s, "Interface\\FrameXML\\QuestFrame.xml"); + load_xml(&s, "MicroMenu.xml"); + // A shift-click reaches stock QuestLogTitleButton_OnClick's unguarded `ChatFrameEditBox:IsVisible()` + // (QuestLogFrame.lua:478) — the chat window is on the manifest long before the log (1944). + load_xml(&s, "UIParent.xml"); + load_xml(&s, "Interface\\FrameXML\\UIDropDownMenu.xml"); + load_xml(&s, "Interface\\FrameXML\\UIMenu.xml"); + load_xml(&s, "ChatFrame.xml"); + load_xml(&s, "Interface\\FrameXML\\QuestLogFrame.xml"); s.set_quest_log(eight_entries()); + // The app fires QUEST_LOG_UPDATE when the log lands; stock QuestLog_OnEvent's arm runs the + // first QuestWatch_Update, which is what hides an empty tracker. + s.fire_event("QUEST_LOG_UPDATE", vec![]); s.run("ToggleQuestLog()").unwrap(); assert!(s.errors().is_empty(), "script errors: {:?}", s.errors()); @@ -403,9 +430,12 @@ fn shift_click_toggles_the_watch_checkbox_and_the_tracker_hud() { .unwrap(), " - Kobold Vermin slain: 3/10" ); - // A manual watch is permanent: no auto-watch timer entry rides it. + // A manual watch is permanent: stock's shift-click inserts it with QUEST_WATCH_NO_EXPIRE + // (QuestLogFrame.lua:496), the timer that AutoQuestWatch_OnUpdate never counts down. assert!(s - .eval::("return BENILLA_QUEST_WATCH_TIMERS[\"Quest 1\"] == nil") + .eval::( + "return QUEST_WATCH_LIST[1] ~= nil and QUEST_WATCH_LIST[1].timer == QUEST_WATCH_NO_EXPIRE" + ) .unwrap()); // Shift-click again unwatches: the checkbox clears and — nothing left watched — the whole HUD @@ -433,6 +463,7 @@ fn watch_guards_no_op_without_erroring() { s.set_screen_size(1024.0, 768.0); load_xml(&s, "Interface\\FrameXML\\Fonts.xml"); load_xml(&s, "MoneyFrame.xml"); + load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua"); load_xml(&s, "UiPanels.xml"); load_xml(&s, "GameTooltip.xml"); load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua"); @@ -444,9 +475,18 @@ fn watch_guards_no_op_without_erroring() { // an under-loaded list passes load_xml and then loses the wheel, the arrows and the bar // silently. load_xml(&s, "ScrollTemplates.xml"); - load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua"); - load_xml(&s, "QuestFrame.xml"); - load_xml(&s, "QuestLogFrame.xml"); + load_xml(&s, "Interface\\FrameXML\\BasicControls.xml"); + load_xml(&s, "UIParent.xml"); + load_xml(&s, "Interface\\FrameXML\\ItemButtonTemplate.xml"); + load_xml(&s, "Interface\\FrameXML\\QuestFrame.xml"); + load_xml(&s, "MicroMenu.xml"); + // A shift-click reaches stock QuestLogTitleButton_OnClick's unguarded `ChatFrameEditBox:IsVisible()` + // (QuestLogFrame.lua:478) — the chat window is on the manifest long before the log (1944). + load_xml(&s, "UIParent.xml"); + load_xml(&s, "Interface\\FrameXML\\UIDropDownMenu.xml"); + load_xml(&s, "Interface\\FrameXML\\UIMenu.xml"); + load_xml(&s, "ChatFrame.xml"); + load_xml(&s, "Interface\\FrameXML\\QuestLogFrame.xml"); s.set_quest_log(eight_entries()); s.run("ToggleQuestLog()").unwrap(); @@ -482,8 +522,9 @@ fn watch_guards_no_op_without_erroring() { ); assert_eq!( s.eval::("return GetQuestLogSelection()").unwrap(), - 2, - "the click still selects" + 1, + "a shift-click never selects — stock QuestLogTitleButton_OnClick's shift branch returns \ + before the plain-click arm's QuestLog_SetSelection (QuestLogFrame.lua:472-500)" ); // Fill the watch list to the cap (5) with quests 2-6 via the Lua API directly — the engine-level @@ -532,6 +573,7 @@ fn progress_auto_watches_for_five_minutes() { s.set_screen_size(1024.0, 768.0); load_xml(&s, "Interface\\FrameXML\\Fonts.xml"); load_xml(&s, "MoneyFrame.xml"); + load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua"); load_xml(&s, "UiPanels.xml"); load_xml(&s, "GameTooltip.xml"); load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua"); @@ -542,14 +584,22 @@ fn progress_auto_watches_for_five_minutes() { // an under-loaded list passes load_xml and then loses the wheel, the arrows and the bar // silently. load_xml(&s, "ScrollTemplates.xml"); - load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua"); - load_xml(&s, "QuestFrame.xml"); - load_xml(&s, "QuestLogFrame.xml"); + load_xml(&s, "Interface\\FrameXML\\BasicControls.xml"); + load_xml(&s, "UIParent.xml"); + // AUTO_QUEST_WATCH is the options window's (UIOptionsFrame_Init in the reference; 1944), so the + // auto-watch needs that window loaded, as the manifest has it long before the log. + load_xml(&s, "Interface\\FrameXML\\UIDropDownMenu.xml"); + load_xml(&s, "KeyBindingsPage.xml"); + load_xml(&s, "OptionsFrame.xml"); + load_xml(&s, "Interface\\FrameXML\\ItemButtonTemplate.xml"); + load_xml(&s, "Interface\\FrameXML\\QuestFrame.xml"); + load_xml(&s, "MicroMenu.xml"); + load_xml(&s, "Interface\\FrameXML\\QuestLogFrame.xml"); s.set_quest_log(eight_entries()); // A kill credit lands: the engine fires the event with the quest's 1-based log index. s.fire_event( - "BENILLA_QUEST_PROGRESS", + "QUEST_WATCH_UPDATE", vec![benilla_ui::script::ScriptValue::Int(1)], ); assert!(s.errors().is_empty(), "auto-watch errors: {:?}", s.errors()); @@ -557,15 +607,20 @@ fn progress_auto_watches_for_five_minutes() { assert!(s .eval::("return QuestWatchFrame:IsVisible()") .unwrap()); - assert!(s - .eval::("return BENILLA_QUEST_WATCH_TIMERS[\"Quest 1\"] ~= nil") - .unwrap()); + assert!( + s.eval::( + "return QUEST_WATCH_LIST[1] ~= nil and QUEST_WATCH_LIST[1].index == 1 \ + and QUEST_WATCH_LIST[1].timer == MAX_QUEST_WATCH_TIMER" + ) + .unwrap(), + "the reference's QUEST_WATCH_LIST carries the armed timer (QuestLogFrame.lua:752-769)" + ); // 299 s in it still holds; fresh progress re-arms the timer; expiry unwatches and hides. s.tick(299.0); assert!(s.eval::("return IsQuestWatched(1)").unwrap()); s.fire_event( - "BENILLA_QUEST_PROGRESS", + "QUEST_WATCH_UPDATE", vec![benilla_ui::script::ScriptValue::Int(1)], ); s.tick(299.0); @@ -599,25 +654,35 @@ fn the_auto_watch_flag_is_the_references_uvar_and_gates_the_watch() { s.set_screen_size(1024.0, 768.0); load_xml(&s, "Interface\\FrameXML\\Fonts.xml"); load_xml(&s, "MoneyFrame.xml"); + load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua"); load_xml(&s, "UiPanels.xml"); load_xml(&s, "GameTooltip.xml"); load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua"); load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml"); + // The flag is declared by the options window's init since 1944, as the reference declares it + // in UIOptionsFrame_Init — so the window that owns it loads first, as in the manifest. + load_xml(&s, "UIParent.xml"); + load_xml(&s, "Interface\\FrameXML\\UIDropDownMenu.xml"); + load_xml(&s, "ScrollTemplates.xml"); + load_xml(&s, "KeyBindingsPage.xml"); + load_xml(&s, "OptionsFrame.xml"); load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml"); // ScrollTemplates.xml + UIPanelTemplates.xml are NOT optional: the detail pane inherits // UIPanelScrollFrameTemplate, and a MISSING template is a loader *warning*, not an error — // an under-loaded list passes load_xml and then loses the wheel, the arrows and the bar // silently. load_xml(&s, "ScrollTemplates.xml"); - load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua"); - load_xml(&s, "QuestFrame.xml"); - load_xml(&s, "QuestLogFrame.xml"); + load_xml(&s, "Interface\\FrameXML\\BasicControls.xml"); + load_xml(&s, "Interface\\FrameXML\\ItemButtonTemplate.xml"); + load_xml(&s, "Interface\\FrameXML\\QuestFrame.xml"); + load_xml(&s, "MicroMenu.xml"); + load_xml(&s, "Interface\\FrameXML\\QuestLogFrame.xml"); s.set_quest_log(eight_entries()); assert_eq!(s.eval::("return AUTO_QUEST_WATCH").unwrap(), "1"); s.run(r#"AUTO_QUEST_WATCH = "0""#).unwrap(); s.fire_event( - "BENILLA_QUEST_PROGRESS", + "QUEST_WATCH_UPDATE", vec![benilla_ui::script::ScriptValue::Int(1)], ); assert!( @@ -627,7 +692,7 @@ fn the_auto_watch_flag_is_the_references_uvar_and_gates_the_watch() { s.run(r#"AUTO_QUEST_WATCH = "1""#).unwrap(); s.fire_event( - "BENILLA_QUEST_PROGRESS", + "QUEST_WATCH_UPDATE", vec![benilla_ui::script::ScriptValue::Int(1)], ); assert!(s.eval::("return IsQuestWatched(1)").unwrap()); @@ -658,8 +723,12 @@ fn the_row_tag_is_its_own_right_flush_string_and_the_state_word_wins() { load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml"); load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml"); load_xml(&s, "ScrollTemplates.xml"); - load_xml(&s, "QuestFrame.xml"); - load_xml(&s, "QuestLogFrame.xml"); + load_xml(&s, "Interface\\FrameXML\\BasicControls.xml"); + load_xml(&s, "UIParent.xml"); + load_xml(&s, "Interface\\FrameXML\\ItemButtonTemplate.xml"); + load_xml(&s, "Interface\\FrameXML\\QuestFrame.xml"); + load_xml(&s, "MicroMenu.xml"); + load_xml(&s, "Interface\\FrameXML\\QuestLogFrame.xml"); let mut state = eight_entries(); // Row 1: a plain elite quest. Row 2: an elite quest that is COMPLETE — the state word wins. @@ -695,11 +764,11 @@ fn the_row_tag_is_its_own_right_flush_string_and_the_state_word_wins() { // The title is the bare (indented) name — the state word lives on the tag string now, so // appending it here too would double it. let title1 = s - .eval::("return QuestLogTitle1Text:GetText()") + .eval::("return QuestLogTitle1NormalText:GetText()") .unwrap(); assert_eq!(title1, " Quest 1"); let title2 = s - .eval::("return QuestLogTitle2Text:GetText()") + .eval::("return QuestLogTitle2NormalText:GetText()") .unwrap(); assert_eq!(title2, " Quest 2", "no \"(Complete)\" on the title"); @@ -722,6 +791,7 @@ fn empty_quest_log_hides_rows_and_disables_abandon() { s.set_screen_size(1024.0, 768.0); load_xml(&s, "Interface\\FrameXML\\Fonts.xml"); load_xml(&s, "MoneyFrame.xml"); + load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua"); load_xml(&s, "UiPanels.xml"); load_xml(&s, "GameTooltip.xml"); load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua"); @@ -732,9 +802,12 @@ fn empty_quest_log_hides_rows_and_disables_abandon() { // an under-loaded list passes load_xml and then loses the wheel, the arrows and the bar // silently. load_xml(&s, "ScrollTemplates.xml"); - load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua"); - load_xml(&s, "QuestFrame.xml"); - load_xml(&s, "QuestLogFrame.xml"); + load_xml(&s, "Interface\\FrameXML\\BasicControls.xml"); + load_xml(&s, "UIParent.xml"); + load_xml(&s, "Interface\\FrameXML\\ItemButtonTemplate.xml"); + load_xml(&s, "Interface\\FrameXML\\QuestFrame.xml"); + load_xml(&s, "MicroMenu.xml"); + load_xml(&s, "Interface\\FrameXML\\QuestLogFrame.xml"); s.set_quest_log(QuestLogState::default()); s.run("ToggleQuestLog()").unwrap(); @@ -742,22 +815,24 @@ fn empty_quest_log_hides_rows_and_disables_abandon() { // A bare FontString region has no Show/Hide/IsVisible in this engine (only Frames/Buttons do — // `region.rs`'s method table) — the empty-state message is a toggled SetText, read back here. + // The empty log says the reference's QUESTLOG_NO_QUESTS_TEXT (GlobalStrings.lua:3225) through + // stock QuestLogNoQuestsText; "Your quest log is empty." was ours (1944). assert_eq!( s.eval::("return QuestLogNoQuestsText:GetText()") .unwrap(), - "Your quest log is empty." + "No Active Quests" ); assert!(!s.eval::("return QuestLogTitle1:IsVisible()").unwrap()); assert!(!s .eval::("return QuestLogFrameAbandonButton:IsEnabled() ~= 0") .unwrap()); - // The Description header is now Lua-managed (BenillaQuestLogDetail_Clear blanks it) rather than - // a static `text=` — it used to float "Description" over the empty-log parchment with no - // selection (this task's fix; QuestLogFrame.xml's header comment on the FontString). - assert_eq!( - s.eval::("return QuestLogDescriptionTitle:GetText()") + // With no selection the reference hides the whole detail pane (stock + // `QuestLog_UpdateQuestDetails`: `QuestLogDetailScrollFrame:Hide()`), header included — it + // keeps its static `text="QUEST_DESCRIPTION"`; ours used to blank the header instead (1944). + assert!( + !s.eval::("return QuestLogDetailScrollFrame:IsVisible()") .unwrap(), - "" + "no selection: the detail pane is hidden, header and all" ); assert_eq!( s.eval::("return QuestLogQuestCount:GetText()") @@ -777,6 +852,7 @@ fn reward_rows_follow_the_refs_two_per_row_layout() { s.set_screen_size(1024.0, 768.0); load_xml(&s, "Interface\\FrameXML\\Fonts.xml"); load_xml(&s, "MoneyFrame.xml"); + load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua"); load_xml(&s, "UiPanels.xml"); load_xml(&s, "GameTooltip.xml"); load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua"); @@ -787,9 +863,12 @@ fn reward_rows_follow_the_refs_two_per_row_layout() { // an under-loaded list passes load_xml and then loses the wheel, the arrows and the bar // silently. load_xml(&s, "ScrollTemplates.xml"); - load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua"); - load_xml(&s, "QuestFrame.xml"); - load_xml(&s, "QuestLogFrame.xml"); + load_xml(&s, "Interface\\FrameXML\\BasicControls.xml"); + load_xml(&s, "UIParent.xml"); + load_xml(&s, "Interface\\FrameXML\\ItemButtonTemplate.xml"); + load_xml(&s, "Interface\\FrameXML\\QuestFrame.xml"); + load_xml(&s, "MicroMenu.xml"); + load_xml(&s, "Interface\\FrameXML\\QuestLogFrame.xml"); let mut state = eight_entries(); state.detail = Some(QuestLogDetail { @@ -826,6 +905,7 @@ fn reward_rows_follow_the_refs_two_per_row_layout() { usable: true, ..Default::default() }], + reward_spell: None, }); s.set_quest_log(state); s.run("ToggleQuestLog()").unwrap(); @@ -850,7 +930,7 @@ fn reward_rows_follow_the_refs_two_per_row_layout() { assert_eq!( s.eval::("return QuestLogItemChooseText:GetText()") .unwrap(), - "You may choose one of these rewards:" + "You will be able to choose one of these rewards:" ); // With choices present, the receive text reads "...also..." (QuestFrame.lua:461-462) — it @@ -886,6 +966,24 @@ fn reward_rows_follow_the_refs_two_per_row_layout() { /// ResizeChild`) well past the pane — load-bearing for the scroll/clip tests below even /// engine-only (no font atlas, so every auto-height FontString measures 0 here — this fixture /// overflows on the FIXED-height portions alone: 10×12px objective rows + the reward row). +/// The measure round-trip the app's resolve pass drives with its font engine: answer every +/// FontString the layout asked about with the 6px-per-glyph, 12px-per-line stand-in the +/// synchronous `FixedWidthFont(6.0)` uses, so the two agree. +fn answer_measures(s: &mut UiScript) { + let answers: Vec<(u32, f32, f32, u64)> = s + .fontstrings_needing_measure() + .into_iter() + .map(|r| { + let ink = r.text.chars().count() as f32 * 6.0; + match r.wrap_width { + Some(w) if w > 0.0 && ink > w => (r.id, w, (ink / w).ceil() * 12.0, r.key), + _ => (r.id, ink, 12.0, r.key), + } + }) + .collect(); + s.set_measured_text_unwrapped(&answers); +} + fn overflowing_entry() -> QuestLogState { let objectives = (1..=10) .map(|i| QuestLogObjectiveView { @@ -921,6 +1019,7 @@ fn overflowing_entry() -> QuestLogState { usable: true, ..Default::default() }], + reward_spell: None, }), } } @@ -938,6 +1037,7 @@ fn overflowing_detail_content_clips_to_the_scrollframe_rect() { s.set_screen_size(1024.0, 768.0); load_xml(&s, "Interface\\FrameXML\\Fonts.xml"); load_xml(&s, "MoneyFrame.xml"); + load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua"); load_xml(&s, "UiPanels.xml"); load_xml(&s, "GameTooltip.xml"); load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua"); @@ -948,9 +1048,12 @@ fn overflowing_detail_content_clips_to_the_scrollframe_rect() { // an under-loaded list passes load_xml and then loses the wheel, the arrows and the bar // silently. load_xml(&s, "ScrollTemplates.xml"); - load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua"); - load_xml(&s, "QuestFrame.xml"); - load_xml(&s, "QuestLogFrame.xml"); + load_xml(&s, "Interface\\FrameXML\\BasicControls.xml"); + load_xml(&s, "UIParent.xml"); + load_xml(&s, "Interface\\FrameXML\\ItemButtonTemplate.xml"); + load_xml(&s, "Interface\\FrameXML\\QuestFrame.xml"); + load_xml(&s, "MicroMenu.xml"); + load_xml(&s, "Interface\\FrameXML\\QuestLogFrame.xml"); s.set_quest_log(overflowing_entry()); s.run("ToggleQuestLog()").unwrap(); @@ -1002,8 +1105,10 @@ fn wheel_over_the_detail_pane_changes_vertical_scroll() { let _data = benilla_formats::wow_data_or_skip!(); let mut s = UiScript::new().unwrap(); s.set_screen_size(1024.0, 768.0); + s.set_text_measurer(Box::new(super::FixedWidthFont(6.0))); load_xml(&s, "Interface\\FrameXML\\Fonts.xml"); load_xml(&s, "MoneyFrame.xml"); + load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua"); load_xml(&s, "UiPanels.xml"); load_xml(&s, "GameTooltip.xml"); load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua"); @@ -1014,13 +1119,21 @@ fn wheel_over_the_detail_pane_changes_vertical_scroll() { // an under-loaded list passes load_xml and then loses the wheel, the arrows and the bar // silently. load_xml(&s, "ScrollTemplates.xml"); - load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua"); - load_xml(&s, "QuestFrame.xml"); - load_xml(&s, "QuestLogFrame.xml"); + load_xml(&s, "Interface\\FrameXML\\BasicControls.xml"); + load_xml(&s, "UIParent.xml"); + load_xml(&s, "Interface\\FrameXML\\ItemButtonTemplate.xml"); + load_xml(&s, "Interface\\FrameXML\\QuestFrame.xml"); + load_xml(&s, "MicroMenu.xml"); + load_xml(&s, "Interface\\FrameXML\\QuestLogFrame.xml"); s.set_quest_log(overflowing_entry()); s.run("ToggleQuestLog()").unwrap(); assert!(s.errors().is_empty(), "script errors: {:?}", s.errors()); + // The detail pane's scroll range is the union of its measured content (0128): the layout's + // measure round-trip has to be answered, as the app's resolve pass answers it every frame. + s.resolve(); + answer_measures(&mut s); + s.resolve(); // the measured span reaches the scroll range, and the bar, on this pass s.resolve(); let quads = s.extract(); @@ -1053,8 +1166,10 @@ fn selection_change_resets_detail_scroll_but_a_quest_log_update_refresh_does_not let _data = benilla_formats::wow_data_or_skip!(); let mut s = UiScript::new().unwrap(); s.set_screen_size(1024.0, 768.0); + s.set_text_measurer(Box::new(super::FixedWidthFont(6.0))); load_xml(&s, "Interface\\FrameXML\\Fonts.xml"); load_xml(&s, "MoneyFrame.xml"); + load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua"); load_xml(&s, "UiPanels.xml"); load_xml(&s, "GameTooltip.xml"); load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua"); @@ -1065,16 +1180,27 @@ fn selection_change_resets_detail_scroll_but_a_quest_log_update_refresh_does_not // an under-loaded list passes load_xml and then loses the wheel, the arrows and the bar // silently. load_xml(&s, "ScrollTemplates.xml"); - load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua"); - load_xml(&s, "QuestFrame.xml"); - load_xml(&s, "QuestLogFrame.xml"); + load_xml(&s, "Interface\\FrameXML\\BasicControls.xml"); + load_xml(&s, "UIParent.xml"); + load_xml(&s, "Interface\\FrameXML\\ItemButtonTemplate.xml"); + load_xml(&s, "Interface\\FrameXML\\QuestFrame.xml"); + load_xml(&s, "MicroMenu.xml"); + load_xml(&s, "Interface\\FrameXML\\QuestLogFrame.xml"); s.set_quest_log(overflowing_entry()); s.run("ToggleQuestLog()").unwrap(); assert!(s.errors().is_empty(), "script errors: {:?}", s.errors()); + // The detail pane's scroll range is the union of its measured content (0128): the layout's + // measure round-trip has to be answered, as the app's resolve pass answers it every frame. + s.resolve(); + answer_measures(&mut s); + s.resolve(); // the measured span reaches the scroll range, and the bar, on this pass s.resolve(); // GetVerticalScrollRange (SetVerticalScroll's clamp) reads resolved rects. - s.run("QuestLogDetailScrollFrame:SetVerticalScroll(10)") + // Scroll THROUGH the bar, as the wheel and the thumb do (ScrollFrameTemplate_OnMouseWheel sets + // the bar; its OnValueChanged moves the frame): a reselect's `ScrollBar:SetValue(0)` is what + // snaps the pane back, and a bar already at 0 would fire no change over a frame-only scroll. + s.run("QuestLogDetailScrollFrameScrollBar:SetValue(10)") .unwrap(); assert_eq!( s.eval::("return QuestLogDetailScrollFrame:GetVerticalScroll()") @@ -1093,8 +1219,7 @@ fn selection_change_resets_detail_scroll_but_a_quest_log_update_refresh_does_not ); // A manual reselect (the row-click path — no doNotScroll) DOES reset it. - s.run(r#"BenillaQuestLogTitle_OnClick(QuestLogTitle1, "LeftButton")"#) - .unwrap(); + s.run("QuestLogTitle1:Click()").unwrap(); assert!(s.errors().is_empty(), "click errors: {:?}", s.errors()); assert_eq!( s.eval::("return QuestLogDetailScrollFrame:GetVerticalScroll()") @@ -1117,6 +1242,7 @@ fn reward_row_hover_serves_the_shared_item_tooltip() { s.set_screen_size(1024.0, 768.0); load_xml(&s, "Interface\\FrameXML\\Fonts.xml"); load_xml(&s, "MoneyFrame.xml"); + load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua"); load_xml(&s, "UiPanels.xml"); load_xml(&s, "GameTooltip.xml"); load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua"); @@ -1132,9 +1258,12 @@ fn reward_row_hover_serves_the_shared_item_tooltip() { // an under-loaded list passes load_xml and then loses the wheel, the arrows and the bar // silently. load_xml(&s, "ScrollTemplates.xml"); - load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua"); - load_xml(&s, "QuestFrame.xml"); - load_xml(&s, "QuestLogFrame.xml"); + load_xml(&s, "Interface\\FrameXML\\BasicControls.xml"); + load_xml(&s, "UIParent.xml"); + load_xml(&s, "Interface\\FrameXML\\ItemButtonTemplate.xml"); + load_xml(&s, "Interface\\FrameXML\\QuestFrame.xml"); + load_xml(&s, "MicroMenu.xml"); + load_xml(&s, "Interface\\FrameXML\\QuestLogFrame.xml"); s.set_quest_log(eight_entries()); s.run("ToggleQuestLog()").unwrap(); @@ -1264,6 +1393,7 @@ fn reward_rows_preview_and_post_and_a_plain_click_stays_inert() { s.set_screen_size(1024.0, 768.0); load_xml(&s, "Interface\\FrameXML\\Fonts.xml"); load_xml(&s, "MoneyFrame.xml"); + load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua"); load_xml(&s, "UiPanels.xml"); load_xml(&s, "GameTooltip.xml"); load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua"); @@ -1274,9 +1404,15 @@ fn reward_rows_preview_and_post_and_a_plain_click_stays_inert() { // an under-loaded list passes load_xml and then loses the wheel, the arrows and the bar // silently. load_xml(&s, "ScrollTemplates.xml"); - load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua"); - load_xml(&s, "QuestFrame.xml"); - load_xml(&s, "QuestLogFrame.xml"); + load_xml(&s, "Interface\\FrameXML\\BasicControls.xml"); + load_xml(&s, "UIParent.xml"); + load_xml(&s, "Interface\\FrameXML\\ItemButtonTemplate.xml"); + load_xml(&s, "Interface\\FrameXML\\QuestFrame.xml"); + load_xml(&s, "MicroMenu.xml"); + // A shift-click reaches stock QuestLogTitleButton_OnClick's unguarded `ChatFrameEditBox:IsVisible()` + // (QuestLogFrame.lua:478) — the chat window is on the manifest long before the log (1944). + load_xml(&s, "Interface\\FrameXML\\UIDropDownMenu.xml"); + load_xml(&s, "Interface\\FrameXML\\QuestLogFrame.xml"); load_xml(&s, "UIParent.xml"); // BenillaChatEdit_InsertLink lives here load_xml(&s, "DressUpFrame.xml"); // DressUpItemLink lives here load_xml(&s, "Interface\\FrameXML\\UIMenu.xml"); // the kit its menus build from @@ -1349,6 +1485,7 @@ fn shift_click_on_a_title_posts_the_quest_name_with_chat_open_and_watches_with_i s.set_screen_size(1024.0, 768.0); load_xml(&s, "Interface\\FrameXML\\Fonts.xml"); load_xml(&s, "MoneyFrame.xml"); + load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua"); load_xml(&s, "UiPanels.xml"); load_xml(&s, "GameTooltip.xml"); load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua"); @@ -1359,9 +1496,15 @@ fn shift_click_on_a_title_posts_the_quest_name_with_chat_open_and_watches_with_i // an under-loaded list passes load_xml and then loses the wheel, the arrows and the bar // silently. load_xml(&s, "ScrollTemplates.xml"); - load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua"); - load_xml(&s, "QuestFrame.xml"); - load_xml(&s, "QuestLogFrame.xml"); + load_xml(&s, "Interface\\FrameXML\\BasicControls.xml"); + load_xml(&s, "UIParent.xml"); + load_xml(&s, "Interface\\FrameXML\\ItemButtonTemplate.xml"); + load_xml(&s, "Interface\\FrameXML\\QuestFrame.xml"); + load_xml(&s, "MicroMenu.xml"); + // A shift-click reaches stock QuestLogTitleButton_OnClick's unguarded `ChatFrameEditBox:IsVisible()` + // (QuestLogFrame.lua:478) — the chat window is on the manifest long before the log (1944). + load_xml(&s, "Interface\\FrameXML\\UIDropDownMenu.xml"); + load_xml(&s, "Interface\\FrameXML\\QuestLogFrame.xml"); load_xml(&s, "UIParent.xml"); // BenillaChatEdit_InsertLink lives here load_xml(&s, "Interface\\FrameXML\\UIMenu.xml"); // the kit its menus build from load_xml(&s, "ChatFrame.xml"); // ChatFrameEditBox lives here @@ -1445,6 +1588,7 @@ fn share_quest_needs_both_a_sharable_selection_and_a_party() { s.set_screen_size(1024.0, 768.0); load_xml(&s, "Interface\\FrameXML\\Fonts.xml"); load_xml(&s, "MoneyFrame.xml"); + load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua"); load_xml(&s, "UiPanels.xml"); load_xml(&s, "GameTooltip.xml"); load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua"); @@ -1455,9 +1599,12 @@ fn share_quest_needs_both_a_sharable_selection_and_a_party() { // an under-loaded list passes load_xml and then loses the wheel, the arrows and the bar // silently. load_xml(&s, "ScrollTemplates.xml"); - load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua"); - load_xml(&s, "QuestFrame.xml"); - load_xml(&s, "QuestLogFrame.xml"); + load_xml(&s, "Interface\\FrameXML\\BasicControls.xml"); + load_xml(&s, "UIParent.xml"); + load_xml(&s, "Interface\\FrameXML\\ItemButtonTemplate.xml"); + load_xml(&s, "Interface\\FrameXML\\QuestFrame.xml"); + load_xml(&s, "MicroMenu.xml"); + load_xml(&s, "Interface\\FrameXML\\QuestLogFrame.xml"); // Solo, quest 1 sharable and selected by the auto-selection. s.set_quest_log(entries_sharable(&[1])); @@ -1489,7 +1636,7 @@ fn share_quest_needs_both_a_sharable_selection_and_a_party() { // Select an unsharable quest: party unchanged, button goes dark. s.run("SelectQuestLogEntry(2)").unwrap(); - s.run("BenillaQuestLogFrame_Update()").unwrap(); + s.run("QuestLog_Update()").unwrap(); assert!( s.eval::("return GetQuestLogPushable() == nil") .unwrap(), @@ -1503,7 +1650,7 @@ fn share_quest_needs_both_a_sharable_selection_and_a_party() { // Leaving the party darkens it again from the other side. s.run("SelectQuestLogEntry(1)").unwrap(); - s.run("BenillaQuestLogFrame_Update()").unwrap(); + s.run("QuestLog_Update()").unwrap(); assert!(s .eval::("return QuestFramePushQuestButton:IsEnabled() ~= 0") .unwrap()); @@ -1524,6 +1671,7 @@ fn share_quest_is_dark_on_an_empty_log_even_in_a_party() { s.set_screen_size(1024.0, 768.0); load_xml(&s, "Interface\\FrameXML\\Fonts.xml"); load_xml(&s, "MoneyFrame.xml"); + load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua"); load_xml(&s, "UiPanels.xml"); load_xml(&s, "GameTooltip.xml"); load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua"); @@ -1534,9 +1682,12 @@ fn share_quest_is_dark_on_an_empty_log_even_in_a_party() { // an under-loaded list passes load_xml and then loses the wheel, the arrows and the bar // silently. load_xml(&s, "ScrollTemplates.xml"); - load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua"); - load_xml(&s, "QuestFrame.xml"); - load_xml(&s, "QuestLogFrame.xml"); + load_xml(&s, "Interface\\FrameXML\\BasicControls.xml"); + load_xml(&s, "UIParent.xml"); + load_xml(&s, "Interface\\FrameXML\\ItemButtonTemplate.xml"); + load_xml(&s, "Interface\\FrameXML\\QuestFrame.xml"); + load_xml(&s, "MicroMenu.xml"); + load_xml(&s, "Interface\\FrameXML\\QuestLogFrame.xml"); s.set_quest_log(QuestLogState::default()); s.set_party(party(4)); @@ -1557,6 +1708,7 @@ fn share_quest_click_queues_the_selected_quests_id() { s.set_screen_size(1024.0, 768.0); load_xml(&s, "Interface\\FrameXML\\Fonts.xml"); load_xml(&s, "MoneyFrame.xml"); + load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua"); load_xml(&s, "UiPanels.xml"); load_xml(&s, "GameTooltip.xml"); load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua"); @@ -1567,16 +1719,19 @@ fn share_quest_click_queues_the_selected_quests_id() { // an under-loaded list passes load_xml and then loses the wheel, the arrows and the bar // silently. load_xml(&s, "ScrollTemplates.xml"); - load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua"); - load_xml(&s, "QuestFrame.xml"); - load_xml(&s, "QuestLogFrame.xml"); + load_xml(&s, "Interface\\FrameXML\\BasicControls.xml"); + load_xml(&s, "UIParent.xml"); + load_xml(&s, "Interface\\FrameXML\\ItemButtonTemplate.xml"); + load_xml(&s, "Interface\\FrameXML\\QuestFrame.xml"); + load_xml(&s, "MicroMenu.xml"); + load_xml(&s, "Interface\\FrameXML\\QuestLogFrame.xml"); s.set_quest_log(entries_sharable(&[1, 5])); s.set_party(party(1)); s.run("ToggleQuestLog()").unwrap(); s.run("SelectQuestLogEntry(5)").unwrap(); - s.run("BenillaQuestLogFrame_Update()").unwrap(); + s.run("QuestLog_Update()").unwrap(); s.run("QuestFramePushQuestButton:Click()").unwrap(); assert_eq!(s.take_quest_log_pushes(), vec![5]); assert!(s.take_quest_log_pushes().is_empty(), "drained"); diff --git a/crates/benilla-app/src/ui_script/raid_tests.rs b/crates/benilla-app/src/ui_script/raid_tests.rs index f176faa4..2e2e3882 100644 --- a/crates/benilla-app/src/ui_script/raid_tests.rs +++ b/crates/benilla-app/src/ui_script/raid_tests.rs @@ -26,6 +26,7 @@ fn setup() -> UiScript { s.set_screen_size(1024.0, 768.0); load_xml(&s, "Interface\\FrameXML\\Fonts.xml"); load_xml(&s, "MoneyFrame.xml"); + load_xml(&s, r"Interface\FrameXML\GlobalStrings.lua"); load_xml(&s, "UiPanels.xml"); load_xml(&s, "UIParent.xml"); load_xml(&s, "GameTooltip.xml"); @@ -40,7 +41,6 @@ fn setup() -> UiScript { // drags in the whole unit-frame cluster — and it must be the REAL frames: loading only // `PartyFrame.lua` for the two names gets "attempt to index a nil value" the first time one // iterates. The manifest already seats these far above RaidFrame (1874). - load_xml(&s, r"Interface\FrameXML\GlobalStrings.lua"); // The reference's own `BasicControls.xml`, which is manifest entry 3 — far above the pane // (1874). `UIParentLoadAddOn` used to be what this line was here for; 1881 moved that // function to `UIParent.xml`, its home in the reference, and this harness already loads diff --git a/crates/benilla-app/src/ui_script/reference_ui.rs b/crates/benilla-app/src/ui_script/reference_ui.rs index 195f4941..61d88e2e 100644 --- a/crates/benilla-app/src/ui_script/reference_ui.rs +++ b/crates/benilla-app/src/ui_script/reference_ui.rs @@ -2047,6 +2047,12 @@ mod tests { ("SHOW_COMPARE_TOOLTIP", "PaperDollFrame.lua"), ("SYSMSG", "UIErrorsFrame.lua"), ("UNIT_DEFENSE", "PetPaperDollFrame.lua"), + ( + "UNIT_QUEST_LOG_CHANGED", + "QuestLogFrame.lua — a party member's quest-log fields changing (the reference \ + fires it off the unit's PLAYER_QUEST_LOG_* descriptor updates); benilla's unit \ + feed does not derive it yet (1944)", + ), ( "UNIT_MODEL_CHANGED", "four files — the paperdoll model refresh", @@ -2115,10 +2121,20 @@ mod tests { } let lines: Vec<&str> = text.lines().map(str::trim).collect(); for (i, t) in lines.iter().enumerate() { + // …or the value of a `match` arm (`QuestPanel::Progress => + // "QUEST_PROGRESS",` — `ui_quest.rs`'s `panel_event`). + let (t, arm_value) = match t.find("=> \"") { + Some(k) => (t[k + 3..].trim_end_matches(','), true), + None => (*t, false), + }; let Some(lit) = t.strip_prefix('"').and_then(|x| x.strip_suffix('"')) else { continue; }; + if arm_value && caps(lit) { + fired.insert(lit.to_string()); + continue; + } let arm = i > 0 && lines[i - 1].ends_with('{') && lines.get(i + 1).is_some_and(|n| n.starts_with('}')); diff --git a/crates/benilla-app/src/ui_script/shipped_xml_tests.rs b/crates/benilla-app/src/ui_script/shipped_xml_tests.rs index 12e32535..7a8274be 100644 --- a/crates/benilla-app/src/ui_script/shipped_xml_tests.rs +++ b/crates/benilla-app/src/ui_script/shipped_xml_tests.rs @@ -30,7 +30,7 @@ fn every_shipped_ui_xml_parses() { // Never let the sweep pass by finding nothing — a moved assets dir would otherwise turn this // into a test that guards zero files while staying green. assert!( - checked >= 40, + checked >= 38, // 41 until 1944 took the two quest files stock "only {checked} xml files swept — sweep broke" ); } @@ -753,7 +753,7 @@ fn no_shipped_script_sets_a_global_string_key_as_display_text() { } assert!(offenders.is_empty(), "{}", offenders.join("\n")); // The sweep must never pass by finding nothing to sweep. - assert!(swept >= 40, "only {swept} xml files swept — sweep broke"); + assert!(swept >= 38, "only {swept} xml files swept — sweep broke"); } /// **The `$parentTextureFrame` idiom's contract, over the whole shipped UI**: a frame whose art is diff --git a/crates/benilla-app/src/ui_script/spellbook_tests.rs b/crates/benilla-app/src/ui_script/spellbook_tests.rs index 9a5181ef..ae651c47 100644 --- a/crates/benilla-app/src/ui_script/spellbook_tests.rs +++ b/crates/benilla-app/src/ui_script/spellbook_tests.rs @@ -121,6 +121,7 @@ fn shipped_spellbook_drives_end_to_end() { s.set_screen_size(1024.0, 768.0); load_xml(&s, "Interface\\FrameXML\\Fonts.xml"); load_xml(&s, "MoneyFrame.xml"); + load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua"); load_xml(&s, "UiPanels.xml"); load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua"); load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml"); @@ -130,7 +131,6 @@ fn shipped_spellbook_drives_end_to_end() { load_xml(&s, "Interface\\FrameXML\\TextStatusBar.lua"); load_xml(&s, "Interface\\FrameXML\\TextStatusBar.xml"); load_xml(&s, "UIParent.xml"); - load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua"); load_xml(&s, "Interface\\FrameXML\\MainMenuBar.xml"); load_xml(&s, "Interface\\FrameXML\\ActionBarFrame.xml"); load_xml(&s, "Interface\\FrameXML\\BonusActionBarFrame.xml"); diff --git a/crates/benilla-app/src/ui_script/taxi_tests.rs b/crates/benilla-app/src/ui_script/taxi_tests.rs index 9e7b1c11..eb5debf1 100644 --- a/crates/benilla-app/src/ui_script/taxi_tests.rs +++ b/crates/benilla-app/src/ui_script/taxi_tests.rs @@ -16,6 +16,7 @@ fn taxi_script() -> UiScript { s.set_screen_size(1024.0, 768.0); load_xml(&s, "Interface\\FrameXML\\Fonts.xml"); load_xml(&s, "MoneyFrame.xml"); + load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua"); load_xml(&s, "UiPanels.xml"); load_xml(&s, "GameTooltip.xml"); // TaxiNodeOnButtonEnter's tooltip + SetTooltipMoney load_xml(&s, "Interface\\FrameXML\\UIErrorsFrame.xml"); // BenillaErrorsFrame_AddMessage — DrawOneHopLines' refusal @@ -25,7 +26,6 @@ fn taxi_script() -> UiScript { // · UIPanelTemplates (.lua then .xml) — `TaxiCloseButton` inherits `UIPanelCloseButton`, // which lives there and NOT in our UiPanels.xml. Without it the close button loads as a // bare Button with no handler and a click does nothing at all. - load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua"); load_xml(&s, "Interface\\FrameXML\\UIPanelTemplates.lua"); load_xml(&s, "Interface\\FrameXML\\UIPanelTemplates.xml"); load_xml(&s, "Interface\\FrameXML\\TaxiFrame.xml"); diff --git a/crates/benilla-app/src/ui_script/trainer_tests.rs b/crates/benilla-app/src/ui_script/trainer_tests.rs index 2674fc28..9c9558e2 100644 --- a/crates/benilla-app/src/ui_script/trainer_tests.rs +++ b/crates/benilla-app/src/ui_script/trainer_tests.rs @@ -26,6 +26,7 @@ fn trainer_script() -> UiScript { s.set_screen_size(1024.0, 768.0); load_xml(&s, "Interface\\FrameXML\\Fonts.xml"); load_xml(&s, "MoneyFrame.xml"); + load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua"); load_xml(&s, "UiPanels.xml"); load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua"); load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml"); @@ -33,7 +34,6 @@ fn trainer_script() -> UiScript { load_xml(&s, "Interface\\FrameXML\\UIDropDownMenu.xml"); // the filter dropdown's kit load_xml(&s, "ScrollTemplates.xml"); // the faux-scroll bar kit load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml"); // the merchant kit; the money frames are MoneyFrame.xml's (1937) - load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua"); load_xml(&s, "TrainerFrame.xml"); s.run( "TRAINER_FILTER_AVAILABLE = 1 TRAINER_FILTER_UNAVAILABLE = 1 TRAINER_FILTER_USED = 1 \ diff --git a/crates/benilla-app/src/ui_script/unit_frame_tests.rs b/crates/benilla-app/src/ui_script/unit_frame_tests.rs index a32e12eb..08e086ae 100644 --- a/crates/benilla-app/src/ui_script/unit_frame_tests.rs +++ b/crates/benilla-app/src/ui_script/unit_frame_tests.rs @@ -646,13 +646,19 @@ fn shipped_target_frame_runs_the_level_law() { load_unit_frames(&s); // GetDifficultyColor's own load chain (the quest log window, its ref home). load_xml(&s, "MoneyFrame.xml"); + load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua"); load_xml(&s, "UiPanels.xml"); load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua"); load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml"); load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml"); - load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua"); - load_xml(&s, "QuestFrame.xml"); - load_xml(&s, "QuestLogFrame.xml"); + load_xml(&s, "Interface\\FrameXML\\Fonts.xml"); + load_xml(&s, "Interface\\FrameXML\\BasicControls.xml"); + load_xml(&s, "UIParent.xml"); + load_xml(&s, "Interface\\FrameXML\\ItemButtonTemplate.xml"); + load_xml(&s, "Interface\\FrameXML\\QuestFrame.xml"); + load_xml(&s, "Interface\\FrameXML\\Fonts.xml"); + load_xml(&s, "MicroMenu.xml"); + load_xml(&s, "Interface\\FrameXML\\QuestLogFrame.xml"); // The player at level 3, both feeds (the snapshot UnitLevel("player") reads; the req state // the −1 gate and GetQuestGreenRange read) — the app keeps the two in step. s.set_player_req_state(PlayerReqState { diff --git a/crates/benilla-app/src/ui_script/unit_popup_tests.rs b/crates/benilla-app/src/ui_script/unit_popup_tests.rs index 8a03bf8e..0b45350d 100644 --- a/crates/benilla-app/src/ui_script/unit_popup_tests.rs +++ b/crates/benilla-app/src/ui_script/unit_popup_tests.rs @@ -466,6 +466,7 @@ fn load_pet_menu_frames(s: &UiScript) { "Interface\\FrameXML\\Fonts.xml", "UIParent.xml", "MoneyFrame.xml", + "Interface\\FrameXML\\GlobalStrings.lua", "UiPanels.xml", r"Interface\FrameXML\UIPanelTemplates.lua", r"Interface\FrameXML\UIPanelTemplates.xml", @@ -483,7 +484,6 @@ fn load_pet_menu_frames(s: &UiScript) { "Interface\\FrameXML\\PetFrame.xml", "Cooldown.xml", "Interface\\FrameXML\\ActionButtonTemplate.xml", - "Interface\\FrameXML\\GlobalStrings.lua", "Interface\\FrameXML\\MainMenuBar.xml", "Interface\\FrameXML\\ActionBarFrame.xml", "Interface\\FrameXML\\BonusActionBarFrame.xml", diff --git a/crates/benilla-app/tests/mail_frame.rs b/crates/benilla-app/tests/mail_frame.rs index cf329162..a07b2a3b 100644 --- a/crates/benilla-app/tests/mail_frame.rs +++ b/crates/benilla-app/tests/mail_frame.rs @@ -19,11 +19,11 @@ const FILES: [&str; 10] = [ // the `MoneyInputFrame_*` verbs. Seated straight after MoneyFrame.xml, benilla.toc's order. r"Interface\FrameXML\MoneyInputFrame.lua", r"Interface\FrameXML\MoneyInputFrame.xml", + "Interface\\FrameXML\\GlobalStrings.lua", "UiPanels.xml", r"Interface\FrameXML\UIPanelTemplates.lua", r"Interface\FrameXML\UIPanelTemplates.xml", "GameTooltip.xml", - "Interface\\FrameXML\\GlobalStrings.lua", "MailFrame.xml", ]; diff --git a/crates/benilla-formats/src/page_text_material.rs b/crates/benilla-formats/src/page_text_material.rs index f70f2415..91a67f26 100644 --- a/crates/benilla-formats/src/page_text_material.rs +++ b/crates/benilla-formats/src/page_text_material.rs @@ -33,6 +33,14 @@ pub struct PageTextMaterialCatalog { } impl PageTextMaterialCatalog { + /// A catalog from `(id, basename)` rows — the shipped six are 1 Parchment · 2 Stone · 3 Marble + /// · 4 Silver · 5 Bronze · 6 Valentine (wow-re quest-material-reward-spell-bindings.md §1). + pub fn from_rows(rows: &[(u32, &str)]) -> Self { + Self { + by_id: rows.iter().map(|&(id, n)| (id, n.to_string())).collect(), + } + } + /// The basename for a material id, or `None` for `0`/an id the table doesn't carry — the /// reference's own `nil`, which `ItemTextFrame.lua` substitutes with "Parchment" (and then /// hides the corner art, since Parchment has none). diff --git a/crates/benilla-protocol/src/guid.rs b/crates/benilla-protocol/src/guid.rs index 0f838c57..9c16e366 100644 --- a/crates/benilla-protocol/src/guid.rs +++ b/crates/benilla-protocol/src/guid.rs @@ -72,6 +72,10 @@ pub fn is_transport(guid: u64) -> bool { matches!(high(guid), HIGH_MO_TRANSPORT | HIGH_TRANSPORT) } +pub fn is_gameobject(guid: u64) -> bool { + high(guid) == HIGH_GAMEOBJECT +} + /// The embedded template entry, for the guid families that carry one. Two different layouts, both /// VERIFIED against vmangos: /// diff --git a/crates/benilla-ui/src/script/mod.rs b/crates/benilla-ui/src/script/mod.rs index 30d1e326..c10a7d72 100644 --- a/crates/benilla-ui/src/script/mod.rs +++ b/crates/benilla-ui/src/script/mod.rs @@ -207,7 +207,9 @@ pub use model::{TextureProbe, TextureSizeProbe}; pub use party::{PartyMemberInfo, PartyRequest, PartyState, RaidMemberInfo, SavedInstanceInfo}; pub use pet::{PetActionView, PetStats}; pub use pvp::{HonorState, InspectHonorData}; -pub use quest::{QuestAction, QuestItemView, QuestPanel, QuestSelect, QuestState}; +pub use quest::{ + QuestAction, QuestItemView, QuestPanel, QuestRewardSpell, QuestSelect, QuestState, +}; pub use quest_log::{QuestLogDetail, QuestLogEntryView, QuestLogObjectiveView, QuestLogState}; pub(crate) use region::{apply_font_parts, implicit_creation_anchor_lua}; pub use reputation::{FactionEntry, ReputationSend, ReputationState}; diff --git a/crates/benilla-ui/src/script/quest.rs b/crates/benilla-ui/src/script/quest.rs index d0018a7e..b83d7c7e 100644 --- a/crates/benilla-ui/src/script/quest.rs +++ b/crates/benilla-ui/src/script/quest.rs @@ -68,6 +68,20 @@ pub struct QuestItemView { /// One open questgiver panel: the active sub-panel plus every field the four panels might read. /// Pushed whole by the app; `None` means no quest window is open. +/// The quest's reward spell — `rewSpell` on `SMSG_QUESTGIVER_QUEST_DETAILS` / +/// `SMSG_QUESTGIVER_OFFER_REWARD` and on `SMSG_QUEST_QUERY_RESPONSE` — resolved by the app to +/// the name and icon `GetRewardSpell` / `GetQuestLogRewardSpell` answer (stock +/// `QuestFrameItems_Update` counts it as one more reward slot, `rewardType = "spell"`, and the +/// slot's hover is `GameTooltip:SetQuestRewardSpell()`). `tradeskill` is the third return the +/// reference derives from the spell itself (1944 — the derivation is wow-re's to pin). +#[derive(Clone, Debug, Default, PartialEq)] +pub struct QuestRewardSpell { + pub spell_id: u32, + pub name: Option, + pub texture: Option, + pub tradeskill: bool, +} + #[derive(Clone, Debug, PartialEq)] pub struct QuestState { pub panel: QuestPanel, @@ -95,6 +109,12 @@ pub struct QuestState { pub required_money: u32, /// Whether the turn-in is completable (`IsQuestCompletable`, progress panel). pub completable: bool, + /// The reward spell, if the quest teaches one (`GetRewardSpell`; detail/reward panels). + pub reward_spell: Option, + /// The panel's background material (`GetQuestBackgroundMaterial` — nil is the reference's own + /// "Parchment" fallback in `QuestFrame_GetMaterial`). What fills it on the wire is 1944's + /// wow-re question; until the binary says, nothing does, and the verb answers nil honestly. + pub background_material: Option, } impl Default for QuestState { @@ -113,6 +133,8 @@ impl Default for QuestState { reward_money: 0, required_money: 0, completable: false, + reward_spell: None, + background_material: None, } } } @@ -170,7 +192,36 @@ impl super::UiScript { } /// `GetQuestItemInfo(type, index)` reads from this vector by `type`; unknown types → `None`. -fn item_vec<'a>(state: &'a QuestState, kind: &str) -> Option<&'a Vec> { +/// The three returns of `GetRewardSpell` / `GetQuestLogRewardSpell`: `texture, name, +/// isTradeskillSpell` — the third a 1/nil boolean in the reference's convention. +pub(super) fn reward_spell_returns( + lua: &Lua, + spell: Option, +) -> mlua::Result { + let Some(sp) = spell else { + return Ok(MultiValue::from_vec(vec![ + Value::Nil, + Value::Nil, + Value::Nil, + ])); + }; + let texture = match &sp.texture { + Some(t) => Value::String(lua.create_string(t)?), + None => Value::Nil, + }; + let name = match &sp.name { + Some(n) => Value::String(lua.create_string(n)?), + None => Value::Nil, + }; + let tradeskill = if sp.tradeskill { + Value::Integer(1) + } else { + Value::Nil + }; + Ok(MultiValue::from_vec(vec![texture, name, tradeskill])) +} + +pub(super) fn item_vec<'a>(state: &'a QuestState, kind: &str) -> Option<&'a Vec> { match kind { "choice" => Some(&state.choices), "reward" => Some(&state.rewards), @@ -315,12 +366,19 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { })?, )?; - // GetRewardSpell() → nil (spell-reward rows out of scope in v1, decision 0088). + // GetRewardSpell() → texture, name, isTradeskillSpell (three returns, 0x501df0; three nils on + // every empty path, the reference's own `(nil,nil,nil)` kind — 1842). Real since 1944: the + // app resolves `rewSpell` off the giver packets; stock's `if ( GetRewardSpell() ) then` counts + // the slot. g.set( "GetRewardSpell", - // THREE values on every reachable path, and the reference's own kinds include - // `(nil,nil,nil)` — so the empty answer is three nils, not one (decision 1842). - lua.create_function(|_, ()| Ok((Value::Nil, Value::Nil, Value::Nil)))?, + lua.create_function(|lua, ()| { + let spell = { + let model = lua.app_data_ref::().expect("model app_data"); + model.quest.as_ref().and_then(|q| q.reward_spell.clone()) + }; + reward_spell_returns(lua, spell) + })?, )?; // ── Intents ─────────────────────────────────────────────────────────────────────────────────── @@ -357,6 +415,57 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { install_action(lua, "CloseQuest", super::QuestAction::Close)?; install_action(lua, "CompleteQuest", super::QuestAction::Continue)?; + // GetQuestBackgroundMaterial() → nil | string (0x502230; 0 args, 1 return). The reference's + // `QuestFrame_GetMaterial` (QuestFrame.lua:597-603) treats nil as "Parchment" and any string + // as the name of a material whose four `$parentMaterial*` quadrants and text colours + // (`GetMaterialTextColors`) the panel switches to. Answered from the pushed panel state. + g.set( + "GetQuestBackgroundMaterial", + lua.create_function(|lua, ()| { + let material = { + let model = lua.app_data_ref::().expect("model app_data"); + model + .quest + .as_ref() + .and_then(|q| q.background_material.clone()) + }; + match material { + Some(m) => Ok(Value::String(lua.create_string(&m)?)), + None => Ok(Value::Nil), + } + })?, + )?; + + // QuestChooseRewardError() — 0x5021a0, 0 args, 0 returns: the Complete button pressed with + // choices on offer and none picked (stock `QuestRewardCompleteButton_OnClick`, + // QuestFrame.lua:96-100). Sixteen bytes — `push 0x98; call 0x496720` — the game-error row + // `ERR_QUEST_MUST_CHOOSE` (wow-re quest-material-reward-spell-bindings.md §2): the row's sound + // cue `igQuestFailed` plays first, then kind 2 routes the GlobalStrings text through + // `UI_ERROR_MESSAGE` — synchronously, as SignalEvent is — and UIErrorsFrame prints it. The + // text is read from the same global the FrameXML shows, so a localised chain shows its own + // string; the shipped English is the fallback when no GlobalStrings is loaded. + g.set( + "QuestChooseRewardError", + lua.create_function(|lua, ()| { + { + let mut model = lua.app_data_mut::().expect("model app_data"); + model + .sound_queue + .push(super::SoundRequest::KitName("igQuestFailed".to_string())); + } + let text: String = lua + .globals() + .get::>("ERR_QUEST_MUST_CHOOSE")? + .unwrap_or_else(|| "You must choose a reward.".to_string()); + super::tick::fire_event_into( + lua, + "UI_ERROR_MESSAGE", + vec![super::ScriptValue::Str(text)], + ); + Ok(()) + })?, + )?; + // ConfirmAcceptQuest() — the escort confirm's Yes (ref StaticPopup.lua:731-733, raised by // QUEST_ACCEPT_CONFIRM). No argument and no quest id: the client answers whichever confirm it // is holding, so the engine counts the calls and the app supplies the id (decision 1733). @@ -369,16 +478,19 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { })?, )?; - // GetQuestReward(choice) — the reward panel's Complete button: queue the chosen choice index - // (Era passes the 1-based item id; the app maps it to the 0-based wire index). A quest with no - // choice rewards passes 0/nil. + // GetQuestReward(choice) — the reward panel's Complete button. FrameXML passes the 1-based row + // (`QuestFrameRewardPanel.itemChoice = this:GetID()`, stock QuestFrame.lua:97-101) and the + // wire wants the 0-based choice (`CMSG_QUESTGIVER_CHOOSE_REWARD`'s reward index — vmangos + // indexes `RewChoiceItemId[reward]` with it), so the conversion is the binding's, as it is the + // client's; our own Lua used to do the -1 itself (1944). A quest with no choice rewards + // passes 0, which stays 0. g.set( "GetQuestReward", lua.create_function(|lua, choice: Option| { let mut model = lua.app_data_mut::().expect("model app_data"); - model - .quest_actions - .push(super::QuestAction::Reward(choice.unwrap_or(0))); + model.quest_actions.push(super::QuestAction::Reward( + choice.unwrap_or(0).saturating_sub(1), + )); Ok(()) })?, )?; @@ -431,6 +543,43 @@ mod tests { } } + #[test] + fn background_material_answers_the_pushed_material_or_nil() { + let mut s = UiScript::new().unwrap(); + s.set_quest(Some(QuestState::default())); + assert!(s + .eval::("return GetQuestBackgroundMaterial() == nil") + .unwrap()); + s.set_quest(Some(QuestState { + background_material: Some("Stone".into()), + ..QuestState::default() + })); + assert_eq!( + s.eval::("return GetQuestBackgroundMaterial()") + .unwrap(), + "Stone" + ); + } + + #[test] + fn choose_reward_error_fires_the_error_event_with_the_global_string() { + let mut s = UiScript::new().unwrap(); + s.run( + "ERR_QUEST_MUST_CHOOSE = 'Pick one.' \ + local f = CreateFrame('Frame') f:RegisterEvent('UI_ERROR_MESSAGE') \ + f:SetScript('OnEvent', function() SEEN = arg1 end) \ + N = table.getn({QuestChooseRewardError()})", + ) + .unwrap(); + assert_eq!(s.eval::("return SEEN").unwrap(), "Pick one."); + assert_eq!(s.eval::("return N").unwrap(), 0, "zero return values"); + assert_eq!( + s.take_sounds(), + vec![crate::script::SoundRequest::KitName("igQuestFailed".into())], + "row 0x98's cue plays with the message" + ); + } + #[test] fn detail_panel_getters_read() { let mut s = UiScript::new().unwrap(); @@ -572,14 +721,14 @@ mod tests { assert_eq!(s.take_quest_actions(), vec![QuestAction::Accept]); s.run("CompleteQuest()").unwrap(); // progress -> reward - s.run("GetQuestReward(2)").unwrap(); // choose reward index 2 + s.run("GetQuestReward(2)").unwrap(); // row 2 (1-based) → wire choice 1 (0-based) s.run("GetQuestReward()").unwrap(); // no-choice quest -> 0 s.run("DeclineQuest()").unwrap(); assert_eq!( s.take_quest_actions(), vec![ QuestAction::Continue, - QuestAction::Reward(2), + QuestAction::Reward(1), QuestAction::Reward(0), QuestAction::Close, ] diff --git a/crates/benilla-ui/src/script/quest_log.rs b/crates/benilla-ui/src/script/quest_log.rs index 6801197e..a8bd15a5 100644 --- a/crates/benilla-ui/src/script/quest_log.rs +++ b/crates/benilla-ui/src/script/quest_log.rs @@ -137,6 +137,9 @@ pub struct QuestLogDetail { pub choices: Vec, /// Fixed rewards (`GetNumQuestLogRewards`/`GetQuestLogRewardInfo`). pub rewards: Vec, + /// The selected quest's reward spell (`rewSpell` on `SMSG_QUEST_QUERY_RESPONSE`), as + /// `GetQuestLogRewardSpell` answers it. + pub reward_spell: Option, } /// A quest-log reward row is the same shape as a questgiver panel row. @@ -687,11 +690,21 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { )?; // ── v1 stubs (each the seam of a named later slice — see the module doc) ───────────────────── + // GetQuestLogRewardSpell() → texture, name, isTradeskillSpell (0x4e1130): the selected + // quest's reward spell, three nils when it has none. g.set( "GetQuestLogRewardSpell", - // THREE values on every reachable path, and the reference's own kinds include - // `(nil,nil,nil)` — so the empty answer is three nils, not one (decision 1842). - lua.create_function(|_, ()| Ok((Value::Nil, Value::Nil, Value::Nil)))?, + lua.create_function(|lua, ()| { + let spell = { + let model = lua.app_data_ref::().expect("model app_data"); + model + .quest_log + .detail + .as_ref() + .and_then(|d| d.reward_spell.clone()) + }; + super::quest::reward_spell_returns(lua, spell) + })?, )?; g.set( "IsUnitOnQuest", @@ -874,6 +887,7 @@ mod tests { usable: true, link: Some("|cffffffff|Hitem:2024:0:0:0|h[Militia Hammer]|h|r".into()), }], + reward_spell: None, }), } } diff --git a/crates/benilla-ui/src/script/scrollframe.rs b/crates/benilla-ui/src/script/scrollframe.rs index 9377fc2b..4c03a7c2 100644 --- a/crates/benilla-ui/src/script/scrollframe.rs +++ b/crates/benilla-ui/src/script/scrollframe.rs @@ -130,6 +130,29 @@ fn subtree_span(model: &Model, f: FrameHandle) -> Option<(f32, f32)> { span } +/// Every FontString under `f` (its own regions, then its visible children's), in tree order. +fn subtree_font_strings(model: &Model, f: FrameHandle) -> Vec { + let mut out = Vec::new(); + let Some(frame) = model.arena.frame(f) else { + return out; + }; + for &rh in &frame.regions { + if model + .arena + .region(rh) + .is_some_and(|r| matches!(r.kind, crate::widget::RegionKind::FontString)) + { + out.push(rh); + } + } + for &ch in &frame.children { + if model.arena.frame(ch).is_some_and(|c| c.effective_visible) { + out.extend(subtree_font_strings(model, ch)); + } + } + out +} + pub(super) fn install(lua: &Lua) -> mlua::Result<()> { let m = lua.create_table()?; @@ -229,6 +252,26 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { "UpdateScrollChildRect", lua.create_function(|lua, this: Table| { let h = frame_handle_of(lua, &this)?; + // The reference derives the range from a child whose text is already laid out — its + // measure is synchronous. Ours measures on the metric reads and on the host's + // round-trip, which lands a frame later; stock `QuestLog_UpdateQuestDetails` calls + // this right after the SetTexts that fill the pane, and read a range of 0 (1944). So + // the child's FontStrings are measured here, through the installed measurer, before + // the layout that the range reads; with no measurer installed the round-trip fills + // them as before. + let strings: Vec = { + let model = lua.app_data_ref::().expect("model app_data"); + match model.arena.frame(h).map(|f| &f.kind_state) { + Some(KindState::Scroll(sf)) => sf + .child + .map(|c| subtree_font_strings(&model, c)) + .unwrap_or_default(), + _ => Vec::new(), + } + }; + for rh in strings { + super::measure::ensure_measured(lua, rh); + } let (id, range) = { let mut model = lua.app_data_mut::().expect("model app_data"); super::UiScript::resolve_layout(&mut model); diff --git a/crates/benilla-ui/src/script/tests/scrollframe.rs b/crates/benilla-ui/src/script/tests/scrollframe.rs index 0e49b1e8..d9951e13 100644 --- a/crates/benilla-ui/src/script/tests/scrollframe.rs +++ b/crates/benilla-ui/src/script/tests/scrollframe.rs @@ -490,3 +490,43 @@ fn hit_test_denies_a_button_clipped_out_and_admits_it_once_scrolled_into_view() "scrolled into view, the button hits again" ); } + +/// `UpdateScrollChildRect` right after the SetTexts that fill a pane reads a range that already +/// counts the wrapped text — the reference's measure is synchronous, and with a font engine +/// installed so is ours (1944; stock `QuestLog_UpdateQuestDetails` is the caller that found it). +#[test] +fn update_scroll_child_rect_measures_the_childs_text_before_it_reads_the_range() { + struct Rows; + impl crate::script::TextMeasure for Rows { + fn measure(&mut self, req: &crate::script::MeasureRequest) -> (f32, f32, f32) { + let natural = req.text.chars().count() as f32 * 7.0; + match req.wrap_width { + Some(w) if w > 0.0 && natural > w => (w, (natural / w).ceil() * 14.0, natural), + _ => (natural, 14.0, natural), + } + } + } + let mut s = script(); + s.set_screen_size(800.0, 600.0); + s.set_text_measurer(Box::new(Rows)); + s.run( + r#" + local sf = CreateFrame("ScrollFrame", "SF", UIParent) + sf:SetPoint("TOPLEFT", 0, 0); sf:SetSize(100, 50) + local child = CreateFrame("Frame", "SFChild", sf) + child:SetSize(100, 50); sf:SetScrollChild(child) + local fs = child:CreateFontString("SFText", "ARTWORK") + fs:SetPoint("TOPLEFT", 0, 0); fs:SetWidth(70) + sf:SetScript("OnScrollRangeChanged", function() RANGE = arg2 end) + fs:SetText(string.rep("x", 40)) -- 280px of ink over a 70px column: four 14px rows + sf:UpdateScrollChildRect() + "#, + ) + .unwrap(); + assert!(s.errors().is_empty(), "script errors: {:?}", s.errors()); + assert_eq!( + s.eval::("return RANGE").unwrap(), + 6.0, + "56px of measured text under a 50px frame: the range is known at the call, not a frame later" + ); +} diff --git a/crates/benilla-ui/src/script/tests/tooltip_item/set.rs b/crates/benilla-ui/src/script/tests/tooltip_item/set.rs index 260d261b..d853536c 100644 --- a/crates/benilla-ui/src/script/tests/tooltip_item/set.rs +++ b/crates/benilla-ui/src/script/tests/tooltip_item/set.rs @@ -166,3 +166,67 @@ fn item_set_block_counts_and_colors() { assert_eq!(s.take_item_set_asks(), vec![1], "the set ask recorded"); assert!(s.take_errors().is_empty()); } + +/// `GameTooltip:SetQuestItem(type, index)` and `SetQuestLogItem(type, index)` — the quest-giver +/// panels' and the quest log's reward hovers (stock QuestFrameTemplates.xml:148, +/// QuestLogFrame.xml:113): the row's item by id through the shared renderer; a row that is not +/// there leaves the tooltip empty (1944). +#[test] +fn quest_item_hovers_render_the_rows_item_by_id() { + let mut s = script(); + s.set_screen_size(800.0, 600.0); + s.set_item_template( + 2024, + ItemTemplateView { + name: "Militia Hammer".into(), + quality: 1, + ..Default::default() + }, + ); + let row = || QuestItemView { + item_id: 2024, + name: Some("Militia Hammer".into()), + quality: 1, + ..Default::default() + }; + s.set_quest(Some(QuestState { + panel: QuestPanel::Detail, + choices: vec![row()], + ..QuestState::default() + })); + s.run( + r#" + local a = CreateFrame("Button", "Q1"); a:SetPoint("CENTER", 0, 0); a:SetSize(10, 10) + local tt = CreateFrame("GameTooltip", "TT") + tt:SetOwner(a, "ANCHOR_RIGHT"); tt:SetQuestItem("choice", 1) + "#, + ) + .unwrap(); + assert_eq!(lines_of(&mut s)[0].0, "Militia Hammer"); + + s.run(r#"TT:SetOwner(Q1, "ANCHOR_RIGHT"); TT:SetQuestItem("choice", 2)"#) + .unwrap(); + assert_eq!( + s.eval::("return TT:NumLines()").unwrap(), + 0, + "an out-of-range row shows nothing" + ); + s.run(r#"TT:SetOwner(Q1, "ANCHOR_RIGHT"); TT:SetQuestItem("reward", 1)"#) + .unwrap(); + assert_eq!(s.eval::("return TT:NumLines()").unwrap(), 0); + + s.set_quest_log(QuestLogState { + detail: Some(QuestLogDetail { + rewards: vec![row()], + ..Default::default() + }), + ..Default::default() + }); + s.run(r#"TT:SetOwner(Q1, "ANCHOR_RIGHT"); TT:SetQuestLogItem("reward", 1)"#) + .unwrap(); + assert_eq!(lines_of(&mut s)[0].0, "Militia Hammer"); + s.run(r#"TT:SetOwner(Q1, "ANCHOR_RIGHT"); TT:SetQuestLogItem("choice", 1)"#) + .unwrap(); + assert_eq!(s.eval::("return TT:NumLines()").unwrap(), 0); + assert!(s.errors().is_empty(), "script errors: {:?}", s.errors()); +} diff --git a/crates/benilla-ui/src/script/tests/tooltip_spell.rs b/crates/benilla-ui/src/script/tests/tooltip_spell.rs index 41317931..1d64889a 100644 --- a/crates/benilla-ui/src/script/tests/tooltip_spell.rs +++ b/crates/benilla-ui/src/script/tests/tooltip_spell.rs @@ -992,3 +992,86 @@ fn the_remaining_line_is_gated_on_until_cancelled_not_on_the_duration() { ); assert!(s.take_errors().is_empty()); } + +/// `GetRewardSpell()` / `GetQuestLogRewardSpell()` answer `texture, name, isTradeskillSpell` — +/// three nils when the quest teaches nothing (the reference's own `(nil,nil,nil)` kind) — and +/// `GameTooltip:SetQuestRewardSpell()` / `SetQuestLogRewardSpell()` render that spell (1944). +#[test] +fn quest_reward_spell_getters_and_hovers() { + let mut s = script(); + s.set_screen_size(800.0, 600.0); + s.set_spell_tooltip(133, fireball()); + s.run( + r#" + local a = CreateFrame("Button", "Q1"); a:SetPoint("CENTER", 0, 0); a:SetSize(10, 10) + local tt = CreateFrame("GameTooltip", "TT") + "#, + ) + .unwrap(); + + s.set_quest(Some(QuestState::default())); + assert_eq!( + s.eval::("return select('#', GetRewardSpell())") + .unwrap(), + 3, + "three values on the empty path" + ); + assert!(s + .eval::( + "local t, n, ts = GetRewardSpell() return t == nil and n == nil and ts == nil" + ) + .unwrap()); + s.run(r#"TT:SetOwner(Q1, "ANCHOR_RIGHT"); TT:SetQuestRewardSpell()"#) + .unwrap(); + assert_eq!(s.eval::("return TT:NumLines()").unwrap(), 0); + + let reward = QuestRewardSpell { + spell_id: 133, + name: Some("Fireball".into()), + texture: Some("Interface\\Icons\\Spell_Fire_FlameBolt".into()), + tradeskill: false, + }; + s.set_quest(Some(QuestState { + panel: QuestPanel::Reward, + reward_spell: Some(reward.clone()), + ..QuestState::default() + })); + assert_eq!( + s.eval::<(String, String, Option)>("return GetRewardSpell()") + .unwrap(), + ( + "Interface\\Icons\\Spell_Fire_FlameBolt".to_string(), + "Fireball".to_string(), + None + ) + ); + s.run(r#"TT:SetOwner(Q1, "ANCHOR_RIGHT"); TT:SetQuestRewardSpell()"#) + .unwrap(); + s.resolve(); + assert_eq!( + s.eval::("return TTTextLeft1:GetText()").unwrap(), + "Fireball" + ); + + s.set_quest_log(QuestLogState { + detail: Some(QuestLogDetail { + reward_spell: Some(reward), + ..Default::default() + }), + ..Default::default() + }); + assert_eq!( + s.eval::<(String, String, Option)>("return GetQuestLogRewardSpell()") + .unwrap() + .1, + "Fireball" + ); + s.run(r#"TT:SetOwner(Q1, "ANCHOR_RIGHT"); TT:SetQuestLogRewardSpell()"#) + .unwrap(); + s.resolve(); + assert_eq!( + s.eval::("return TTTextLeft1:GetText()").unwrap(), + "Fireball" + ); + assert!(s.errors().is_empty(), "script errors: {:?}", s.errors()); +} diff --git a/crates/benilla-ui/src/script/tooltip_item/mod.rs b/crates/benilla-ui/src/script/tooltip_item/mod.rs index 1678a10d..9a23a1d2 100644 --- a/crates/benilla-ui/src/script/tooltip_item/mod.rs +++ b/crates/benilla-ui/src/script/tooltip_item/mod.rs @@ -159,6 +159,26 @@ fn render_by_id( /// (rings, trinkets, one-hand weapons, and a two-hander displacing both hands) fire two /// shopping tooltips, ref PaperDollFrame.lua's `arg2`. Twin of the app-side /// `ui_items::find_equip_slot` (the equip-click fit rule) — one law, two consumers. +/// The quest-item hover: the row's item by id, or an empty tooltip when the row is not there. +fn set_quest_item_view( + lua: &Lua, + this: &Table, + item: Option, +) -> mlua::Result<()> { + match item { + Some(it) => render_by_id(lua, this, it.item_id, it.name.clone(), Some(it.quality)), + None => { + let h = frame_handle_of(lua, this)?; + { + let mut model = lua.app_data_mut::().expect("model app_data"); + clear_content(&mut model, h); + } + super::tooltip::show_or_hide_empty(lua, h); + Ok(()) + } + } +} + fn equip_slots_for(inventory_type: u32) -> &'static [u32] { match inventory_type { 1 => &[1], // head @@ -292,6 +312,46 @@ pub(super) fn install_methods(lua: &Lua, m: &Table) -> mlua::Result<()> { )?, )?; + // GameTooltip:SetQuestItem(type, index) / SetQuestLogItem(type, index) — the quest-giver + // panels' and the quest log's reward/choice/required hovers (stock QuestFrameTemplates.xml:148, + // QuestLogFrame.xml:113; bindings 0x533610 / 0x533760, 3 args with self, 0 returns). `type` is + // the same "choice" | "reward" | "required" key `GetQuestItemInfo` takes, `index` the 1-based + // slot; the item is rendered by id through the shared renderer, with the row's own name and + // quality as the fallbacks a template that has not arrived yet paints from. An unknown type or + // an out-of-range index shows nothing — the reference's tooltip stays empty too. + m.set( + "SetQuestItem", + lua.create_function(|lua, (this, kind, index): (Table, String, usize)| { + let item = { + let model = lua.app_data_ref::().expect("model app_data"); + model.quest.as_ref().and_then(|q| { + crate::script::quest::item_vec(q, &kind) + .and_then(|v| index.checked_sub(1).and_then(|n| v.get(n))) + .cloned() + }) + }; + set_quest_item_view(lua, &this, item) + })?, + )?; + m.set( + "SetQuestLogItem", + lua.create_function(|lua, (this, kind, index): (Table, String, usize)| { + let item = { + let model = lua.app_data_ref::().expect("model app_data"); + model.quest_log.detail.as_ref().and_then(|d| { + let v = match kind.as_str() { + "choice" => Some(&d.choices), + "reward" => Some(&d.rewards), + _ => None, + }; + v.and_then(|v| index.checked_sub(1).and_then(|n| v.get(n))) + .cloned() + }) + }; + set_quest_item_view(lua, &this, item) + })?, + )?; + // GameTooltip:SetHyperlink(link) — the chat-link tooltip (ref ItemRef.lua's SetItemRef → // ItemRefTooltip:SetHyperlink). Accepts the full escaped link or a bare "item:"; // non-item links (player/spell/quest) have no tooltip surface yet and no-op. diff --git a/crates/benilla-ui/src/script/tooltip_spell.rs b/crates/benilla-ui/src/script/tooltip_spell.rs index be537e6c..f654b83a 100644 --- a/crates/benilla-ui/src/script/tooltip_spell.rs +++ b/crates/benilla-ui/src/script/tooltip_spell.rs @@ -318,6 +318,27 @@ pub(super) fn set_spell_with_talent( /// Shared entry: clear, render (or record the ask and show nothing but the name if the caller /// knows one), show. +/// The reward-spell hover: the spell by id with the quest's own name as the fallback, or an +/// empty tooltip. +fn set_reward_spell( + lua: &Lua, + this: &Table, + spell: Option<(u32, Option)>, +) -> mlua::Result<()> { + match spell { + Some((id, name)) => set_spell_by_id(lua, this, id, name, SpellRenderOpts::default(), None), + None => { + let h = frame_handle_of(lua, this)?; + { + let mut model = lua.app_data_mut::().expect("model app_data"); + clear_content(&mut model, h); + } + super::tooltip::show_or_hide_empty(lua, h); + Ok(()) + } + } +} + fn set_spell_by_id( lua: &Lua, this: &Table, @@ -614,6 +635,39 @@ pub(super) fn install_methods(lua: &Lua, m: &Table) -> mlua::Result<()> { // table). Not the spell title's gold, and nothing about the bound spell: 1.12 has no // `#showtooltip`. This arm was a `_ => Ok(())` left from before 0983 shipped macros, which is // why a macro on the bar hovered to nothing (the director, 2026-08-27, after 1636). + // GameTooltip:SetQuestRewardSpell() / SetQuestLogRewardSpell() — the hover of the reward + // slot stock marks `rewardType = "spell"` (QuestFrameTemplates.xml:150, QuestLogFrame.xml:115; + // bindings 0x535bb0 / 0x535c60, self only, 0 returns): the quest's reward spell by id through + // the spell renderer, an empty tooltip when the quest rewards none. + m.set( + "SetQuestRewardSpell", + lua.create_function(|lua, this: Table| { + let id = { + let model = lua.app_data_ref::().expect("model app_data"); + model + .quest + .as_ref() + .and_then(|q| q.reward_spell.as_ref()) + .map(|s| (s.spell_id, s.name.clone())) + }; + set_reward_spell(lua, &this, id) + })?, + )?; + m.set( + "SetQuestLogRewardSpell", + lua.create_function(|lua, this: Table| { + let id = { + let model = lua.app_data_ref::().expect("model app_data"); + model + .quest_log + .detail + .as_ref() + .and_then(|d| d.reward_spell.as_ref()) + .map(|s| (s.spell_id, s.name.clone())) + }; + set_reward_spell(lua, &this, id) + })?, + )?; m.set( "SetAction", lua.create_function(|lua, (this, slot): (Table, u32)| { diff --git a/crates/benilla-world/src/model_render.rs b/crates/benilla-world/src/model_render.rs index ff514415..1037a2a1 100644 --- a/crates/benilla-world/src/model_render.rs +++ b/crates/benilla-world/src/model_render.rs @@ -1192,6 +1192,15 @@ pub struct ModelPart { pub blend: ModelBlend, } +/// **Why this submesh is an entity at all** — the world streamer's answer, one static label per +/// batch, for the census (`VIS_CENSUS … why …`). A city keeps thousands of hidden model +/// submeshes resident on the entity path while the retained pass draws the world (5.6k ADT +/// doodad submeshes at the Stormwind auction house, 1945), and every one of them rides every +/// per-`Mesh3d` sweep; which divert declined it is the first question about pricing them. +/// Absent on units, GameObjects and everything the app lane spawns. +#[derive(Component, Clone, Copy)] +pub struct EntityPathWhy(pub &'static str); + /// Ordering handle so the one system allowed to *override* the model-`Visibility` authority — the /// self-avatar first-person hide ([`crate::player`]) — can run **after** it and win the frame. #[derive(SystemSet, Debug, Clone, Copy, PartialEq, Eq, Hash)] diff --git a/crates/benilla-world/src/rig_anim/pose.rs b/crates/benilla-world/src/rig_anim/pose.rs index eaba5057..973488a3 100644 --- a/crates/benilla-world/src/rig_anim/pose.rs +++ b/crates/benilla-world/src/rig_anim/pose.rs @@ -261,7 +261,10 @@ struct Active { fn evaluate_rig_poses( mut rigs: Query<(&AnimationPlayer, &ModelAnimations, &mut RigPose), Without>, ) { - for (player, anims, mut rig) in &mut rigs { + // Parallel over rigs (decision 1945): a rig reads its shared clips and writes only its own + // pose, and a raid stands ~400 live rigs / ~12k bones — 0.4 ms of one thread's frame when + // walked serially, and this system sits on the main thread's critical path. + rigs.par_iter_mut().for_each(|(player, anims, mut rig)| { let src = &anims.pose; // This rig's contributing animations, ascending by node index (Bevy's effective blend // order — sorted children folded through a LIFO stack, see the module doc). @@ -314,7 +317,7 @@ fn evaluate_rig_poses( blend_rig(src, many, &mut rig); } } - } + }); } /// The multi-animation path (a cross-fade, a masked overlay, the grip): merge-walk the diff --git a/crates/benilla-world/src/static_gx/cull.rs b/crates/benilla-world/src/static_gx/cull.rs index 24cc627a..8b83bb7a 100644 --- a/crates/benilla-world/src/static_gx/cull.rs +++ b/crates/benilla-world/src/static_gx/cull.rs @@ -116,6 +116,7 @@ fn spawn_exile(commands: &mut Commands, f: &GxFader, alpha: f32, admitted: bool) kind: crate::model_render::ModelKind::Doodad, blend: b.blend_mode, }, + crate::model_render::EntityPathWhy("exile"), crate::interact::PickMesh(b.geometry.clone()), bevy::mesh::MeshTag(crate::mesh_tag::alpha_bits(alpha)), crate::model_fade::DoodadFade { diff --git a/crates/benilla-world/src/terrain_stream/spawn/assemble.rs b/crates/benilla-world/src/terrain_stream/spawn/assemble.rs index eed8e4f9..9b7b4ff3 100644 --- a/crates/benilla-world/src/terrain_stream/spawn/assemble.rs +++ b/crates/benilla-world/src/terrain_stream/spawn/assemble.rs @@ -402,10 +402,30 @@ pub fn spawn_model_entities( // prop lane admits steady interior props + never-fade exterior props while an // exterior FADER prop stays on the entity path (the exile protocol has no prop // shape — that keep is today's default look) and is TALLIED, never silent. + // The census label for a batch that stays on the entity path (`EntityPathWhy`): + // refined below as each divert declines it. + let mut why: &'static str = "gx-off"; if let Some((gx, site)) = staticgx.as_mut() { - let facts = if !crate::static_gx::enabled() || shared_geometry[batch_idx] { + let facts = if !crate::static_gx::enabled() { + None + } else if shared_geometry[batch_idx] { + why = "shared-geometry"; None } else { + why = match site { + crate::static_gx::GxSite::Doodad { .. } if is_wmo => "doodad-site-wmo", + crate::static_gx::GxSite::Doodad { .. } if !class.merges() => "no-merge", + crate::static_gx::GxSite::Doodad { .. } if class.interior_prop => { + "interior-prop" + } + crate::static_gx::GxSite::Doodad { .. } => "fader-lane-off", + crate::static_gx::GxSite::Wmo { .. } if !is_wmo => "wmo-site-m2", + crate::static_gx::GxSite::Wmo { .. } if !class.merges() => "wmo-no-merge", + crate::static_gx::GxSite::Wmo { .. } => "wmo-no-group", + crate::static_gx::GxSite::Prop { .. } if is_wmo => "prop-site-wmo", + crate::static_gx::GxSite::Prop { .. } if !class.merges() => "prop-no-merge", + crate::static_gx::GxSite::Prop { .. } => "exterior-fader-prop", + }; match site { crate::static_gx::GxSite::Doodad { owner } if !is_wmo && class.merges() && !class.interior_prop => @@ -469,6 +489,7 @@ pub fn spawn_model_entities( } }; if let Some((owner, wmo, prop, fade)) = facts { + why = "gx-declined"; if gx.divert(crate::static_gx::GxBatch { geometry: &sub.geometry, transform, @@ -616,6 +637,7 @@ pub fn spawn_model_entities( kind, blend: sub.blend, }, + crate::model_render::EntityPathWhy(why), // The picker's triangles (decision 0857): the render forms are `RENDER_WORLD`-only, // so the inspector/probe rays read the model's resident geometry. The caster // centres a card at its pivot, the same bake the render form draws with. @@ -650,6 +672,7 @@ pub fn spawn_model_entities( kind, blend: sub.blend, }, + crate::model_render::EntityPathWhy(why), // The picker's triangles (decision 0857) — same rule as the card above. crate::interact::PickMesh(sub.geometry.clone()), mesh_tag, diff --git a/crates/benilla-world/src/world_census.rs b/crates/benilla-world/src/world_census.rs index 685ad1fb..fc092866 100644 --- a/crates/benilla-world/src/world_census.rs +++ b/crates/benilla-world/src/world_census.rs @@ -79,6 +79,13 @@ pub struct CensusReport { /// Visible submeshes per model subsystem — `(column name, visible, of-those-gated)` — in a /// fixed column order, because a census whose columns move cannot be diffed across runs. pub kinds: [(&'static str, usize, usize); 4], + /// Resident submeshes per model subsystem, same column order as `kinds` — hidden or not. + /// A city's hidden twins (the retained pass draws them) ride every per-`Mesh3d` sweep + /// whether they draw or not, and this is the count that prices them. + pub resident: [usize; 4], + /// Resident submeshes per `EntityPathWhy` label (the streamer's reason a batch is an + /// entity), most first; `"-"` for the untagged (units, GameObjects, the app lane). + pub why: Vec<(&'static str, usize)>, /// Of every [`ExteriorScene`]-tagged submesh: how many exist, how many the cull wrote `Hidden` /// on, how many were exempt (the camera's own placement — decision 0784), and how many carry /// **no `Aabb`**, which is the cull's fail-open arm admitting them unconditionally. @@ -191,6 +198,12 @@ impl WorldCensus<'_, '_> { } /// Snapshot this frame. + /// Live particles this instant — the one census number cheap enough to sample per frame + /// (a fold over the emitters, no part walk): a spell burst's signature on a tail frame. + pub fn live_particles(&self) -> usize { + self.emitters.iter().map(|p| p.live()).sum() + } + pub fn take(&self) -> CensusReport { let own_instance = self .claim @@ -199,13 +212,17 @@ impl WorldCensus<'_, '_> { .map(|c| c.room.instance); let mut kinds = [(0usize, 0usize); 4]; + let mut resident = [0usize; 4]; + let mut why: HashMap<&'static str, usize> = HashMap::new(); let mut labels: HashMap<(String, bool), usize> = HashMap::new(); let mut escaped: HashMap<(String, bool), usize> = HashMap::new(); let (mut tagged, mut hidden, mut exempt_n, mut no_aabb) = (0, 0, 0, 0); let (mut submeshes, mut drawn) = (0usize, 0usize); - for (vis, part, gated, object, want, aabb, card, group) in self.parts.iter() { + for (vis, part, gated, object, want, aabb, card, group, path_why) in self.parts.iter() { submeshes += 1; + resident[kind_index(part.kind)] += 1; + *why.entry(path_why.map_or("-", |w| w.0)).or_default() += 1; drawn += usize::from(vis.get()); if gated { // The camera's own placement is not exterior scene to itself (decision 0784) and @@ -266,6 +283,12 @@ impl WorldCensus<'_, '_> { CensusReport { submeshes, drawn, + why: { + let mut v: Vec<_> = why.into_iter().collect(); + v.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(b.0))); + v + }, + resident, kinds: std::array::from_fn(|i| (KIND_COLUMNS[i], kinds[i].0, kinds[i].1)), tagged, hidden, @@ -352,6 +375,7 @@ type CensusData = ( Option<&'static Aabb>, Has, Option<&'static WmoGroupVis>, + Option<&'static crate::model_render::EntityPathWhy>, ); /// The census column order, pinned: entry `i` names [`kind_index`]'s slot `i`. Column positions diff --git a/third_party/kira/BENILLA.md b/third_party/kira/BENILLA.md new file mode 100644 index 00000000..d2fb6c33 --- /dev/null +++ b/third_party/kira/BENILLA.md @@ -0,0 +1,39 @@ +# benilla's `kira` fork — what differs from upstream, and how to check + +Upstream: [`tesselode/kira`](https://github.com/tesselode/kira), MIT OR Apache-2.0, version +`0.12.1` — the version the workspace lock resolves to. Wired in through `[patch.crates-io]` in +the workspace root, the way `lua-src` is. The crate is copied from the cargo registry as +published (`src/`, `Cargo.toml`, `README.md`) plus the two licence files from the repository; +upstream files are CRLF and are left so, except the one file patched. + +## Why a fork exists at all + +The mixer runs on benilla's own audio render thread (`sound/output`), and with a raid buffing +itself — a dozen live voices, most of them spatial — that thread cost **~1.5 ms of every 60 Hz +frame** on the M2 Air (decision 1945: a sampled profile of the crowd rig's Blackrock leg with +sound on, `Track::process` two thirds of it). Upstream's sub-track `process` spatializes **every +frame**: two vector normalizes, a length, the attenuation curve and a dB→amplitude `powf` per +frame per spatial track, then two more `powf`s per frame for the volume tween and the fade. At +48 kHz × 12 tracks that is ~1.7 M `powf`s a second plus the vector math, for gains that change +imperceptibly across a 10 ms chunk. FMOD 3 — the mixer the real client delegates to — updates +pan and attenuation per mixer update, not per sample. + +## The one patch — `src/track/sub.rs` + +`Track::process` evaluates the spatial gains (attenuation, left/right ear gain, the mono fold) +and the volume × fade amplitude at the chunk's two ends and interpolates them linearly across +the chunk. `SpatialData::spatial_gains` is `spatialize` factored into a value; `spatialize` +itself is kept, unused, so the factoring can be diffed against it. A chunk is the output +device's buffer — 512 frames, ~10.7 ms, `sound/output::DEVICE_BUFFER_FRAMES`. + +What changes audibly: nothing a listener can name. A source crossing an ear's axis within one +chunk pans linearly in gain over 10 ms instead of following the dot product per sample; a +volume tween ramps linearly in amplitude across each chunk instead of in decibels. Both are +below the granularity the reference itself updates at. + +## How to check + +`git diff --no-index /kira-0.12.1/src/track/sub.rs third_party/kira/src/track/sub.rs` +(after `sed 's/\r$//'` on the upstream copy) shows the patch and nothing else; every other file +is byte-identical to the registry crate. The mixer's own tests (`cargo test -p benilla-app +sound::`) run against the fork. diff --git a/third_party/kira/Cargo.toml b/third_party/kira/Cargo.toml new file mode 100644 index 00000000..673bcf46 --- /dev/null +++ b/third_party/kira/Cargo.toml @@ -0,0 +1,182 @@ +# THIS FILE IS AUTOMATICALLY GENERATED BY CARGO +# +# When uploading crates to the registry Cargo will automatically +# "normalize" Cargo.toml files for maximal compatibility +# with all versions of Cargo and also rewrite `path` dependencies +# to registry (e.g., crates.io) dependencies. +# +# If you are reading this file be aware that the original Cargo.toml +# will likely look very different (and much more reasonable). +# See Cargo.toml.orig for the original contents. + +[package] +edition = "2024" +name = "kira" +version = "0.12.1" +authors = ["Andrew Minnich "] +build = false +autolib = false +autobins = false +autoexamples = false +autotests = false +autobenches = false +description = "Expressive audio library for games" +readme = "README.md" +keywords = [ + "gamedev", + "audio", + "music", +] +categories = [ + "game-development", + "multimedia::audio", +] +license = "MIT OR Apache-2.0" +repository = "https://github.com/tesselode/kira" +resolver = "2" + +[package.metadata.docs.rs] +all-features = true +rustdoc-args = [ + "--cfg", + "docsrs", +] + +[features] +aac = [ + "symphonia", + "symphonia/aac", +] +adpcm = [ + "symphonia", + "symphonia/adpcm", +] +aiff = [ + "symphonia", + "symphonia/aiff", +] +alac = [ + "symphonia", + "symphonia/alac", +] +caf = [ + "symphonia", + "symphonia/caf", +] +default = [ + "cpal", + "mp3", + "ogg", + "vorbis", + "flac", + "wav", + "pcm", +] +flac = [ + "symphonia", + "symphonia/flac", +] +isomp4 = [ + "symphonia", + "symphonia/isomp4", +] +mkv = [ + "symphonia", + "symphonia/mkv", +] +mp1 = [ + "symphonia", + "symphonia/mp1", +] +mp2 = [ + "symphonia", + "symphonia/mp2", +] +mp3 = [ + "symphonia", + "symphonia/mp3", +] +mpa = [ + "symphonia", + "symphonia/mpa", +] +ogg = [ + "symphonia", + "symphonia/ogg", +] +pcm = [ + "symphonia", + "symphonia/pcm", +] +vorbis = [ + "symphonia", + "symphonia/vorbis", +] +wav = [ + "symphonia", + "symphonia/wav", +] + +[lib] +name = "kira" +path = "src/lib.rs" + +[[test]] +name = "change_sample_rate" +path = "tests/change_sample_rate.rs" + +[[test]] +name = "streaming_sound_stops_on_error" +path = "tests/streaming_sound_stops_on_error.rs" + +[[test]] +name = "sync_send" +path = "tests/sync_send.rs" + +[dependencies.assert_no_alloc] +version = "1.1.2" +optional = true + +[dependencies.atomic-arena] +version = "0.1.2" + +[dependencies.glam] +version = "0.33.0" +features = ["mint"] + +[dependencies.mint] +version = "0.5.9" + +[dependencies.pastey] +version = "0.2.1" + +[dependencies.rtrb] +version = "0.3.2" + +[dependencies.serde] +version = "1.0.219" +features = ["derive"] +optional = true + +[dependencies.symphonia] +version = "0.5.4" +optional = true +default-features = false + +[dependencies.triple_buffer] +version = "9.0.0" + +[dev-dependencies.approx] +version = "0.5.1" + +[target.'cfg(not(target_arch = "wasm32"))'.dependencies.cpal] +version = "0.17.0" +optional = true + +[target.'cfg(target_arch = "wasm32")'.dependencies.cpal] +version = "0.17.0" +features = ["wasm-bindgen"] +optional = true + +[target.'cfg(target_arch = "wasm32")'.dependencies.send_wrapper] +version = "0.6.0" diff --git a/third_party/kira/LICENSE-APACHE b/third_party/kira/LICENSE-APACHE new file mode 100644 index 00000000..dfc4a784 --- /dev/null +++ b/third_party/kira/LICENSE-APACHE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2021 Andrew Minnich + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/third_party/kira/LICENSE-MIT b/third_party/kira/LICENSE-MIT new file mode 100644 index 00000000..b7618ccf --- /dev/null +++ b/third_party/kira/LICENSE-MIT @@ -0,0 +1,7 @@ +Copyright 2021 Andrew Minnich + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/third_party/kira/README.md b/third_party/kira/README.md new file mode 100644 index 00000000..51e6fc3e --- /dev/null +++ b/third_party/kira/README.md @@ -0,0 +1,143 @@ +# Kira + +#### [crates.io](https://crates.io/crates/kira) | [docs](https://docs.rs/kira/) + +Kira is a backend-agnostic library to create expressive audio for games. It +provides tweens for smoothly adjusting properties of sounds, a flexible mixer +for applying effects to audio, a clock system for precisely timing audio events, +and spatial audio support. + +## Examples + +Playing a sound multiple times simultaneously: + +```rust +use kira::{ + AudioManager, AudioManagerSettings, DefaultBackend, + sound::static_sound::StaticSoundData, +}; + +// Create an audio manager. This plays sounds and manages resources. +let mut manager = AudioManager::::new(AudioManagerSettings::default())?; +let sound_data = StaticSoundData::from_file("sound.ogg")?; +manager.play(sound_data.clone())?; +// After a couple seconds... +manager.play(sound_data.clone())?; +// Cloning the sound data will not use any extra memory. +``` + +Gradually speeding up a sound over time: + +```rust +use std::time::Duration; + +use kira::{ + AudioManager, AudioManagerSettings, DefaultBackend, + sound::static_sound::StaticSoundData, + Tween, +}; + +let mut manager = AudioManager::::new(AudioManagerSettings::default())?; +let sound_data = StaticSoundData::from_file("sound.ogg")?; +let mut sound = manager.play(sound_data)?; +// Start smoothly adjusting the playback rate parameter. +sound.set_playback_rate( + 2.0, + Tween { + duration: Duration::from_secs(3), + ..Default::default() + }, +); +``` + +Playing a sound with a low-pass filter applied (this makes the audio sound +muffled): + +```rust +use kira::{ + AudioManager, AudioManagerSettings, DefaultBackend, + sound::static_sound::StaticSoundData, + track::{ + TrackBuilder, + effect::filter::FilterBuilder, + }, +}; + +let mut manager = AudioManager::::new(AudioManagerSettings::default())?; +// Create a mixer sub-track with a filter. +let track = manager.add_sub_track({ + let mut builder = TrackBuilder::new(); + builder.add_effect(FilterBuilder::new().cutoff(1000.0)); + builder +})?; +// Play the sound on the track. +let sound_data = StaticSoundData::from_file("sound.ogg")?.output_destination(&track); +manager.play(sound_data)?; +``` + +Playing sounds in time with a musical beat: + +```rust +use kira::{ + AudioManager, AudioManagerSettings, DefaultBackend, + sound::static_sound::StaticSoundData, + clock::ClockSpeed, +}; + +const TEMPO: f64 = 120.0; + +let mut manager = AudioManager::::new(AudioManagerSettings::default())?; +// Create a clock that ticks 120 times per minute. In this case, +// each tick is one musical beat. We can use a tick to represent any +// arbitrary amount of time. +let mut clock = manager.add_clock(ClockSpeed::TicksPerMinute(TEMPO))?; +// Play a sound 2 ticks (beats) from now. +let sound_data_1 = StaticSoundData::from_file("sound1.ogg")? + .start_time(clock.time() + 2); +manager.play(sound_data_1)?; +// Play a different sound 4 ticks (beats) from now. +let sound_data_2 = StaticSoundData::from_file("sound2.ogg")? + .start_time(clock.time() + 4); +manager.play(sound_data_2)?; +// Start the clock. +clock.start()?; +``` + +## Platform support + +Kira is mainly meant for desktop platforms. Most testing has occurred on +Windows, but it has been used successfully on Mac and Linux. + +Kira can also be used in wasm environments with the following limitations: + +- Static sounds cannot be loaded from files +- Streaming sounds are not supported because they make heavy use of threads + +If you'd like to help improve wasm support, please reach out! + +## Roadmap + +Features I'd like to have: + +- C API +- Spatial audio features: + - Doppler effect + +## Contributing + +I'd love for other people to get involved with development! Since the library is +still in the early stages, I'm open to all kinds of input - bug reports, feature +requests, design critiques, etc. Feel free to open an issue or pull request! + +## License + +This project is licensed under either of + +- Apache License, Version 2.0 (LICENSE-APACHE) +- MIT license (LICENSE-MIT) + +at your option. + +Unless you explicitly state otherwise, any contribution intentionally submitted +for inclusion in `kira` by you, as defined in the Apache-2.0 license, shall be +dual licensed as above, without any additional terms or conditions. diff --git a/third_party/kira/src/backend.rs b/third_party/kira/src/backend.rs new file mode 100644 index 00000000..f33e823b --- /dev/null +++ b/third_party/kira/src/backend.rs @@ -0,0 +1,40 @@ +//! Communication between Kira and a low-level audio API. + +#[cfg(feature = "cpal")] +pub mod cpal; +pub mod mock; +mod renderer; +pub(crate) mod resources; + +pub use renderer::*; + +#[cfg(feature = "cpal")] +/// The default backend used by [`AudioManager`](crate::AudioManager)s. +/// +/// If the `cpal` feature is enabled, this will be the cpal backend. Otherwise, +/// it will be the mock backend. +pub type DefaultBackend = cpal::CpalBackend; +#[cfg(not(feature = "cpal"))] +/// The default backend used by [`AudioManager`](crate::AudioManager)s. +/// +/// If the `cpal` feature is enabled, this will be the cpal backend. Otherwise, +/// it will be the mock backend. +pub type DefaultBackend = mock::MockBackend; + +/// Connects a [`Renderer`] to a lower level audio API. +pub trait Backend: Sized { + /// Settings for this backend. + type Settings; + + /// Errors that can occur when using this backend. + type Error; + + /// Starts the backend and returns itself and the initial sample rate. + fn setup( + settings: Self::Settings, + internal_buffer_size: usize, + ) -> Result<(Self, u32), Self::Error>; + + /// Sends the renderer to the backend to start audio playback. + fn start(&mut self, renderer: Renderer) -> Result<(), Self::Error>; +} diff --git a/third_party/kira/src/backend/cpal.rs b/third_party/kira/src/backend/cpal.rs new file mode 100644 index 00000000..71aa241c --- /dev/null +++ b/third_party/kira/src/backend/cpal.rs @@ -0,0 +1,29 @@ +//! Plays audio using [cpal](https://crates.io/crates/cpal). + +#![cfg_attr(docsrs, doc(cfg(feature = "cpal")))] + +mod error; +use cpal::{Device, StreamConfig}; +pub use error::*; + +/// Settings for the cpal backend. +#[derive(Clone, Default)] +pub struct CpalBackendSettings { + /// The output audio device to use. If [`None`], the default output + /// device will be used. + pub device: Option, + /// A StreamConfig given by Cpal. If [`None`], the default supported + /// config will be used. You can also get a supported config of your + /// choosing using Cpal functions. + pub config: Option, +} + +#[cfg(target_arch = "wasm32")] +mod wasm; +#[cfg(target_arch = "wasm32")] +pub use wasm::CpalBackend; + +#[cfg(not(target_arch = "wasm32"))] +mod desktop; +#[cfg(not(target_arch = "wasm32"))] +pub use desktop::CpalBackend; diff --git a/third_party/kira/src/backend/cpal/desktop.rs b/third_party/kira/src/backend/cpal/desktop.rs new file mode 100644 index 00000000..77ad57f1 --- /dev/null +++ b/third_party/kira/src/backend/cpal/desktop.rs @@ -0,0 +1,166 @@ +mod renderer_with_cpu_usage; +mod stream_manager; + +use std::sync::Mutex; + +use renderer_with_cpu_usage::RendererWithCpuUsage; +use rtrb::Consumer; +use stream_manager::{StreamManager, StreamManagerController}; + +use crate::backend::{Backend, Renderer}; +use cpal::{ + BufferSize, Device, StreamConfig, StreamError, + traits::{DeviceTrait, HostTrait}, +}; + +use super::{CpalBackendSettings, Error}; + +enum State { + Empty, + Uninitialized { + device: Device, + config: StreamConfig, + }, + Initialized { + stream_manager_controller: StreamManagerController, + }, +} + +/// A backend that uses [cpal](https://crates.io/crates/cpal) to +/// connect a [`Renderer`] to the operating system's audio driver. +pub struct CpalBackend { + state: State, + /// Whether the device was specified by the user. + custom_device: bool, + buffer_size: BufferSize, + cpu_usage_consumer: Option>>, +} + +impl CpalBackend { + /** + Returns the oldest reported CPU usage in the queue. + + The formula for the CPU usage is time elapsed / time allotted, where + - time elapsed is the amount of time it took to fill the audio buffer + requested by the OS + - time allotted is the maximum amount of time Kira could take to process + audio and still finish in time to avoid audio stuttering (num frames / sample + rate) + */ + #[cfg_attr(docsrs, doc(cfg(not(wasm32))))] + pub fn pop_cpu_usage(&mut self) -> Option { + self.cpu_usage_consumer + .as_mut() + .unwrap() + .get_mut() + .unwrap() + .pop() + .ok() + } + + /// Returns the oldest available stream error in the queue. + #[cfg_attr(docsrs, doc(cfg(not(wasm32))))] + pub fn pop_error(&mut self) -> Option { + if let State::Initialized { + stream_manager_controller, + } = &mut self.state + { + stream_manager_controller.pop_handled_error() + } else { + None + } + } + + /** + Returns the number of unhandled stream errors discarded because of overcrowding. + This increases either: + + 1. When `cpal` produces errors faster than they are polled by `kira`, or + 2. When `kira` produces errors faster than they are polled by [`Self::pop_error`]. + */ + #[must_use] + #[cfg_attr(docsrs, doc(cfg(not(wasm32))))] + pub fn num_stream_errors_discarded(&self) -> Option { + if let State::Initialized { + stream_manager_controller, + } = &self.state + { + Some(stream_manager_controller.num_stream_errors_discarded()) + } else { + None + } + } +} + +impl Backend for CpalBackend { + type Settings = CpalBackendSettings; + + type Error = Error; + + fn setup( + settings: Self::Settings, + _internal_buffer_size: usize, + ) -> Result<(Self, u32), Self::Error> { + let host = cpal::default_host(); + + let (device, custom_device) = if let Some(device) = settings.device { + (device, true) + } else { + ( + host.default_output_device() + .ok_or(Error::NoDefaultOutputDevice)?, + false, + ) + }; + + let config = if let Some(config) = settings.config { + config + } else { + device.default_output_config()?.config() + }; + + let sample_rate = config.sample_rate; + let buffer_size = config.buffer_size; + + Ok(( + Self { + state: State::Uninitialized { device, config }, + custom_device, + buffer_size, + cpu_usage_consumer: None, + }, + sample_rate, + )) + } + + fn start(&mut self, renderer: Renderer) -> Result<(), Self::Error> { + let state = std::mem::replace(&mut self.state, State::Empty); + if let State::Uninitialized { device, config } = state { + let (renderer, cpu_usage_consumer) = RendererWithCpuUsage::new(renderer); + self.state = State::Initialized { + stream_manager_controller: StreamManager::start( + renderer, + device, + config, + self.custom_device, + self.buffer_size, + )?, + }; + self.cpu_usage_consumer = Some(Mutex::new(cpu_usage_consumer)); + } else { + panic!("Cannot initialize the backend multiple times") + } + Ok(()) + } +} + +impl Drop for CpalBackend { + fn drop(&mut self) { + if let State::Initialized { + stream_manager_controller, + } = &self.state + { + stream_manager_controller.stop(); + } + } +} diff --git a/third_party/kira/src/backend/cpal/desktop/renderer_with_cpu_usage.rs b/third_party/kira/src/backend/cpal/desktop/renderer_with_cpu_usage.rs new file mode 100644 index 00000000..c455b162 --- /dev/null +++ b/third_party/kira/src/backend/cpal/desktop/renderer_with_cpu_usage.rs @@ -0,0 +1,53 @@ +use std::{ + ops::{Deref, DerefMut}, + time::Instant, +}; + +use rtrb::{Consumer, Producer, RingBuffer}; + +use crate::backend::Renderer; + +const CPU_USAGE_RINGBUFFER_CAPACITY: usize = 100; + +pub struct RendererWithCpuUsage { + renderer: Renderer, + cpu_usage_producer: Producer, +} + +impl RendererWithCpuUsage { + pub fn new(renderer: Renderer) -> (Self, Consumer) { + let (cpu_usage_producer, cpu_usage_consumer) = + RingBuffer::new(CPU_USAGE_RINGBUFFER_CAPACITY); + ( + Self { + renderer, + cpu_usage_producer, + }, + cpu_usage_consumer, + ) + } + + pub fn process(&mut self, out: &mut [f32], num_channels: u16, sample_rate: u32) { + let allotted_time = out.len() as f32 / num_channels as f32 / sample_rate as f32; + let start_time = Instant::now(); + self.renderer.process(out, num_channels); + let end_time = Instant::now(); + let process_duration = end_time - start_time; + let cpu_usage = process_duration.as_secs_f32() / allotted_time; + self.cpu_usage_producer.push(cpu_usage).ok(); + } +} + +impl Deref for RendererWithCpuUsage { + type Target = Renderer; + + fn deref(&self) -> &Self::Target { + &self.renderer + } +} + +impl DerefMut for RendererWithCpuUsage { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.renderer + } +} diff --git a/third_party/kira/src/backend/cpal/desktop/stream_manager.rs b/third_party/kira/src/backend/cpal/desktop/stream_manager.rs new file mode 100644 index 00000000..3eae856d --- /dev/null +++ b/third_party/kira/src/backend/cpal/desktop/stream_manager.rs @@ -0,0 +1,284 @@ +mod send_on_drop; + +use std::{ + sync::{ + Arc, Mutex, + atomic::{AtomicBool, AtomicU64, Ordering}, + }, + time::Duration, +}; + +use super::renderer_with_cpu_usage::RendererWithCpuUsage; +use cpal::{ + BufferSize, Device, Stream, StreamConfig, StreamError, + traits::{DeviceTrait, HostTrait, StreamTrait}, +}; +use rtrb::{Consumer, Producer, PushError, RingBuffer}; +use send_on_drop::SendOnDrop; + +use super::super::Error; + +const CHECK_STREAM_INTERVAL: Duration = Duration::from_millis(500); + +#[allow(clippy::large_enum_variant)] +enum State { + Empty, + Idle { + renderer: RendererWithCpuUsage, + }, + Running { + stream: Stream, + renderer_consumer: Consumer, + }, +} + +pub(super) struct StreamManagerController { + should_drop: Arc, + num_stream_errors_discarded: Arc, + handled_stream_error_consumer: Mutex>, +} + +impl StreamManagerController { + pub fn stop(&self) { + self.should_drop.store(true, Ordering::SeqCst); + } + + #[must_use] + pub fn num_stream_errors_discarded(&self) -> u64 { + self.num_stream_errors_discarded.load(Ordering::Acquire) + } + + pub fn pop_handled_error(&mut self) -> Option { + self.handled_stream_error_consumer + .get_mut() + .unwrap() + .pop() + .ok() + } +} + +/// Starts a cpal stream and restarts it if needed +/// in the case of device changes or disconnections. +pub(super) struct StreamManager { + state: State, + device_id: String, + sample_rate: u32, + custom_device: bool, + buffer_size: BufferSize, +} + +impl StreamManager { + pub fn start( + renderer: RendererWithCpuUsage, + device: Device, + mut config: StreamConfig, + custom_device: bool, + buffer_size: BufferSize, + ) -> Result { + let should_drop = Arc::new(AtomicBool::new(false)); + let should_drop_clone = should_drop.clone(); + + let num_stream_errors_discarded = Arc::new(AtomicU64::new(0)); + let num_stream_errors_discarded_clone = num_stream_errors_discarded.clone(); + + let (mut handled_stream_error_producer, handled_stream_error_consumer) = + RingBuffer::new(64); + + let (mut initial_result_producer, mut initial_result_consumer) = RingBuffer::new(1); + + std::thread::spawn(move || { + let mut stream_manager = StreamManager { + state: State::Idle { renderer }, + device_id: device_id(&device), + sample_rate: config.sample_rate, + custom_device, + buffer_size, + }; + let mut unhandled_stream_error_consumer = match stream_manager.start_stream( + &device, + &mut config, + num_stream_errors_discarded.clone(), + ) { + Ok(unhandled_stream_error_consumer) => { + initial_result_producer.push(Ok(())).unwrap(); + unhandled_stream_error_consumer + } + Err(err) => { + initial_result_producer.push(Err(err)).unwrap(); + return; + } + }; + loop { + std::thread::sleep(CHECK_STREAM_INTERVAL); + if should_drop.load(Ordering::SeqCst) { + break; + } + stream_manager.check_stream( + &mut unhandled_stream_error_consumer, + &mut handled_stream_error_producer, + &num_stream_errors_discarded, + ); + } + }); + + loop { + if let Ok(result) = initial_result_consumer.pop() { + result?; + break; + } + std::thread::sleep(Duration::from_micros(100)); + } + + Ok(StreamManagerController { + should_drop: should_drop_clone, + num_stream_errors_discarded: num_stream_errors_discarded_clone, + handled_stream_error_consumer: Mutex::new(handled_stream_error_consumer), + }) + } + + /// Restarts the stream if the audio device gets disconnected. + fn check_stream( + &mut self, + unhandled_stream_error_consumer: &mut Consumer, + handled_stream_error_producer: &mut Producer, + num_stream_errors_discarded: &Arc, + ) { + if let State::Running { .. } = &self.state { + while let Ok(error) = unhandled_stream_error_consumer.pop() { + match error { + // check for device disconnection + StreamError::DeviceNotAvailable | StreamError::StreamInvalidated => { + self.stop_stream(); + if let Ok((device, mut config)) = default_device_and_config() { + // TODO: gracefully handle errors that occur in this function + *unhandled_stream_error_consumer = self + .start_stream( + &device, + &mut config, + num_stream_errors_discarded.clone(), + ) + .unwrap(); + } + } + StreamError::BackendSpecific { err: _ } | StreamError::BufferUnderrun => {} + } + match handled_stream_error_producer.push(error) { + Ok(()) => {} + Err(PushError::Full(_stream_error)) => { + num_stream_errors_discarded.fetch_add(1, Ordering::AcqRel); + } + } + } + // check for device changes if a custom device hasn't been specified + // Disabled on macos due to audio artifacts that seem to occur when the device is + // queried while playing. + // see: https://github.com/tesselode/kira/issues/38 + #[cfg(not(target_os = "macos"))] + if !self.custom_device { + if let Ok((device, mut config)) = default_device_and_config() { + let device_id = device_id(&device); + let sample_rate = config.sample_rate; + if device_id != self.device_id || sample_rate != self.sample_rate { + self.stop_stream(); + *unhandled_stream_error_consumer = self + .start_stream(&device, &mut config, num_stream_errors_discarded.clone()) + .unwrap(); + } + } + } + } + } + + fn start_stream( + &mut self, + device: &Device, + config: &mut StreamConfig, + num_stream_errors_discarded: Arc, + ) -> Result, Error> { + let mut renderer = + if let State::Idle { renderer } = std::mem::replace(&mut self.state, State::Empty) { + renderer + } else { + panic!("trying to start a stream when the stream manager is not idle"); + }; + config.buffer_size = self.buffer_size; // this won't change anything if the buffer size is BufferSize::Default + let device_id = device_id(device); + let sample_rate = config.sample_rate; + if sample_rate != self.sample_rate { + renderer.on_change_sample_rate(sample_rate); + } + self.device_id = device_id; + self.sample_rate = sample_rate; + let (mut renderer_wrapper, renderer_consumer) = SendOnDrop::new(renderer); + let (mut unhandled_stream_error_producer, unhandled_stream_error_consumer) = + RingBuffer::new(64); + let channels = config.channels; + let stream = device.build_output_stream( + config, + move |data: &mut [f32], _| { + #[cfg(feature = "assert_no_alloc")] + assert_no_alloc::assert_no_alloc(|| { + process_renderer(&mut renderer_wrapper, data, channels, sample_rate); + }); + #[cfg(not(feature = "assert_no_alloc"))] + process_renderer(&mut renderer_wrapper, data, channels, sample_rate); + }, + move |error| match unhandled_stream_error_producer.push(error) { + Ok(()) => {} + Err(PushError::Full(_stream_error)) => { + num_stream_errors_discarded.fetch_add(1, Ordering::AcqRel); + } + }, + None, + )?; + stream.play()?; + self.state = State::Running { + stream, + renderer_consumer, + }; + Ok(unhandled_stream_error_consumer) + } + + fn stop_stream(&mut self) { + if let State::Running { + mut renderer_consumer, + stream, + .. + } = std::mem::replace(&mut self.state, State::Empty) + { + drop(stream); + let renderer = renderer_consumer + .pop() + .expect("Could not retrieve the renderer after dropping a stream"); + self.state = State::Idle { renderer }; + } else { + panic!("Trying to stop the stream when it's not running") + } + } +} + +fn default_device_and_config() -> Result<(Device, StreamConfig), Error> { + let host = cpal::default_host(); + let device = host + .default_output_device() + .ok_or(Error::NoDefaultOutputDevice)?; + let config = device.default_output_config()?.config(); + Ok((device, config)) +} + +fn device_id(device: &Device) -> String { + match device.id() { + Ok(id) => format!("{}", id), + Err(_) => "device id unavailable".to_string(), + } +} + +fn process_renderer( + renderer: &mut SendOnDrop, + data: &mut [f32], + channels: u16, + sample_rate: u32, +) { + renderer.on_start_processing(); + renderer.process(data, channels, sample_rate); +} diff --git a/third_party/kira/src/backend/cpal/desktop/stream_manager/send_on_drop.rs b/third_party/kira/src/backend/cpal/desktop/stream_manager/send_on_drop.rs new file mode 100644 index 00000000..7244ac74 --- /dev/null +++ b/third_party/kira/src/backend/cpal/desktop/stream_manager/send_on_drop.rs @@ -0,0 +1,49 @@ +use std::ops::{Deref, DerefMut}; + +use rtrb::{Consumer, Producer, RingBuffer}; + +/// Wraps `T` so that when it's dropped, it gets sent +/// back through a thread channel. +/// +/// This allows us to retrieve the data after a closure +/// that takes ownership of the data is dropped because of, +/// for instance, a cpal error. +pub struct SendOnDrop { + data: Option, + producer: Producer, +} + +impl SendOnDrop { + pub fn new(data: T) -> (Self, Consumer) { + let (producer, consumer) = RingBuffer::new(1); + ( + Self { + data: Some(data), + producer, + }, + consumer, + ) + } +} + +impl Deref for SendOnDrop { + type Target = T; + + fn deref(&self) -> &Self::Target { + self.data.as_ref().unwrap() + } +} + +impl DerefMut for SendOnDrop { + fn deref_mut(&mut self) -> &mut Self::Target { + self.data.as_mut().unwrap() + } +} + +impl Drop for SendOnDrop { + fn drop(&mut self) { + self.producer + .push(self.data.take().unwrap()) + .expect("send on drop producer full"); + } +} diff --git a/third_party/kira/src/backend/cpal/error.rs b/third_party/kira/src/backend/cpal/error.rs new file mode 100644 index 00000000..5acb2ecc --- /dev/null +++ b/third_party/kira/src/backend/cpal/error.rs @@ -0,0 +1,58 @@ +use std::fmt::{Display, Formatter}; + +use cpal::{BuildStreamError, DefaultStreamConfigError, PlayStreamError}; + +/// Errors that can occur when using the cpal backend. +#[derive(Debug)] +pub enum Error { + /// A default audio output device could not be determined. + NoDefaultOutputDevice, + /// An error occurred when getting the default output configuration. + DefaultStreamConfigError(DefaultStreamConfigError), + /// An error occurred when building the audio stream. + BuildStreamError(BuildStreamError), + /// An error occurred when starting the audio stream. + PlayStreamError(PlayStreamError), +} + +impl Display for Error { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + Error::NoDefaultOutputDevice => { + f.write_str("Cannot find the default audio output device") + } + Error::DefaultStreamConfigError(error) => error.fmt(f), + Error::BuildStreamError(error) => error.fmt(f), + Error::PlayStreamError(error) => error.fmt(f), + } + } +} + +impl std::error::Error for Error { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Error::DefaultStreamConfigError(error) => Some(error), + Error::BuildStreamError(error) => Some(error), + Error::PlayStreamError(error) => Some(error), + _ => None, + } + } +} + +impl From for Error { + fn from(v: DefaultStreamConfigError) -> Self { + Self::DefaultStreamConfigError(v) + } +} + +impl From for Error { + fn from(v: BuildStreamError) -> Self { + Self::BuildStreamError(v) + } +} + +impl From for Error { + fn from(v: PlayStreamError) -> Self { + Self::PlayStreamError(v) + } +} diff --git a/third_party/kira/src/backend/cpal/wasm.rs b/third_party/kira/src/backend/cpal/wasm.rs new file mode 100644 index 00000000..b370febf --- /dev/null +++ b/third_party/kira/src/backend/cpal/wasm.rs @@ -0,0 +1,78 @@ +use crate::backend::{Backend, Renderer}; +use cpal::{ + Device, Stream, StreamConfig, + traits::{DeviceTrait, HostTrait, StreamTrait}, +}; +use send_wrapper::SendWrapper; + +use super::{CpalBackendSettings, Error}; + +enum State { + Empty, + Uninitialized { + device: Device, + config: StreamConfig, + }, + Initialized { + _stream: Stream, + }, +} + +/// A backend that uses [cpal](https://crates.io/crates/cpal) to +/// connect a [`Renderer`] to the operating system's audio driver. +pub struct CpalBackend { + state: SendWrapper, +} + +impl Backend for CpalBackend { + type Settings = CpalBackendSettings; + + type Error = Error; + + fn setup( + settings: Self::Settings, + _internal_buffer_size: usize, + ) -> Result<(Self, u32), Self::Error> { + let host = cpal::default_host(); + let device = if let Some(device) = settings.device { + device + } else { + host.default_output_device() + .ok_or(Error::NoDefaultOutputDevice)? + }; + let config = if let Some(config) = settings.config { + config + } else { + device.default_output_config()?.config() + }; + let sample_rate = config.sample_rate; + Ok(( + Self { + state: SendWrapper::new(State::Uninitialized { device, config }), + }, + sample_rate, + )) + } + + fn start(&mut self, mut renderer: Renderer) -> Result<(), Self::Error> { + if let State::Uninitialized { device, config } = + std::mem::replace(&mut *self.state, State::Empty) + { + let channels = config.channels; + let stream = device.build_output_stream( + &config, + move |data: &mut [f32], _| { + renderer.on_start_processing(); + renderer.process(data, channels); + }, + move |_| {}, + None, + )?; + stream.play()?; + self.state = SendWrapper::new(State::Initialized { _stream: stream }); + } else { + panic!("Cannot initialize the backend multiple times") + } + Ok(()) + } +} diff --git a/third_party/kira/src/backend/mock.rs b/third_party/kira/src/backend/mock.rs new file mode 100644 index 00000000..1013ab0d --- /dev/null +++ b/third_party/kira/src/backend/mock.rs @@ -0,0 +1,99 @@ +//! Useful for testing and benchmarking. + +use std::sync::Mutex; + +use super::{Backend, Renderer}; + +enum State { + Uninitialized, + Initialized { renderer: Mutex }, +} + +/// Settings for the mock backend. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct MockBackendSettings { + /// The sample rate that the [`Renderer`] should run at. + pub sample_rate: u32, +} + +impl Default for MockBackendSettings { + fn default() -> Self { + Self { sample_rate: 1 } + } +} + +/// A backend that does not connect to any lower-level +/// audio APIs, but allows manually calling +/// [`Renderer::on_start_processing`] and [`Renderer::process`]. +/// +/// This is useful for testing and benchmarking. +pub struct MockBackend { + sample_rate: u32, + state: State, + frames: Vec, +} + +impl MockBackend { + /// Changes the sample rate of the [`Renderer`]. + pub fn set_sample_rate(&mut self, sample_rate: u32) { + self.sample_rate = sample_rate; + if let State::Initialized { renderer } = &mut self.state { + renderer + .get_mut() + .expect("mutex poisoned") + .on_change_sample_rate(sample_rate); + } + } + + /// Calls the [`on_start_processing`](Renderer::on_start_processing) + /// callback of the [`Renderer`]. + pub fn on_start_processing(&mut self) { + if let State::Initialized { renderer } = &mut self.state { + renderer + .get_mut() + .expect("mutex poisoned") + .on_start_processing(); + } else { + panic!("backend is not initialized") + } + } + + /// Calls the [`process`](Renderer::process) callback of the [`Renderer`]. + pub fn process(&mut self) { + if let State::Initialized { renderer } = &mut self.state { + renderer + .get_mut() + .expect("mutex poisoned") + .process(&mut self.frames, 2) + } else { + panic!("backend is not initialized") + } + } +} + +impl Backend for MockBackend { + type Settings = MockBackendSettings; + + type Error = (); + + fn setup( + settings: Self::Settings, + internal_buffer_size: usize, + ) -> Result<(Self, u32), Self::Error> { + Ok(( + Self { + sample_rate: settings.sample_rate, + state: State::Uninitialized, + frames: vec![0.0; internal_buffer_size * 2], + }, + settings.sample_rate, + )) + } + + fn start(&mut self, renderer: Renderer) -> Result<(), Self::Error> { + self.state = State::Initialized { + renderer: Mutex::new(renderer), + }; + Ok(()) + } +} diff --git a/third_party/kira/src/backend/renderer.rs b/third_party/kira/src/backend/renderer.rs new file mode 100644 index 00000000..9fae8bd6 --- /dev/null +++ b/third_party/kira/src/backend/renderer.rs @@ -0,0 +1,126 @@ +use std::sync::{ + Arc, + atomic::{AtomicU32, Ordering}, +}; + +use crate::Frame; + +use super::resources::Resources; + +#[derive(Debug)] +pub(crate) struct RendererShared { + pub(crate) sample_rate: AtomicU32, +} + +impl RendererShared { + #[must_use] + pub fn new(sample_rate: u32) -> Self { + Self { + sample_rate: AtomicU32::new(sample_rate), + } + } +} + +/// Produces [`Frame`]s of audio data to be consumed by a +/// low-level audio API. +/// +/// You will probably not need to interact with [`Renderer`]s +/// directly unless you're writing a [`Backend`](super::Backend). +pub struct Renderer { + dt: f64, + shared: Arc, + resources: Resources, + internal_buffer_size: usize, + temp_buffer: Vec, +} + +impl Renderer { + #[must_use] + pub(crate) fn new( + shared: Arc, + internal_buffer_size: usize, + resources: Resources, + ) -> Self { + Self { + dt: 1.0 / shared.sample_rate.load(Ordering::SeqCst) as f64, + shared, + resources, + internal_buffer_size, + temp_buffer: vec![Frame::ZERO; internal_buffer_size], + } + } + + /// Called by the backend when the sample rate of the + /// audio output changes. + pub fn on_change_sample_rate(&mut self, sample_rate: u32) { + self.dt = 1.0 / sample_rate as f64; + self.shared.sample_rate.store(sample_rate, Ordering::SeqCst); + self.resources.mixer.on_change_sample_rate(sample_rate); + } + + /// Called by the backend when it's time to process + /// a new batch of samples. + pub fn on_start_processing(&mut self) { + self.resources.mixer.on_start_processing(); + self.resources.clocks.on_start_processing(); + self.resources.listeners.on_start_processing(); + self.resources.modulators.on_start_processing(); + } + + /// Produces the next [`Frame`]s of audio. + pub fn process(&mut self, out: &mut [f32], num_channels: u16) { + for chunk in out.chunks_mut(self.internal_buffer_size * num_channels as usize) { + self.process_chunk(chunk, num_channels); + } + } + + fn process_chunk(&mut self, chunk: &mut [f32], num_channels: u16) { + let num_frames = chunk.len() / num_channels as usize; + + self.resources.modulators.process( + self.dt * num_frames as f64, + &self.resources.clocks, + &self.resources.listeners, + ); + self.resources.clocks.update( + self.dt * num_frames as f64, + &self.resources.modulators, + &self.resources.listeners, + ); + self.resources.listeners.update( + self.dt * num_frames as f64, + &self.resources.clocks, + &self.resources.modulators, + ); + + self.resources.mixer.process( + &mut self.temp_buffer[..num_frames], + self.dt, + &self.resources.clocks, + &self.resources.modulators, + &self.resources.listeners, + ); + + // convert from frames to requested number of channels + for (i, channels) in chunk.chunks_mut(num_channels.into()).enumerate() { + let mut frame = self.temp_buffer[i]; + frame.left = frame.left.clamp(-1.0, 1.0); + frame.right = frame.right.clamp(-1.0, 1.0); + if num_channels == 1 { + channels[0] = (frame.left + frame.right) / 2.0; + } else { + channels[0] = frame.left; + channels[1] = frame.right; + /* + if there's more channels, send silence to them. if we don't, + we might get bad sounds outputted to those channels. + (https://github.com/tesselode/kira/issues/50) + */ + for channel in channels.iter_mut().skip(2) { + *channel = 0.0; + } + } + } + self.temp_buffer.fill(Frame::ZERO); + } +} diff --git a/third_party/kira/src/backend/resources.rs b/third_party/kira/src/backend/resources.rs new file mode 100644 index 00000000..ec7876ee --- /dev/null +++ b/third_party/kira/src/backend/resources.rs @@ -0,0 +1,309 @@ +pub(crate) mod clocks; +pub(crate) mod listeners; +pub(crate) mod mixer; +pub(crate) mod modulators; + +#[cfg(test)] +mod test; + +use std::{ + fmt::{Debug, Formatter}, + sync::Mutex, +}; + +use crate::{ + ResourceLimitReached, + listener::Listener, + track::{MainTrackBuilder, MainTrackHandle, SendTrack, Track}, +}; +use atomic_arena::{Arena, Controller, Key}; +use listeners::Listeners; +use rtrb::{Consumer, Producer, RingBuffer}; + +use crate::{clock::Clock, manager::Capacities, modulator::Modulator}; + +use self::{clocks::Clocks, mixer::Mixer, modulators::Modulators}; + +pub(crate) struct ResourceStorage { + pub(crate) resources: Arena, + new_resource_consumer: Consumer<(Key, T)>, + unused_resource_producer: Producer, +} + +impl ResourceStorage { + #[must_use] + pub fn new(capacity: usize) -> (Self, ResourceController) { + let (new_resource_producer, new_resource_consumer) = RingBuffer::new(capacity); + let (unused_resource_producer, unused_resource_consumer) = RingBuffer::new(capacity); + let resources = Arena::new(capacity); + let arena_controller = resources.controller(); + ( + Self { + resources, + new_resource_consumer, + unused_resource_producer, + }, + ResourceController { + arena_controller, + new_resource_producer: Mutex::new(new_resource_producer), + unused_resource_consumer: Mutex::new(unused_resource_consumer), + }, + ) + } + + pub fn remove_and_add(&mut self, remove_test: impl FnMut(&T) -> bool) { + for (_, resource) in self.resources.drain_filter(remove_test) { + self.unused_resource_producer + .push(resource) + .unwrap_or_else(|_| panic!("unused resource producer is full")); + } + while let Ok((key, resource)) = self.new_resource_consumer.pop() { + self.resources + .insert_with_key(key, resource) + .expect("error inserting resource"); + } + } + + #[must_use] + pub fn get_mut(&mut self, key: Key) -> Option<&mut T> { + self.resources.get_mut(key) + } + + #[must_use] + pub fn iter(&self) -> atomic_arena::iter::Iter<'_, T> { + self.resources.iter() + } + + #[must_use] + pub fn iter_mut(&mut self) -> atomic_arena::iter::IterMut<'_, T> { + self.resources.iter_mut() + } + + #[must_use] + pub fn is_empty(&self) -> bool { + self.resources.is_empty() + } +} + +impl<'a, T> IntoIterator for &'a mut ResourceStorage { + type Item = (Key, &'a mut T); + + type IntoIter = atomic_arena::iter::IterMut<'a, T>; + + fn into_iter(self) -> Self::IntoIter { + self.iter_mut() + } +} + +pub(crate) struct SelfReferentialResourceStorage { + pub(crate) resources: Arena, + keys: Vec, + new_resource_consumer: Consumer<(Key, T)>, + unused_resource_producer: Producer, + dummy: T, +} + +impl SelfReferentialResourceStorage { + pub fn new(capacity: usize) -> (Self, ResourceController) + where + T: Default, + { + let (new_resource_producer, new_resource_consumer) = RingBuffer::new(capacity); + let (unused_resource_producer, unused_resource_consumer) = RingBuffer::new(capacity); + let resources = Arena::new(capacity); + let arena_controller = resources.controller(); + ( + Self { + resources, + keys: Vec::with_capacity(capacity), + new_resource_consumer, + unused_resource_producer, + dummy: T::default(), + }, + ResourceController { + arena_controller, + new_resource_producer: Mutex::new(new_resource_producer), + unused_resource_consumer: Mutex::new(unused_resource_consumer), + }, + ) + } + + pub fn remove_and_add(&mut self, remove_test: impl FnMut(&T) -> bool) { + self.remove_unused(remove_test); + while let Ok((key, resource)) = self.new_resource_consumer.pop() { + self.resources + .insert_with_key(key, resource) + .expect("error inserting resource"); + self.keys.push(key); + } + } + + #[cfg(test)] + #[must_use] + pub fn get_mut(&mut self, key: Key) -> Option<&mut T> { + self.resources.get_mut(key) + } + + #[must_use] + pub fn iter_mut(&mut self) -> atomic_arena::iter::IterMut<'_, T> { + self.resources.iter_mut() + } + + pub fn for_each(&mut self, mut f: impl FnMut(&mut T, &mut Arena)) { + for key in &self.keys { + std::mem::swap(&mut self.resources[*key], &mut self.dummy); + f(&mut self.dummy, &mut self.resources); + std::mem::swap(&mut self.resources[*key], &mut self.dummy); + } + } + + fn remove_unused(&mut self, mut remove_test: impl FnMut(&T) -> bool) { + let mut i = 0; + while i < self.keys.len() && !self.unused_resource_producer.is_full() { + let key = self.keys[i]; + let resource = &mut self.resources[key]; + if remove_test(resource) { + let resource = self.resources.remove(key).unwrap(); + self.unused_resource_producer + .push(resource) + .unwrap_or_else(|_| panic!("unused resource producer is full")); + self.keys.remove(i); + } else { + i += 1; + } + } + } +} + +impl<'a, T> IntoIterator for &'a mut SelfReferentialResourceStorage { + type Item = (Key, &'a mut T); + + type IntoIter = atomic_arena::iter::IterMut<'a, T>; + + fn into_iter(self) -> Self::IntoIter { + self.iter_mut() + } +} + +pub(crate) struct ResourceController { + pub arena_controller: Controller, + pub new_resource_producer: Mutex>, + pub unused_resource_consumer: Mutex>, +} + +impl ResourceController { + pub fn insert(&mut self, resource: T) -> Result { + let key = self.try_reserve()?; + self.insert_with_key(key, resource); + Ok(key) + } + + pub fn try_reserve(&self) -> Result { + self.arena_controller + .try_reserve() + .map_err(|_| ResourceLimitReached) + } + + pub fn insert_with_key(&mut self, key: Key, resource: T) { + self.remove_unused(); + self.new_resource_producer + .get_mut() + .expect("new resource producer mutex poisoned") + .push((key, resource)) + .unwrap_or_else(|_| panic!("new resource producer full")); + } + + fn remove_unused(&mut self) { + let unused_resource_consumer = &mut self + .unused_resource_consumer + .get_mut() + .expect("unused resource consumer mutex poisoned"); + while unused_resource_consumer.pop().is_ok() {} + } + + #[must_use] + pub fn capacity(&self) -> usize { + self.arena_controller.capacity() + } + + #[must_use] + pub fn len(&self) -> usize { + self.arena_controller.len() + } +} + +impl Debug for ResourceController { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ResourceController") + .field("arena_controller", &self.arena_controller) + .field("new_resource_producer", &ProducerDebug) + .field("unused_resource_consumer", &ConsumerDebug) + .finish() + } +} + +struct ProducerDebug; + +impl Debug for ProducerDebug { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Producer").finish() + } +} + +struct ConsumerDebug; + +impl Debug for ConsumerDebug { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Consumer").finish() + } +} + +pub(crate) struct Resources { + pub mixer: Mixer, + pub clocks: Clocks, + pub modulators: Modulators, + pub listeners: Listeners, +} + +pub(crate) struct ResourceControllers { + pub sub_track_controller: ResourceController, + pub send_track_controller: ResourceController, + pub clock_controller: ResourceController, + pub modulator_controller: ResourceController>, + pub listener_controller: ResourceController, + pub main_track_handle: MainTrackHandle, +} + +pub(crate) fn create_resources( + capacities: Capacities, + main_track_builder: MainTrackBuilder, + sample_rate: u32, + internal_buffer_size: usize, +) -> (Resources, ResourceControllers) { + let (mixer, sub_track_controller, send_track_controller, main_track_handle) = Mixer::new( + capacities.sub_track_capacity, + capacities.send_track_capacity, + sample_rate, + internal_buffer_size, + main_track_builder, + ); + let (clocks, clock_controller) = Clocks::new(capacities.clock_capacity); + let (modulators, modulator_controller) = Modulators::new(capacities.modulator_capacity); + let (listeners, listener_controller) = Listeners::new(capacities.listener_capacity); + ( + Resources { + mixer, + clocks, + modulators, + listeners, + }, + ResourceControllers { + sub_track_controller, + send_track_controller, + clock_controller, + modulator_controller, + listener_controller, + main_track_handle, + }, + ) +} diff --git a/third_party/kira/src/backend/resources/clocks.rs b/third_party/kira/src/backend/resources/clocks.rs new file mode 100644 index 00000000..372da994 --- /dev/null +++ b/third_party/kira/src/backend/resources/clocks.rs @@ -0,0 +1,38 @@ +use crate::{clock::Clock, info::Info}; + +use super::{ + ResourceController, SelfReferentialResourceStorage, listeners::Listeners, + modulators::Modulators, +}; + +pub(crate) struct Clocks(pub(crate) SelfReferentialResourceStorage); + +impl Clocks { + #[must_use] + pub(crate) fn new(capacity: usize) -> (Self, ResourceController) { + let (storage, controller) = SelfReferentialResourceStorage::new(capacity); + (Self(storage), controller) + } + + pub(crate) fn on_start_processing(&mut self) { + self.0 + .remove_and_add(|clock| clock.shared().is_marked_for_removal()); + for (_, clock) in &mut self.0 { + clock.on_start_processing(); + } + } + + pub(crate) fn update(&mut self, dt: f64, modulators: &Modulators, listeners: &Listeners) { + self.0.for_each(|clock, others| { + clock.update( + dt, + &Info::new( + others, + &modulators.0.resources, + &listeners.0.resources, + None, + ), + ); + }); + } +} diff --git a/third_party/kira/src/backend/resources/listeners.rs b/third_party/kira/src/backend/resources/listeners.rs new file mode 100644 index 00000000..8353aaca --- /dev/null +++ b/third_party/kira/src/backend/resources/listeners.rs @@ -0,0 +1,32 @@ +use crate::{info::Info, listener::Listener}; + +use super::{ + ResourceController, SelfReferentialResourceStorage, clocks::Clocks, modulators::Modulators, +}; + +pub(crate) struct Listeners(pub(crate) SelfReferentialResourceStorage); + +impl Listeners { + #[must_use] + pub(crate) fn new(capacity: usize) -> (Self, ResourceController) { + let (storage, controller) = SelfReferentialResourceStorage::new(capacity); + (Self(storage), controller) + } + + pub(crate) fn on_start_processing(&mut self) { + self.0 + .remove_and_add(|listener| listener.shared.is_marked_for_removal()); + for (_, listener) in &mut self.0 { + listener.on_start_processing(); + } + } + + pub(crate) fn update(&mut self, dt: f64, clocks: &Clocks, modulators: &Modulators) { + self.0.for_each(|listener, others| { + listener.update( + dt, + &Info::new(&clocks.0.resources, &modulators.0.resources, others, None), + ); + }); + } +} diff --git a/third_party/kira/src/backend/resources/mixer.rs b/third_party/kira/src/backend/resources/mixer.rs new file mode 100644 index 00000000..e3bc678f --- /dev/null +++ b/third_party/kira/src/backend/resources/mixer.rs @@ -0,0 +1,112 @@ +use crate::{ + frame::Frame, + info::Info, + track::{MainTrack, MainTrackBuilder, MainTrackHandle, SendTrack, Track}, +}; + +use super::{ + ResourceController, ResourceStorage, clocks::Clocks, listeners::Listeners, + modulators::Modulators, +}; + +pub(crate) struct Mixer { + main_track: MainTrack, + sub_tracks: ResourceStorage, + send_tracks: ResourceStorage, + temp_buffer: Vec, +} + +impl Mixer { + #[must_use] + pub fn new( + sub_track_capacity: usize, + send_track_capacity: usize, + sample_rate: u32, + internal_buffer_size: usize, + main_track_builder: MainTrackBuilder, + ) -> ( + Self, + ResourceController, + ResourceController, + MainTrackHandle, + ) { + let (mut main_track, main_track_handle) = main_track_builder.build(internal_buffer_size); + main_track.init_effects(sample_rate); + let (sub_tracks, sub_track_controller) = ResourceStorage::new(sub_track_capacity); + let (send_tracks, send_track_controller) = ResourceStorage::new(send_track_capacity); + ( + Self { + main_track, + sub_tracks, + send_tracks, + temp_buffer: vec![Frame::ZERO; internal_buffer_size], + }, + sub_track_controller, + send_track_controller, + main_track_handle, + ) + } + + pub fn on_change_sample_rate(&mut self, sample_rate: u32) { + self.main_track.on_change_sample_rate(sample_rate); + for (_, track) in &mut self.sub_tracks { + track.on_change_sample_rate(sample_rate); + } + for (_, track) in &mut self.send_tracks { + track.on_change_sample_rate(sample_rate); + } + } + + pub fn on_start_processing(&mut self) { + self.sub_tracks + .remove_and_add(|track| track.should_be_removed()); + for (_, track) in &mut self.sub_tracks { + track.on_start_processing(); + } + self.send_tracks + .remove_and_add(|track| track.shared().is_marked_for_removal()); + for (_, track) in &mut self.send_tracks { + track.on_start_processing(); + } + self.main_track.on_start_processing(); + } + + pub fn process( + &mut self, + out: &mut [Frame], + dt: f64, + clocks: &Clocks, + modulators: &Modulators, + listeners: &Listeners, + ) { + for (_, track) in &mut self.sub_tracks { + track.process( + &mut self.temp_buffer[..out.len()], + dt, + clocks, + modulators, + listeners, + None, + &mut self.send_tracks, + ); + for (summed_out, sound_out) in out.iter_mut().zip(self.temp_buffer.iter().copied()) { + *summed_out += sound_out; + } + self.temp_buffer.fill(Frame::ZERO); + } + let info = Info::new( + &clocks.0.resources, + &modulators.0.resources, + &listeners.0.resources, + None, + ); + for (_, track) in &mut self.send_tracks { + track.process(&mut self.temp_buffer[..out.len()], dt, &info); + for (summed_out, sound_out) in out.iter_mut().zip(self.temp_buffer.iter().copied()) { + *summed_out += sound_out; + } + self.temp_buffer.fill(Frame::ZERO); + } + self.main_track.process(out, dt, &info); + } +} diff --git a/third_party/kira/src/backend/resources/modulators.rs b/third_party/kira/src/backend/resources/modulators.rs new file mode 100644 index 00000000..1d7b589d --- /dev/null +++ b/third_party/kira/src/backend/resources/modulators.rs @@ -0,0 +1,51 @@ +use crate::{info::Info, modulator::Modulator}; + +use super::{ + ResourceController, SelfReferentialResourceStorage, clocks::Clocks, listeners::Listeners, +}; + +pub(crate) struct Modulators(pub(crate) SelfReferentialResourceStorage>); + +impl Modulators { + #[must_use] + pub fn new(capacity: usize) -> (Self, ResourceController>) { + let (storage, controller) = SelfReferentialResourceStorage::new(capacity); + (Self(storage), controller) + } + + pub fn on_start_processing(&mut self) { + self.0.remove_and_add(|modulator| modulator.finished()); + for (_, modulator) in &mut self.0 { + modulator.on_start_processing(); + } + } + + pub fn process(&mut self, dt: f64, clocks: &Clocks, listeners: &Listeners) { + self.0.for_each(|modulator, others| { + modulator.update( + dt, + &Info::new(&clocks.0.resources, others, &listeners.0.resources, None), + ); + }); + } +} + +struct DummyModulator; + +impl Modulator for DummyModulator { + fn update(&mut self, _dt: f64, _info: &Info) {} + + fn value(&self) -> f64 { + 0.0 + } + + fn finished(&self) -> bool { + false + } +} + +impl Default for Box { + fn default() -> Self { + Box::new(DummyModulator) + } +} diff --git a/third_party/kira/src/backend/resources/test.rs b/third_party/kira/src/backend/resources/test.rs new file mode 100644 index 00000000..c0997e5f --- /dev/null +++ b/third_party/kira/src/backend/resources/test.rs @@ -0,0 +1,79 @@ +use crate::{ResourceLimitReached, backend::resources::SelfReferentialResourceStorage}; + +use super::ResourceStorage; + +#[test] +fn resource_storage() { + let (mut storage, mut controller) = ResourceStorage::new(2); + + // add + let one = controller.insert(1).unwrap(); + let two = controller.insert(2).unwrap(); + assert_eq!(controller.insert(3), Err(ResourceLimitReached)); + storage.remove_and_add(|_| false); + + // get + assert_eq!(storage.get_mut(one), Some(&mut 1)); + assert_eq!(storage.get_mut(two), Some(&mut 2)); + + // iter + assert_eq!( + storage.iter_mut().collect::>(), + vec![(two, &mut 2), (one, &mut 1)] + ); + + // remove + storage.remove_and_add(|&x| x < 2); + assert_eq!(storage.get_mut(one), None); + assert_eq!(storage.get_mut(two), Some(&mut 2)); + assert_eq!(storage.iter_mut().collect::>(), vec![(two, &mut 2)]); + + // re-add + let three = controller.insert(3).unwrap(); + assert_eq!(controller.insert(4), Err(ResourceLimitReached)); + storage.remove_and_add(|_| false); + assert_eq!(storage.get_mut(two), Some(&mut 2)); + assert_eq!(storage.get_mut(three), Some(&mut 3)); + assert_eq!( + storage.iter_mut().collect::>(), + vec![(three, &mut 3), (two, &mut 2)] + ); +} + +#[test] +fn self_referential_resource_storage() { + let (mut storage, mut controller) = SelfReferentialResourceStorage::new(2); + + // add + let one = controller.insert(1).unwrap(); + let two = controller.insert(2).unwrap(); + assert_eq!(controller.insert(3), Err(ResourceLimitReached)); + storage.remove_and_add(|_| false); + + // get + assert_eq!(storage.get_mut(one), Some(&mut 1)); + assert_eq!(storage.get_mut(two), Some(&mut 2)); + + // iter + assert_eq!( + storage.iter_mut().collect::>(), + vec![(two, &mut 2), (one, &mut 1)] + ); + + // remove + storage.remove_and_add(|&x| x < 2); + assert_eq!(storage.get_mut(one), None); + assert_eq!(storage.get_mut(two), Some(&mut 2)); + assert_eq!(storage.iter_mut().collect::>(), vec![(two, &mut 2)]); + + // re-add + let three = controller.insert(3).unwrap(); + assert_eq!(controller.insert(4), Err(ResourceLimitReached)); + storage.remove_and_add(|_| false); + assert_eq!(storage.get_mut(two), Some(&mut 2)); + assert_eq!(storage.get_mut(three), Some(&mut 3)); + assert_eq!( + storage.iter_mut().collect::>(), + vec![(three, &mut 3), (two, &mut 2)] + ); +} diff --git a/third_party/kira/src/clock.rs b/third_party/kira/src/clock.rs new file mode 100644 index 00000000..519d3c5d --- /dev/null +++ b/third_party/kira/src/clock.rs @@ -0,0 +1,355 @@ +/*! +Precise timing for audio events. + +Clocks can be used to set the start times of sounds and tweens. To create a +clock, use [`AudioManager::add_clock`](crate::AudioManager::add_clock). + +```no_run +use kira::{ + AudioManager, AudioManagerSettings, DefaultBackend, + clock::ClockSpeed, +}; + +let mut manager = AudioManager::::new(AudioManagerSettings::default())?; +let mut clock = manager.add_clock(ClockSpeed::SecondsPerTick(1.0))?; +clock.start(); +# Result::<(), Box>::Ok(()) +``` + +You can specify the speed of the clock as seconds per tick, ticks per second, or +ticks per minute. + +Clocks are stopped when you first create them, so be sure to explicitly call +[`ClockHandle::start`] when you want the clock to start ticking. + +## Starting sounds on clock ticks + +Sounds can be set to only start playing when a clock has ticked a certain +number of times. You can configure this using +[`StaticSoundData::start_time`](crate::sound::static_sound::StaticSoundData::start_time) +or [`StreamingSoundData::start_time`](crate::sound::streaming::StreamingSoundData::start_time). + +```no_run +use kira::{ + clock::{ClockTime, ClockSpeed}, + AudioManager, AudioManagerSettings, DefaultBackend, + sound::static_sound::{StaticSoundData, StaticSoundSettings}, + StartTime, +}; + +let mut manager = AudioManager::::new(AudioManagerSettings::default())?; +let mut clock = manager.add_clock(ClockSpeed::SecondsPerTick(1.0))?; +manager.play( + StaticSoundData::from_file("sound.ogg")? + .start_time(StartTime::ClockTime(ClockTime { + clock: clock.id(), + ticks: 4, + fraction: 0.0, + })) +)?; +clock.start(); +# Result::<(), Box>::Ok(()) +``` + +As a shorthand, you can pass the [`ClockTime`] directly into +the `start_time` function. + +```no_run +# use kira::{ +# clock::{ClockTime, ClockSpeed}, +# AudioManager, +# AudioManagerSettings, +# DefaultBackend, +# sound::static_sound::{StaticSoundData, StaticSoundSettings}, +# StartTime, +# }; +# +# let mut manager = AudioManager::::new( +# AudioManagerSettings::default(), +# )?; +# let mut clock = manager.add_clock(ClockSpeed::SecondsPerTick(1.0))?; +manager.play( + StaticSoundData::from_file("sound.ogg")? + .start_time(ClockTime { + clock: clock.id(), + ticks: 4, + fraction: 0.0, + }) +)?; +# clock.start(); +# Result::<(), Box>::Ok(()) +``` + +As an even shorter hand, you can use [`ClockHandle::time`] to get the clock's +current [`ClockTime`], and then add to it to get a time in the future: + +```no_run +use kira::{ + AudioManager, AudioManagerSettings, DefaultBackend, + sound::static_sound::{StaticSoundData, StaticSoundSettings}, + clock::ClockSpeed, +}; + +# let mut manager = AudioManager::::new( +# AudioManagerSettings::default(), +# )?; +# let mut clock = manager.add_clock(ClockSpeed::SecondsPerTick(1.0))?; +manager.play( + StaticSoundData::from_file("sound.ogg")? + .start_time(clock.time() + 4) +)?; +# clock.start(); +# Result::<(), Box>::Ok(()) +``` + +## Starting tweens on clock ticks + +You can also use clocks to set the start time of tweens. In this example, we set +the playback rate of a sound to start tweening when a clock reaches its third +tick. + +```no_run +use std::time::Duration; + +use kira::{ + AudioManager, AudioManagerSettings, DefaultBackend, + sound::static_sound::{StaticSoundData, StaticSoundSettings}, + Tween, + clock::ClockSpeed, + StartTime, +}; + +let mut manager = AudioManager::::new(AudioManagerSettings::default())?; +let mut clock = manager.add_clock(ClockSpeed::SecondsPerTick(1.0))?; +let mut sound = manager.play(StaticSoundData::from_file("sound.ogg")?)?; +sound.set_playback_rate( + 0.5, + Tween { + start_time: StartTime::ClockTime(clock.time() + 3), + duration: Duration::from_secs(2), + ..Default::default() + }, +); +clock.start(); +# Result::<(), Box>::Ok(()) +``` +*/ + +mod clock_speed; +mod handle; +mod time; + +#[cfg(test)] +mod test; + +use atomic_arena::Key; +pub use clock_speed::*; +pub use handle::*; +pub use time::*; + +use std::sync::{ + Arc, + atomic::{AtomicBool, AtomicU64, Ordering}, +}; + +use crate::{ + Parameter, Value, + command::{ValueChangeCommand, read_commands_into_parameters}, + command_writers_and_readers, + info::Info, +}; + +/// A unique identifier for a clock. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct ClockId(pub(crate) Key); + +#[derive(Debug)] +pub(crate) struct ClockShared { + ticking: AtomicBool, + ticks: AtomicU64, + fractional_position: AtomicU64, + removed: AtomicBool, +} + +impl ClockShared { + #[must_use] + pub fn new() -> Self { + Self { + ticking: AtomicBool::new(false), + ticks: AtomicU64::new(0), + fractional_position: AtomicU64::new(0.0f64.to_bits()), + removed: AtomicBool::new(false), + } + } + + #[must_use] + pub fn ticking(&self) -> bool { + self.ticking.load(Ordering::SeqCst) + } + + #[must_use] + pub fn ticks(&self) -> u64 { + self.ticks.load(Ordering::SeqCst) + } + + #[must_use] + pub fn fractional_position(&self) -> f64 { + f64::from_bits(self.fractional_position.load(Ordering::SeqCst)) + } + + #[must_use] + pub fn is_marked_for_removal(&self) -> bool { + self.removed.load(Ordering::SeqCst) + } + + pub fn mark_for_removal(&self) { + self.removed.store(true, Ordering::SeqCst); + } +} + +#[derive(Debug, Clone, Copy, PartialEq)] +pub(crate) enum State { + NotStarted, + Started { + ticks: u64, + fractional_position: f64, + }, +} + +pub(crate) struct Clock { + command_readers: CommandReaders, + shared: Arc, + ticking: bool, + speed: Parameter, + state: State, +} + +impl Clock { + #[must_use] + pub(crate) fn new(speed: Value, id: ClockId) -> (Self, ClockHandle) { + let (command_writers, command_readers) = command_writers_and_readers(); + let shared = Arc::new(ClockShared::new()); + ( + Self { + command_readers, + shared: shared.clone(), + ticking: false, + speed: Parameter::new(speed, ClockSpeed::TicksPerMinute(120.0)), + state: State::NotStarted, + }, + ClockHandle { + id, + shared, + command_writers, + }, + ) + } + + #[must_use] + pub(crate) fn without_handle(speed: Value) -> Self { + let (_, command_readers) = command_writers_and_readers(); + Self { + command_readers, + shared: Arc::new(ClockShared::new()), + ticking: false, + speed: Parameter::new(speed, ClockSpeed::TicksPerMinute(120.0)), + state: State::NotStarted, + } + } + + #[must_use] + pub(crate) fn shared(&self) -> Arc { + self.shared.clone() + } + + #[must_use] + pub(crate) fn state(&self) -> State { + self.state + } + + #[must_use] + pub(crate) fn ticking(&self) -> bool { + self.ticking + } + + pub(crate) fn on_start_processing(&mut self) { + read_commands_into_parameters!(self, speed); + if let Some(ticking) = self.command_readers.set_ticking.read() { + self.set_ticking(ticking); + } + if self.command_readers.reset.read().is_some() { + self.reset(); + } + self.update_shared(); + } + + fn set_ticking(&mut self, ticking: bool) { + self.ticking = ticking; + self.shared.ticking.store(ticking, Ordering::SeqCst); + } + + fn reset(&mut self) { + self.state = State::NotStarted; + self.shared.ticks.store(0, Ordering::SeqCst); + } + + fn update_shared(&mut self) { + let (ticks, fractional_position) = match &self.state { + State::NotStarted => (0, 0.0), + State::Started { + ticks, + fractional_position, + } => (*ticks, *fractional_position), + }; + self.shared.ticks.store(ticks, Ordering::SeqCst); + self.shared + .fractional_position + .store(fractional_position.to_bits(), Ordering::SeqCst); + } + + /// Updates the [`Clock`]. + /// + /// If the tick count changes this update, returns `Some(tick_number)`. + /// Otherwise, returns `None`. + pub(crate) fn update(&mut self, dt: f64, info: &Info) -> Option { + self.speed.update(dt, info); + if !self.ticking { + return None; + } + let mut new_tick_count = None; + if self.state == State::NotStarted { + self.state = State::Started { + ticks: 0, + fractional_position: 0.0, + }; + new_tick_count = Some(0); + } + if let State::Started { + ticks, + fractional_position: tick_timer, + } = &mut self.state + { + *tick_timer += self.speed.value().as_ticks_per_second() * dt; + while *tick_timer >= 1.0 { + *tick_timer -= 1.0; + *ticks += 1; + new_tick_count = Some(*ticks); + } + } else { + panic!("clock state should be Started by now"); + } + new_tick_count + } +} + +impl Default for Clock { + fn default() -> Self { + Self::without_handle(Value::Fixed(ClockSpeed::TicksPerSecond(0.0))) + } +} + +command_writers_and_readers! { + set_speed: ValueChangeCommand, + set_ticking: bool, + reset: (), +} diff --git a/third_party/kira/src/clock/clock_speed.rs b/third_party/kira/src/clock/clock_speed.rs new file mode 100644 index 00000000..b79e0947 --- /dev/null +++ b/third_party/kira/src/clock/clock_speed.rs @@ -0,0 +1,121 @@ +use crate::{Value, tween::Tweenable}; + +/// The rate that a [clock](crate::clock) ticks at. +#[derive(Debug, Clone, Copy, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub enum ClockSpeed { + /// The clock ticks every x seconds. + SecondsPerTick(f64), + /// The clock ticks x times per second. + TicksPerSecond(f64), + /// The clock ticks x times per minute. + TicksPerMinute(f64), +} + +impl ClockSpeed { + /// Returns the [`ClockSpeed`] as a number of seconds between each tick. + #[must_use] + pub fn as_seconds_per_tick(&self) -> f64 { + match self { + ClockSpeed::SecondsPerTick(seconds_per_tick) => *seconds_per_tick, + ClockSpeed::TicksPerSecond(ticks_per_second) => 1.0 / *ticks_per_second, + ClockSpeed::TicksPerMinute(ticks_per_minute) => 60.0 / *ticks_per_minute, + } + } + + /// Returns the [`ClockSpeed`] as a number of ticks per second. + #[must_use] + pub fn as_ticks_per_second(&self) -> f64 { + match self { + ClockSpeed::SecondsPerTick(seconds_per_tick) => 1.0 / *seconds_per_tick, + ClockSpeed::TicksPerSecond(ticks_per_second) => *ticks_per_second, + ClockSpeed::TicksPerMinute(ticks_per_minute) => *ticks_per_minute / 60.0, + } + } + + /// Returns the [`ClockSpeed`] as a number of ticks per minute. + #[must_use] + pub fn as_ticks_per_minute(&self) -> f64 { + match self { + ClockSpeed::SecondsPerTick(seconds_per_tick) => 60.0 / *seconds_per_tick, + ClockSpeed::TicksPerSecond(ticks_per_second) => *ticks_per_second * 60.0, + ClockSpeed::TicksPerMinute(ticks_per_minute) => *ticks_per_minute, + } + } +} + +impl Tweenable for ClockSpeed { + fn interpolate(a: Self, b: Self, amount: f64) -> Self { + match b { + ClockSpeed::SecondsPerTick(b) => ClockSpeed::SecondsPerTick(Tweenable::interpolate( + a.as_seconds_per_tick(), + b, + amount, + )), + ClockSpeed::TicksPerSecond(b) => ClockSpeed::TicksPerSecond(Tweenable::interpolate( + a.as_ticks_per_second(), + b, + amount, + )), + ClockSpeed::TicksPerMinute(b) => ClockSpeed::TicksPerMinute(Tweenable::interpolate( + a.as_ticks_per_minute(), + b, + amount, + )), + } + } +} + +impl From for Value { + fn from(clock_speed: ClockSpeed) -> Self { + Value::Fixed(clock_speed) + } +} + +#[cfg(test)] +#[test] +#[allow(clippy::float_cmp)] +fn test() { + const SECONDS_PER_TICK: f64 = 0.5; + const TICKS_PER_SECOND: f64 = 2.0; + const TICKS_PER_MINUTE: f64 = 120.0; + + assert_eq!( + ClockSpeed::SecondsPerTick(SECONDS_PER_TICK).as_seconds_per_tick(), + SECONDS_PER_TICK + ); + assert_eq!( + ClockSpeed::SecondsPerTick(SECONDS_PER_TICK).as_ticks_per_second(), + TICKS_PER_SECOND + ); + assert_eq!( + ClockSpeed::SecondsPerTick(SECONDS_PER_TICK).as_ticks_per_minute(), + TICKS_PER_MINUTE + ); + + assert_eq!( + ClockSpeed::TicksPerSecond(TICKS_PER_SECOND).as_seconds_per_tick(), + SECONDS_PER_TICK + ); + assert_eq!( + ClockSpeed::TicksPerSecond(TICKS_PER_SECOND).as_ticks_per_second(), + TICKS_PER_SECOND + ); + assert_eq!( + ClockSpeed::TicksPerSecond(TICKS_PER_SECOND).as_ticks_per_minute(), + TICKS_PER_MINUTE + ); + + assert_eq!( + ClockSpeed::TicksPerMinute(TICKS_PER_MINUTE).as_seconds_per_tick(), + SECONDS_PER_TICK + ); + assert_eq!( + ClockSpeed::TicksPerMinute(TICKS_PER_MINUTE).as_ticks_per_second(), + TICKS_PER_SECOND + ); + assert_eq!( + ClockSpeed::TicksPerMinute(TICKS_PER_MINUTE).as_ticks_per_minute(), + TICKS_PER_MINUTE + ); +} diff --git a/third_party/kira/src/clock/handle.rs b/third_party/kira/src/clock/handle.rs new file mode 100644 index 00000000..2ace734e --- /dev/null +++ b/third_party/kira/src/clock/handle.rs @@ -0,0 +1,81 @@ +use std::sync::{Arc, atomic::Ordering}; + +use crate::command::handle_param_setters; + +use super::{ClockId, ClockShared, ClockSpeed, ClockTime, CommandWriters}; + +/// Controls a clock. +/// +/// When a [`ClockHandle`] is dropped, the corresponding clock +/// will be removed. +#[derive(Debug)] +pub struct ClockHandle { + pub(crate) id: ClockId, + pub(crate) shared: Arc, + pub(crate) command_writers: CommandWriters, +} + +impl ClockHandle { + /// Returns the unique identifier for the clock. + #[must_use] + pub fn id(&self) -> ClockId { + self.id + } + + /// Returns `true` if the clock is currently ticking + /// and `false` if not. + #[must_use] + pub fn ticking(&self) -> bool { + self.shared.ticking() + } + + /// Returns the current time of the clock. + #[must_use] + pub fn time(&self) -> ClockTime { + ClockTime { + clock: self.id, + ticks: self.shared.ticks(), + fraction: self.shared.fractional_position(), + } + } + + handle_param_setters! { + /// Sets the speed of the clock. + speed: ClockSpeed, + } + + /// Starts or resumes the clock. + pub fn start(&mut self) { + self.command_writers.set_ticking.write(true) + } + + /// Pauses the clock. + pub fn pause(&mut self) { + self.command_writers.set_ticking.write(false) + } + + /// Stops and resets the clock. + pub fn stop(&mut self) { + self.command_writers.set_ticking.write(false); + self.command_writers.reset.write(()); + // store 0 as the current time so any reads that happen + // immediately after a stop don't errantly get the previous + // clock time + self.shared.ticks.store(0, Ordering::SeqCst); + self.shared + .fractional_position + .store(0.0f64.to_bits(), Ordering::SeqCst); + } +} + +impl Drop for ClockHandle { + fn drop(&mut self) { + self.shared.mark_for_removal(); + } +} + +impl From<&ClockHandle> for ClockId { + fn from(handle: &ClockHandle) -> Self { + handle.id() + } +} diff --git a/third_party/kira/src/clock/test.rs b/third_party/kira/src/clock/test.rs new file mode 100644 index 00000000..e01eb30f --- /dev/null +++ b/third_party/kira/src/clock/test.rs @@ -0,0 +1,209 @@ +use std::time::Duration; + +use atomic_arena::Arena; + +use crate::{StartTime, Tween, Value, clock::ClockTime, info::MockInfoBuilder}; + +use super::{Clock, ClockId, ClockSpeed}; + +/// Tests that a `Clock` is stopped when it's first created. +#[test] +fn initially_stopped() { + let (mut clock, handle) = Clock::new( + Value::Fixed(ClockSpeed::SecondsPerTick(1.0)), + fake_clock_id(), + ); + for _ in 0..3 { + assert!(!handle.ticking()); + assert_eq!(handle.time().ticks, 0); + clock.update(1.0, &MockInfoBuilder::new().build()); + clock.on_start_processing(); + } +} + +/// Tests that a `Clock` ticks. +#[test] +fn basic_behavior() { + let (mut clock, mut handle) = Clock::new( + Value::Fixed(ClockSpeed::SecondsPerTick(1.0)), + fake_clock_id(), + ); + handle.start(); + clock.on_start_processing(); + for i in 0..3 { + assert!(handle.ticking()); + assert_eq!(handle.time().ticks, i); + assert_eq!( + clock.update(1.0, &MockInfoBuilder::new().build()), + Some(i + 1) + ); + clock.on_start_processing(); + } +} + +/// Tests that a `Clock` can be paused. +#[test] +fn pause() { + let (mut clock, mut handle) = Clock::new( + Value::Fixed(ClockSpeed::SecondsPerTick(1.0)), + fake_clock_id(), + ); + handle.start(); + clock.on_start_processing(); + clock.update(1.5, &MockInfoBuilder::new().build()); + clock.on_start_processing(); + assert_eq!(handle.time().ticks, 1); + handle.pause(); + clock.on_start_processing(); + // the clock should not be ticking + for _ in 0..3 { + clock.update(1.0, &MockInfoBuilder::new().build()); + clock.on_start_processing(); + assert!(!handle.ticking()); + assert_eq!(handle.time().ticks, 1); + } + handle.start(); + clock.on_start_processing(); + // make sure we've preserved the fractional position from before + // pausing + clock.update(0.4, &MockInfoBuilder::new().build()); + clock.on_start_processing(); + assert_eq!(handle.time().ticks, 1); + clock.update(0.1, &MockInfoBuilder::new().build()); + clock.on_start_processing(); + assert_eq!(handle.time().ticks, 2); +} + +/// Tests that a `Clock` can be stopped. +#[test] +fn stop() { + let (mut clock, mut handle) = Clock::new( + Value::Fixed(ClockSpeed::SecondsPerTick(1.0)), + fake_clock_id(), + ); + handle.start(); + clock.on_start_processing(); + clock.update(1.5, &MockInfoBuilder::new().build()); + clock.on_start_processing(); + handle.stop(); + clock.on_start_processing(); + // the clock should not be ticking + for _ in 0..3 { + clock.update(1.0, &MockInfoBuilder::new().build()); + clock.on_start_processing(); + assert!(!handle.ticking()); + assert_eq!(handle.time().ticks, 0); + } + handle.start(); + clock.on_start_processing(); + // make sure the fractional position has been reset + clock.update(0.9, &MockInfoBuilder::new().build()); + clock.on_start_processing(); + assert_eq!(handle.time().ticks, 0); + clock.update(0.1, &MockInfoBuilder::new().build()); + clock.on_start_processing(); + assert_eq!(handle.time().ticks, 1); +} + +/// Tests that the speed of a [`Clock`] can be changed after creation. +#[test] +fn set_speed() { + let (mut clock, mut handle) = Clock::new( + Value::Fixed(ClockSpeed::SecondsPerTick(1.0)), + fake_clock_id(), + ); + handle.start(); + clock.on_start_processing(); + handle.set_speed( + Value::Fixed(ClockSpeed::SecondsPerTick(0.5)), + Tween { + duration: Duration::ZERO, + ..Default::default() + }, + ); + clock.on_start_processing(); + clock.update(1.0, &MockInfoBuilder::new().build()); + clock.on_start_processing(); + assert_eq!(handle.time().ticks, 2); + clock.update(1.0, &MockInfoBuilder::new().build()); + clock.on_start_processing(); + assert_eq!(handle.time().ticks, 4); +} + +/// Tests that a clock speed tween properly responds to ticks from +/// other clocks when the start time is set to a clock time. +#[test] +fn set_speed_with_clock_time_start() { + let mut info_builder = MockInfoBuilder::new(); + let clock_id = info_builder.add_clock(true, 0, 0.0); + let info = info_builder.build(); + + let (mut clock, mut handle) = Clock::new( + Value::Fixed(ClockSpeed::SecondsPerTick(1.0)), + fake_clock_id(), + ); + handle.start(); + clock.on_start_processing(); + handle.set_speed( + Value::Fixed(ClockSpeed::SecondsPerTick(0.5)), + Tween { + duration: Duration::ZERO, + start_time: StartTime::ClockTime(ClockTime { + clock: clock_id, + ticks: 1, + fraction: 0.0, + }), + ..Default::default() + }, + ); + clock.on_start_processing(); + + clock.update(1.0, &info); + clock.on_start_processing(); + assert_eq!(handle.time().ticks, 1); + clock.update(1.0, &info); + clock.on_start_processing(); + assert_eq!(handle.time().ticks, 2); + + let info = { + let mut builder = MockInfoBuilder::new(); + builder.add_clock(true, 1, 0.0); + builder.build() + }; + + clock.update(1.0, &info); + clock.on_start_processing(); + assert_eq!(handle.time().ticks, 4); + clock.update(1.0, &info); + clock.on_start_processing(); + assert_eq!(handle.time().ticks, 6); +} + +/// Tests that a clock correctly reports its fractional position. +#[test] +fn fractional_position() { + let (mut clock, mut handle) = Clock::new( + Value::Fixed(ClockSpeed::SecondsPerTick(1.0)), + fake_clock_id(), + ); + assert_eq!(handle.time().fraction, 0.0); + // the clock is not started yet, so the fractional position should remain at 0 + clock.update(1.0, &MockInfoBuilder::new().build()); + clock.on_start_processing(); + assert_eq!(handle.time().fraction, 0.0); + // start the clock + handle.start(); + clock.on_start_processing(); + clock.update(0.5, &MockInfoBuilder::new().build()); + clock.on_start_processing(); + assert_eq!(handle.time().fraction, 0.5); + clock.update(0.75, &MockInfoBuilder::new().build()); + clock.on_start_processing(); + assert_eq!(handle.time().fraction, 0.25); +} + +fn fake_clock_id() -> ClockId { + let mut arena = Arena::new(1); + let key = arena.insert(()).unwrap(); + ClockId(key) +} diff --git a/third_party/kira/src/clock/time.rs b/third_party/kira/src/clock/time.rs new file mode 100644 index 00000000..5ad9220b --- /dev/null +++ b/third_party/kira/src/clock/time.rs @@ -0,0 +1,212 @@ +#[cfg(test)] +mod test; + +use std::{ + cmp::Ordering, + ops::{Add, AddAssign, Sub, SubAssign}, +}; + +use super::ClockId; + +/** +An instant in time associated with a clock. + +`ClockTime`s implement [`PartialOrd`]. They can be compared as long +as both times are associated with the same clock. If the clocks are +different, `a.cmp(b)` will return `None`. +*/ +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct ClockTime { + /// The clock this time is associated with. + pub clock: ClockId, + /// The elapsed time in whole ticks. + pub ticks: u64, + /// The amount of time since the last tick as a fraction of a tick. + /// + /// This will always be in the range of `0.0` (inclusive) to `1.0` (exclusive). + pub fraction: f64, +} + +impl ClockTime { + /** + Creates a [`ClockTime`] for a clock with the specified number of + whole ticks. + + # Example + + ```no_run + use kira::{ + AudioManager, + AudioManagerSettings, + DefaultBackend, + clock::{ClockSpeed, ClockTime}, + }; + + let mut manager = AudioManager::::new(AudioManagerSettings::default())?; + let clock = manager.add_clock(ClockSpeed::TicksPerMinute(120.0))?; + assert_eq!( + ClockTime::from_ticks_u64(&clock, 3), + ClockTime { + clock: clock.id(), + ticks: 3, + fraction: 0.0, + }, + ); + # Ok::<(), Box>(()) + ``` + */ + pub fn from_ticks_u64(clock: impl Into, ticks: u64) -> Self { + Self { + clock: clock.into(), + ticks, + fraction: 0.0, + } + } + + /** + Creates a [`ClockTime`] for a clock with the specified number of + ticks. + + # Example + + ```no_run + use kira::{ + AudioManager, + AudioManagerSettings, + DefaultBackend, + clock::{ClockSpeed, ClockTime}, + }; + + let mut manager = AudioManager::::new(AudioManagerSettings::default())?; + let clock = manager.add_clock(ClockSpeed::TicksPerMinute(120.0))?; + assert_eq!( + ClockTime::from_ticks_f64(&clock, 3.5), + ClockTime { + clock: clock.id(), + ticks: 3, + fraction: 0.5, + }, + ); + # Ok::<(), Box>(()) + ``` + */ + pub fn from_ticks_f64(clock: impl Into, ticks: f64) -> Self { + Self { + clock: clock.into(), + ticks: ticks as u64, + fraction: ticks.fract(), + } + } +} + +impl Add for ClockTime { + type Output = ClockTime; + + fn add(self, ticks: u64) -> Self::Output { + Self { + clock: self.clock, + ticks: self.ticks + ticks, + fraction: self.fraction, + } + } +} + +impl AddAssign for ClockTime { + fn add_assign(&mut self, ticks: u64) { + self.ticks += ticks; + } +} + +impl Add for ClockTime { + type Output = ClockTime; + + fn add(self, ticks: f64) -> Self::Output { + debug_assert!( + ticks.is_finite(), + "Cannot add a non-finite amount to ClockTime: {ticks:?}" + ); + + // This implementation does not allow adding negative numbers, use the Sub impl instead + if ticks.is_sign_negative() { + return self.sub(-ticks); + } + + let fraction = (self.fraction + ticks).fract(); + let ticks = self.ticks + (self.fraction + ticks).trunc() as u64; + + Self { + clock: self.clock, + ticks, + fraction, + } + } +} + +impl AddAssign for ClockTime { + fn add_assign(&mut self, ticks: f64) { + *self = *self + ticks; + } +} + +impl Sub for ClockTime { + type Output = ClockTime; + + fn sub(self, ticks: u64) -> Self::Output { + Self { + clock: self.clock, + ticks: self.ticks - ticks, + fraction: self.fraction, + } + } +} + +impl SubAssign for ClockTime { + fn sub_assign(&mut self, ticks: u64) { + self.ticks -= ticks; + } +} + +impl Sub for ClockTime { + type Output = ClockTime; + + fn sub(self, ticks: f64) -> Self::Output { + debug_assert!( + ticks.is_finite(), + "Cannot subtract a non-finite amount from ClockTime: {ticks:?}" + ); + + // This implementation does not allow subtracting negative numbers, use the Add impl instead + if ticks.is_sign_negative() { + return self.add(-ticks); + } + + let fraction = ((self.fraction - ticks).fract() + 1.0) % 1.0; + let ticks = self + .ticks + .saturating_sub((ticks - self.fraction).ceil() as u64); + + Self { + clock: self.clock, + ticks, + fraction, + } + } +} + +impl SubAssign for ClockTime { + fn sub_assign(&mut self, ticks: f64) { + *self = *self - ticks; + } +} + +impl PartialOrd for ClockTime { + fn partial_cmp(&self, other: &Self) -> Option { + if self.clock != other.clock { + return None; + } + match self.ticks.cmp(&other.ticks) { + Ordering::Equal => self.fraction.partial_cmp(&other.fraction), + ordering => Some(ordering), + } + } +} diff --git a/third_party/kira/src/clock/time/test.rs b/third_party/kira/src/clock/time/test.rs new file mode 100644 index 00000000..39344766 --- /dev/null +++ b/third_party/kira/src/clock/time/test.rs @@ -0,0 +1,254 @@ +use std::cmp::Ordering; + +use approx::assert_relative_eq; +use atomic_arena::Arena; + +use crate::clock::{ClockId, ClockTime}; + +#[test] +fn from_ticks_u64() { + let id = fake_clock_id(); + assert_eq!( + ClockTime::from_ticks_u64(id, 3), + ClockTime { + clock: id, + ticks: 3, + fraction: 0.0 + } + ) +} + +#[test] +fn from_ticks_f64() { + let id = fake_clock_id(); + assert_eq!( + ClockTime::from_ticks_f64(id, 3.5), + ClockTime { + clock: id, + ticks: 3, + fraction: 0.5 + } + ) +} + +#[test] +fn add_u64() { + let id = fake_clock_id(); + assert_eq!( + ClockTime { + clock: id, + ticks: 2, + fraction: 0.0, + } + 3, + ClockTime { + clock: id, + ticks: 5, + fraction: 0.0, + } + ); + assert_eq!( + ClockTime { + clock: id, + ticks: 2, + fraction: 0.5, + } + 2, + ClockTime { + clock: id, + ticks: 4, + fraction: 0.5, + } + ); +} + +#[test] +fn sub_u64() { + let id = fake_clock_id(); + assert_eq!( + ClockTime { + clock: id, + ticks: 5, + fraction: 0.0, + } - 3, + ClockTime { + clock: id, + ticks: 2, + fraction: 0.0, + } + ); + assert_eq!( + ClockTime { + clock: id, + ticks: 4, + fraction: 0.5, + } - 2, + ClockTime { + clock: id, + ticks: 2, + fraction: 0.5, + } + ); +} + +#[test] +fn add_f64() { + let id = fake_clock_id(); + let time = ClockTime { + clock: id, + ticks: 2, + fraction: 0.5, + } + 3.7; + assert_eq!(time.ticks, 6); + assert_relative_eq!(time.fraction, 0.2); + let time = ClockTime { + clock: id, + ticks: 2, + fraction: 0.5, + } + 3.5; + assert_eq!(time.ticks, 6); + assert_relative_eq!(time.fraction, 0.0); + let id = fake_clock_id(); + let time = ClockTime { + clock: id, + ticks: 5, + fraction: 0.5, + } + (-3.7); + assert_eq!(time.ticks, 1); + assert_relative_eq!(time.fraction, 0.8); +} + +#[test] +fn sub_f64() { + let id = fake_clock_id(); + let time = ClockTime { + clock: id, + ticks: 5, + fraction: 0.5, + } - 3.7; + assert_eq!(time.ticks, 1); + assert_relative_eq!(time.fraction, 0.8); + let time = ClockTime { + clock: id, + ticks: 5, + fraction: 0.5, + } - 3.5; + assert_eq!(time.ticks, 2); + assert_relative_eq!(time.fraction, 0.0); + let id = fake_clock_id(); + let time = ClockTime { + clock: id, + ticks: 2, + fraction: 0.5, + } - (-3.7); + assert_eq!(time.ticks, 6); + assert_relative_eq!(time.fraction, 0.2); +} + +#[test] +fn partial_cmp() { + let ids = fake_clock_ids(2); + assert_eq!( + ClockTime { + clock: ids[0], + ticks: 5, + fraction: 0.0 + } + .partial_cmp(&ClockTime { + clock: ids[0], + ticks: 5, + fraction: 0.0 + }), + Some(Ordering::Equal) + ); + assert_eq!( + ClockTime { + clock: ids[0], + ticks: 4, + fraction: 0.0 + } + .partial_cmp(&ClockTime { + clock: ids[0], + ticks: 5, + fraction: 0.0 + }), + Some(Ordering::Less) + ); + assert_eq!( + ClockTime { + clock: ids[0], + ticks: 6, + fraction: 0.0 + } + .partial_cmp(&ClockTime { + clock: ids[0], + ticks: 5, + fraction: 0.0 + }), + Some(Ordering::Greater) + ); + assert_eq!( + ClockTime { + clock: ids[0], + ticks: 5, + fraction: 0.0 + } + .partial_cmp(&ClockTime { + clock: ids[0], + ticks: 5, + fraction: 0.5 + }), + Some(Ordering::Less) + ); + assert_eq!( + ClockTime { + clock: ids[0], + ticks: 5, + fraction: 0.5 + } + .partial_cmp(&ClockTime { + clock: ids[0], + ticks: 5, + fraction: 0.0 + }), + Some(Ordering::Greater) + ); + assert_eq!( + ClockTime { + clock: ids[0], + ticks: 5, + fraction: 0.5 + } + .partial_cmp(&ClockTime { + clock: ids[1], + ticks: 5, + fraction: 0.0 + }), + None + ); + assert_eq!( + ClockTime { + clock: ids[0], + ticks: 5, + fraction: 0.5 + } + .partial_cmp(&ClockTime { + clock: ids[0], + ticks: 5, + fraction: f64::NAN, + }), + None + ); +} + +fn fake_clock_id() -> ClockId { + let mut arena = Arena::new(1); + let key = arena.insert(()).unwrap(); + ClockId(key) +} + +fn fake_clock_ids(count: usize) -> Vec { + let mut arena = Arena::new(count); + (0..count) + .map(|_| arena.insert(()).unwrap()) + .map(ClockId) + .collect() +} diff --git a/third_party/kira/src/command.rs b/third_party/kira/src/command.rs new file mode 100644 index 00000000..55256e01 --- /dev/null +++ b/third_party/kira/src/command.rs @@ -0,0 +1,180 @@ +/*! +Helpers for sending commands from the gameplay thread to the audio thread. +You'll only need to use this if you're making your own implementation of +[`Sound`](crate::sound::Sound), [`Effect`](crate::effect::Effect), +or [`Modulator`](crate::modulator::Modulator). + +This module provides a [`CommandWriter`] and [`CommandReader`] that can +transfer data from one thread to another without blocking or waiting on either +thread. `CommandReader::read` will return the latest data that was written to +the corresponding `CommandWriter` or return `None` if no new data was written +since the last read. + +Keep in mind that if multiple values are written to a `CommandWriter`, any newer +values will overwrite older values that haven't been read yet. Therefore, this +is only suitable when the reader only cares about the most recent value that +has been written; i.e. new values supercede all older values. If you need a +realtime-safe FIFO queue of multiple values, consider using a ring buffer, such as +[`RingBuffer`](rtrb::RingBuffer) from the [rtrb](https://crates.io/crates/rtrb) crate, +which Kira uses internally. +*/ + +use triple_buffer::{Input, Output, triple_buffer}; + +use crate::{Tween, Value}; + +/** Writes values that can be sent to a [`CommandReader`]. */ +#[derive(Debug)] +pub struct CommandWriter(Input>); + +impl CommandWriter { + /** Writes a new value, overwriting any previous values. */ + pub fn write(&mut self, command: T) { + self.0.write(Some(command)) + } +} + +/** Reads values that were written to a [`CommandWriter`]. */ +#[derive(Debug)] +pub struct CommandReader(Output>); + +impl CommandReader { + /** + * Returns the latest value that was written to the [`CommandWriter`], + * or `None` if no new values were written since the last read. + */ + #[must_use] + pub fn read(&mut self) -> Option { + if self.0.update() { + *self.0.output_buffer_mut() + } else { + None + } + } +} + +/** Creates a command writer/reader pair. */ +#[must_use] +pub fn command_writer_and_reader() -> (CommandWriter, CommandReader) { + let (input, output) = triple_buffer(&None); + (CommandWriter(input), CommandReader(output)) +} + +/** + * A command that holds a target [`Value`] and a [`Tween`]. + * + * Setting something to a [`Value`] with a given [`Tween`] is a common + * pattern in Kira. + * + * `CommandReader`s can be passed to [`Parameter`](crate::Parameter)s + * to quickly set the parameter to a new value read from the [`CommandReader`]. + */ +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct ValueChangeCommand { + /// The new value to set something to. + pub target: Value, + /// The tween to use to smoothly transition the value. + pub tween: Tween, +} + +/** +Creates a set of command writers and readers and a constructor for them. +You'll only need to use this if you're making your own implementation of +[`Sound`](crate::sound::Sound), [`Effect`](crate::effect::Effect), +or [`Modulator`](crate::modulator::Modulator). + +# Example + +This macro call... + +```ignore +command_writers_and_readers! { + set_phase: f64, + set_frequency: ValueChangeCommand, +} +``` + +...will produce this code: + +```ignore +pub(crate) struct CommandWriters { + set_phase: kira::command::CommandWriter, + set_frequency: kira::command::CommandWriter>, +} + +pub(crate) struct CommandReaders { + set_phase: kira::command::CommandReader, + set_frequency: kira::command::CommandReader>, +} + +#[must_use] +pub(crate) fn command_writers_and_readers() -> (CommandWriters, CommandReaders) { + let (set_phase_writer, set_phase_reader) = kira::command::command_writer_and_reader(); + let (set_frequency_writer, set_frequency_reader) = kira::command::command_writer_and_reader(); + let command_writers = CommandWriters { + set_phase: set_phase_writer, + set_frequency: set_frequency_writer, + }; + let command_readers = CommandReaders { + set_phase: set_phase_reader, + set_frequency: set_frequency_reader, + }; + (command_writers, command_readers) +} +``` +*/ +#[macro_export] +macro_rules! command_writers_and_readers { + ($($field_name:ident: $type:ty),*$(,)?) => { + #[derive(Debug)] + pub(crate) struct CommandWriters { + $($field_name: $crate::command::CommandWriter<$type>),* + } + + #[derive(Debug)] + pub(crate) struct CommandReaders { + $($field_name: $crate::command::CommandReader<$type>),* + } + + #[must_use] + pub(crate) fn command_writers_and_readers() -> (CommandWriters, CommandReaders) { + $crate::pastey::paste! { + $(let ([<$field_name _writer>], [<$field_name _reader>]) = $crate::command::command_writer_and_reader();)* + let command_writers = CommandWriters { + $($field_name: [<$field_name _writer>]),* + }; + let command_readers = CommandReaders { + $($field_name: [<$field_name _reader>]),* + }; + (command_writers, command_readers) + } + } + }; +} + +macro_rules! read_commands_into_parameters { + ($self:ident, $($parameter_name:ident),*$(,)?) => { + $crate::pastey::paste! { + $($self.$parameter_name.read_command(&mut $self.command_readers.[]);)* + } + }; +} + +macro_rules! handle_param_setters { + ($($(#[$m:meta])* $name:ident: $type:ty),*$(,)?) => { + $crate::pastey::paste! { + $( + $(#[$m])* + pub fn [](&mut self, $name: impl Into<$crate::Value<$type>>, tween: $crate::tween::Tween) { + self.command_writers.[].write($crate::command::ValueChangeCommand { + target: $name.into(), + tween, + }) + } + )* + } + }; +} + +pub(crate) use handle_param_setters; +pub(crate) use read_commands_into_parameters; diff --git a/third_party/kira/src/decibels.rs b/third_party/kira/src/decibels.rs new file mode 100644 index 00000000..1420498f --- /dev/null +++ b/third_party/kira/src/decibels.rs @@ -0,0 +1,118 @@ +use std::ops::{Add, AddAssign, Sub, SubAssign}; + +use crate::{Value, tween::Tweenable}; + +#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(transparent))] +/// Represents a change in volume. +/// +/// Higher values increase the volume and lower values decrease it. +/// Setting the volume of a sound to -60dB or lower makes it silent. +pub struct Decibels(pub f32); + +impl Decibels { + /// The minimum decibel value at which a sound is considered + /// silent. + pub const SILENCE: Self = Self(-60.0); + /// The decibel value that produces no change in volume. + pub const IDENTITY: Self = Self(0.0); + + /// Converts decibels to amplitude, a linear volume measurement. + /// + /// This returns a number from `0.0`-`1.0` that you can multiply + /// a singal by to change its volume. + pub fn as_amplitude(self) -> f32 { + // adding a special case for db == 0.0 improves + // performance in the sound playback benchmarks + // by about 7% + if self == Self(0.0) { + return 1.0; + } + if self <= Self::SILENCE { + return 0.0; + } + 10.0f32.powf(self.0 / 20.0) + } +} + +impl Default for Decibels { + fn default() -> Self { + Self::IDENTITY + } +} + +impl Tweenable for Decibels { + fn interpolate(a: Self, b: Self, amount: f64) -> Self { + Self(Tweenable::interpolate(a.0, b.0, amount)) + } +} + +impl From for Decibels { + fn from(value: f32) -> Self { + Self(value) + } +} + +impl From for Value { + fn from(value: f32) -> Self { + Value::Fixed(Decibels(value)) + } +} + +impl From for Value { + fn from(value: Decibels) -> Self { + Value::Fixed(value) + } +} + +impl Add for Decibels { + type Output = Decibels; + + fn add(self, rhs: Decibels) -> Self::Output { + Self(self.0 + rhs.0) + } +} + +impl AddAssign for Decibels { + fn add_assign(&mut self, rhs: Decibels) { + self.0 += rhs.0; + } +} + +impl Sub for Decibels { + type Output = Decibels; + + fn sub(self, rhs: Decibels) -> Self::Output { + Self(self.0 - rhs.0) + } +} + +impl SubAssign for Decibels { + fn sub_assign(&mut self, rhs: Decibels) { + self.0 -= rhs.0; + } +} + +#[cfg(test)] +#[test] +#[allow(clippy::float_cmp)] +fn test() { + /// A table of dB values to the corresponding amplitudes. + // Data gathered from https://www.silisoftware.com/tools/db.php + const TEST_CALCULATIONS: [(Decibels, f32); 6] = [ + (Decibels::IDENTITY, 1.0), + (Decibels(3.0), 1.4125376), + (Decibels(12.0), 3.9810717), + (Decibels(-3.0), 0.70794576), + (Decibels(-12.0), 0.25118864), + (Decibels::SILENCE, 0.0), + ]; + + for (decibels, amplitude) in TEST_CALCULATIONS { + assert!((decibels.as_amplitude() - amplitude).abs() < 0.00001); + } + + // test some special cases + assert_eq!((Decibels::SILENCE - Decibels(100.0)).as_amplitude(), 0.0); +} diff --git a/third_party/kira/src/effect.rs b/third_party/kira/src/effect.rs new file mode 100644 index 00000000..8d492c03 --- /dev/null +++ b/third_party/kira/src/effect.rs @@ -0,0 +1,55 @@ +/*! +Modifies audio signals. + +Any type that implements [`EffectBuilder`] can be added to a mixer track by +using [`TrackBuilder::add_effect`](crate::track::TrackBuilder::add_effect). Kira +comes with a number of commonly used effects. + +If needed, you can create custom effects by implementing the [`EffectBuilder`] +and [`Effect`] traits. +*/ + +pub mod compressor; +pub mod delay; +pub mod distortion; +pub mod eq_filter; +pub mod filter; +pub mod panning_control; +pub mod reverb; +pub mod volume_control; + +use crate::{frame::Frame, info::Info}; + +/// Configures an effect. +pub trait EffectBuilder { + /// Allows the user to control the effect from gameplay code. + type Handle; + + /// Creates the effect and a handle to the effect. + #[must_use] + fn build(self) -> (Box, Self::Handle); +} + +/// Receives input audio from a mixer track and outputs modified audio. +/// +/// For performance reasons, avoid allocating and deallocating memory in any methods +/// of this trait besides [`on_change_sample_rate`](Effect::on_change_sample_rate). +#[allow(unused_variables)] +pub trait Effect: Send { + /// Called when the effect is first sent to the renderer. + fn init(&mut self, sample_rate: u32, internal_buffer_size: usize) {} + + /// Called when the sample rate of the renderer is changed. + fn on_change_sample_rate(&mut self, sample_rate: u32) {} + + /// Called whenever a new batch of audio samples is requested by the backend. + /// + /// This is a good place to put code that needs to run fairly frequently, + /// but not for every single audio sample. + fn on_start_processing(&mut self) {} + + /// Transforms a slice of input [`Frame`]s. + /// + /// `dt` is the time between each frame (in seconds). + fn process(&mut self, input: &mut [Frame], dt: f64, info: &Info); +} diff --git a/third_party/kira/src/effect/compressor.rs b/third_party/kira/src/effect/compressor.rs new file mode 100644 index 00000000..1faa0928 --- /dev/null +++ b/third_party/kira/src/effect/compressor.rs @@ -0,0 +1,129 @@ +//! Reduces (or increases) the dynamic range of audio. + +// Loosely based on https://www.musicdsp.org/en/latest/Effects/204-simple-compressor-class-c.html + +mod builder; +mod handle; + +pub use builder::*; +pub use handle::*; + +use std::time::Duration; + +use crate::{ + Decibels, Mix, Parameter, + command::{ValueChangeCommand, read_commands_into_parameters}, + command_writers_and_readers, + frame::Frame, + info::Info, +}; + +use super::Effect; + +struct Compressor { + command_readers: CommandReaders, + threshold: Parameter, + ratio: Parameter, + attack_duration: Parameter, + release_duration: Parameter, + makeup_gain: Parameter, + mix: Parameter, + envelope_follower: [f32; 2], +} + +impl Compressor { + #[must_use] + fn new(builder: CompressorBuilder, command_readers: CommandReaders) -> Self { + Self { + command_readers, + threshold: Parameter::new(builder.threshold, CompressorBuilder::DEFAULT_THRESHOLD), + ratio: Parameter::new(builder.ratio, CompressorBuilder::DEFAULT_RATIO), + attack_duration: Parameter::new( + builder.attack_duration, + CompressorBuilder::DEFAULT_ATTACK_DURATION, + ), + release_duration: Parameter::new( + builder.release_duration, + CompressorBuilder::DEFAULT_RELEASE_DURATION, + ), + makeup_gain: Parameter::new( + builder.makeup_gain, + CompressorBuilder::DEFAULT_MAKEUP_GAIN, + ), + mix: Parameter::new(builder.mix, CompressorBuilder::DEFAULT_MIX), + envelope_follower: [0.0; 2], + } + } +} + +impl Effect for Compressor { + fn on_start_processing(&mut self) { + read_commands_into_parameters!( + self, + threshold, + ratio, + attack_duration, + release_duration, + makeup_gain, + mix, + ); + } + + fn process(&mut self, input: &mut [Frame], dt: f64, info: &Info) { + self.threshold.update(dt * input.len() as f64, info); + self.ratio.update(dt * input.len() as f64, info); + self.attack_duration.update(dt * input.len() as f64, info); + self.release_duration.update(dt * input.len() as f64, info); + self.makeup_gain.update(dt * input.len() as f64, info); + self.mix.update(dt * input.len() as f64, info); + + let threshold = self.threshold.value() as f32; + let ratio = self.ratio.value() as f32; + let attack_duration = self.attack_duration.value(); + let release_duration = self.release_duration.value(); + + let num_frames = input.len(); + for (i, frame) in input.iter_mut().enumerate() { + let time_in_chunk = (i + 1) as f64 / num_frames as f64; + let makeup_gain = self.makeup_gain.interpolated_value(time_in_chunk); + let mix = self.mix.interpolated_value(time_in_chunk).0.clamp(0.0, 1.0); + + let input_decibels = [ + 20.0 * frame.left.abs().log10(), + 20.0 * frame.right.abs().log10(), + ]; + let over_decibels = input_decibels.map(|input| (input - threshold).max(0.0)); + for (i, envelope_follower) in self.envelope_follower.iter_mut().enumerate() { + let duration = if *envelope_follower > over_decibels[i] { + release_duration + } else { + attack_duration + }; + let speed = (-1.0 / (duration.as_secs_f64() / dt)).exp(); + *envelope_follower = + over_decibels[i] + speed as f32 * (*envelope_follower - over_decibels[i]); + } + let gain_reduction = self + .envelope_follower + .map(|envelope_follower| envelope_follower * ((1.0 / ratio) - 1.0)); + let amplitude = + gain_reduction.map(|gain_reduction| 10.0f32.powf(gain_reduction / 20.0)); + let makeup_gain_linear = 10.0f32.powf(makeup_gain.0 / 20.0); + let output = Frame { + left: amplitude[0] * frame.left, + right: amplitude[1] * frame.right, + } * makeup_gain_linear; + + *frame = output * mix.sqrt() + *frame * (1.0 - mix).sqrt() + } + } +} + +command_writers_and_readers! { + set_threshold: ValueChangeCommand, + set_ratio: ValueChangeCommand, + set_attack_duration: ValueChangeCommand, + set_release_duration: ValueChangeCommand, + set_makeup_gain: ValueChangeCommand, + set_mix: ValueChangeCommand, +} diff --git a/third_party/kira/src/effect/compressor/builder.rs b/third_party/kira/src/effect/compressor/builder.rs new file mode 100644 index 00000000..7bef94ba --- /dev/null +++ b/third_party/kira/src/effect/compressor/builder.rs @@ -0,0 +1,140 @@ +use std::time::Duration; + +use crate::{ + Decibels, Mix, Value, + effect::{Effect, EffectBuilder}, +}; + +use super::{Compressor, CompressorHandle, command_writers_and_readers}; + +/// Configures a compressor. +pub struct CompressorBuilder { + /// The volume above which volume will start to be decreased (in decibels). + pub threshold: Value, + /// How much the signal will be compressed. + /// + /// A ratio of `2.0` (or 2 to 1) means an increase of 3dB will + /// become an increase of 1.5dB. Ratios between `0.0` and `1.0` + /// will actually expand the audio. + pub ratio: Value, + /// How much time it takes for the volume attenuation to ramp up once + /// the input volume exceeds the threshold. + pub attack_duration: Value, + /// How much time it takes for the volume attenuation to relax once + /// the input volume dips below the threshold. + pub release_duration: Value, + /// The amount to change the volume after processing (in dB). + /// + /// This can be used to compensate for the decrease in volume resulting + /// from compression. This is only applied to the wet signal, nto the + /// dry signal. + pub makeup_gain: Value, + /// How much dry (unprocessed) signal should be blended + /// with the wet (processed) signal. + pub mix: Value, +} + +impl CompressorBuilder { + pub(crate) const DEFAULT_THRESHOLD: f64 = 0.0; + pub(crate) const DEFAULT_RATIO: f64 = 1.0; + pub(crate) const DEFAULT_ATTACK_DURATION: Duration = Duration::from_millis(10); + pub(crate) const DEFAULT_RELEASE_DURATION: Duration = Duration::from_millis(100); + pub(crate) const DEFAULT_MAKEUP_GAIN: Decibels = Decibels(0.0); + pub(crate) const DEFAULT_MIX: Mix = Mix::WET; + + /// Creates a new [`CompressorBuilder`] with the default settings. + #[must_use] + pub fn new() -> Self { + Self { + threshold: Value::Fixed(Self::DEFAULT_THRESHOLD), + ratio: Value::Fixed(Self::DEFAULT_RATIO), + attack_duration: Value::Fixed(Self::DEFAULT_ATTACK_DURATION), + release_duration: Value::Fixed(Self::DEFAULT_RELEASE_DURATION), + makeup_gain: Value::Fixed(Self::DEFAULT_MAKEUP_GAIN), + mix: Value::Fixed(Self::DEFAULT_MIX), + } + } + + /// Sets the volume above which volume will start to be decreased (in decibels). + #[must_use = "This method consumes self and returns a modified CompressorBuilder, so the return value should be used"] + pub fn threshold(self, threshold: impl Into>) -> Self { + Self { + threshold: threshold.into(), + ..self + } + } + + /// Sets how much the signal will be compressed. + /// + /// A ratio of `2.0` (or 2 to 1) means an increase of 3dB will + /// become an increase of 1.5dB. Ratios between `0.0` and `1.0` + /// will actually expand the audio. + #[must_use = "This method consumes self and returns a modified CompressorBuilder, so the return value should be used"] + pub fn ratio(self, ratio: impl Into>) -> Self { + Self { + ratio: ratio.into(), + ..self + } + } + + /// Sets how much time it takes for the volume attenuation to ramp up once + /// the input volume exceeds the threshold. + #[must_use = "This method consumes self and returns a modified CompressorBuilder, so the return value should be used"] + pub fn attack_duration(self, attack_duration: impl Into>) -> Self { + Self { + attack_duration: attack_duration.into(), + ..self + } + } + + /// Sets how much time it takes for the volume attenuation to relax once + /// the input volume dips below the threshold. + #[must_use = "This method consumes self and returns a modified CompressorBuilder, so the return value should be used"] + pub fn release_duration(self, release_duration: impl Into>) -> Self { + Self { + release_duration: release_duration.into(), + ..self + } + } + + /// Sets the amount to change the volume after processing (in decibels). + /// + /// This can be used to compensate for the decrease in volume resulting + /// from compression. This is only applied to the wet signal, nto the + /// dry signal. + #[must_use = "This method consumes self and returns a modified CompressorBuilder, so the return value should be used"] + pub fn makeup_gain(self, makeup_gain: impl Into>) -> Self { + Self { + makeup_gain: makeup_gain.into(), + ..self + } + } + + /// Sets how much dry (unprocessed) signal should be blended + /// with the wet (processed) signal. + #[must_use = "This method consumes self and returns a modified CompressorBuilder, so the return value should be used"] + pub fn mix(self, mix: impl Into>) -> Self { + Self { + mix: mix.into(), + ..self + } + } +} + +impl Default for CompressorBuilder { + fn default() -> Self { + Self::new() + } +} + +impl EffectBuilder for CompressorBuilder { + type Handle = CompressorHandle; + + fn build(self) -> (Box, Self::Handle) { + let (command_writers, command_readers) = command_writers_and_readers(); + ( + Box::new(Compressor::new(self, command_readers)), + CompressorHandle { command_writers }, + ) + } +} diff --git a/third_party/kira/src/effect/compressor/handle.rs b/third_party/kira/src/effect/compressor/handle.rs new file mode 100644 index 00000000..ca400bab --- /dev/null +++ b/third_party/kira/src/effect/compressor/handle.rs @@ -0,0 +1,44 @@ +use std::time::Duration; + +use crate::{Decibels, Mix, command::handle_param_setters}; + +use super::CommandWriters; + +/// Controls a compressor. +#[derive(Debug)] +pub struct CompressorHandle { + pub(super) command_writers: CommandWriters, +} + +impl CompressorHandle { + handle_param_setters! { + /// Sets the volume above which volume will start to be decreased (in decibels). + threshold: f64, + + /// Sets how much the signal will be compressed. + /// + /// A ratio of `2.0` (or 2 to 1) means an increase of 3dB will + /// become an increase of 1.5dB. Ratios between `0.0` and `1.0` + /// will actually expand the audio. + ratio: f64, + + /// Sets how much time it takes for the volume attenuation to ramp up once + /// the input volume exceeds the threshold. + attack_duration: Duration, + + /// Sets how much time it takes for the volume attenuation to relax once + /// the input volume dips below the threshold. + release_duration: Duration, + + /// Sets the amount to change the volume after processing (in dB). + /// + /// This can be used to compensate for the decrease in volume resulting + /// from compression. This is only applied to the wet signal, nto the + /// dry signal. + makeup_gain: Decibels, + + /// Sets how much dry (unprocessed) signal should be blended + /// with the wet (processed) signal. + mix: Mix, + } +} diff --git a/third_party/kira/src/effect/delay.rs b/third_party/kira/src/effect/delay.rs new file mode 100644 index 00000000..b2f3d5e1 --- /dev/null +++ b/third_party/kira/src/effect/delay.rs @@ -0,0 +1,114 @@ +//! Adds echoes to a sound. + +mod builder; +mod handle; + +pub use builder::*; +pub use handle::*; + +use std::time::Duration; + +use crate::{ + Decibels, Mix, Parameter, + command::{ValueChangeCommand, read_commands_into_parameters}, + command_writers_and_readers, + frame::Frame, + info::Info, +}; + +use super::Effect; + +struct Delay { + command_readers: CommandReaders, + delay_time: Duration, + feedback: Parameter, + mix: Parameter, + buffer: Vec, + feedback_effects: Vec>, + temp_buffer: Vec, +} + +impl Delay { + /// Creates a new delay effect. + #[must_use] + fn new(builder: DelayBuilder, command_readers: CommandReaders) -> Self { + Self { + command_readers, + delay_time: builder.delay_time, + feedback: Parameter::new(builder.feedback, Decibels(-6.0)), + mix: Parameter::new(builder.mix, Mix(0.5)), + buffer: Vec::with_capacity(0), + feedback_effects: builder.feedback_effects, + temp_buffer: vec![], + } + } +} + +impl Effect for Delay { + fn init(&mut self, sample_rate: u32, internal_buffer_size: usize) { + let delay_time_frames = (self.delay_time.as_secs_f64() * sample_rate as f64) as usize; + self.buffer = vec![Frame::ZERO; delay_time_frames]; + self.temp_buffer = vec![Frame::ZERO; internal_buffer_size]; + for effect in &mut self.feedback_effects { + effect.init(sample_rate, internal_buffer_size); + } + } + + fn on_change_sample_rate(&mut self, sample_rate: u32) { + let delay_time_frames = (self.delay_time.as_secs_f64() * sample_rate as f64) as usize; + self.buffer = vec![Frame::ZERO; delay_time_frames]; + for effect in &mut self.feedback_effects { + effect.on_change_sample_rate(sample_rate); + } + } + + fn on_start_processing(&mut self) { + read_commands_into_parameters!(self, feedback, mix); + for effect in &mut self.feedback_effects { + effect.on_start_processing(); + } + } + + fn process(&mut self, input: &mut [Frame], dt: f64, info: &Info) { + self.feedback.update(dt * input.len() as f64, info); + self.mix.update(dt * input.len() as f64, info); + + for input in input.chunks_mut(self.buffer.len()) { + let num_frames = input.len(); + + // read from the beginning of the buffer and apply effects and feedback gain + self.temp_buffer[..input.len()].copy_from_slice(&self.buffer[..input.len()]); + for effect in &mut self.feedback_effects { + effect.process(&mut self.temp_buffer[..input.len()], dt, info); + } + for (i, frame) in self.temp_buffer[..input.len()].iter_mut().enumerate() { + let time_in_chunk = (i + 1) as f64 / num_frames as f64; + let feedback = self.feedback.interpolated_value(time_in_chunk); + *frame *= feedback.as_amplitude(); + } + + // write input + read buffer to the end of the buffer + self.buffer.copy_within(input.len().., 0); + let write_range = self.buffer.len() - input.len()..; + for ((out, input), read) in self.buffer[write_range] + .iter_mut() + .zip(input.iter()) + .zip(&mut self.temp_buffer[..input.len()]) + { + *out = *input + *read; + } + + // output mix of input and read buffer + for (i, frame) in input.iter_mut().enumerate() { + let time_in_chunk = (i + 1) as f64 / num_frames as f64; + let mix = self.mix.interpolated_value(time_in_chunk).0.clamp(0.0, 1.0); + *frame = self.temp_buffer[i] * mix.sqrt() + *frame * (1.0 - mix).sqrt() + } + } + } +} + +command_writers_and_readers! { + set_feedback: ValueChangeCommand, + set_mix: ValueChangeCommand, +} diff --git a/third_party/kira/src/effect/delay/builder.rs b/third_party/kira/src/effect/delay/builder.rs new file mode 100644 index 00000000..92a515c5 --- /dev/null +++ b/third_party/kira/src/effect/delay/builder.rs @@ -0,0 +1,94 @@ +use std::time::Duration; + +use crate::{ + Decibels, Mix, Value, + effect::{Effect, EffectBuilder}, +}; + +use super::{Delay, DelayHandle, command_writers_and_readers}; + +/// Configures a delay effect. +pub struct DelayBuilder { + /// The amount of time the input audio is delayed by. + pub(super) delay_time: Duration, + /// The amount of feedback. + pub(super) feedback: Value, + /// Effects that should be applied in the feedback loop. + pub(super) feedback_effects: Vec>, + /// How much dry (unprocessed) signal should be blended + /// with the wet (processed) signal. + pub(super) mix: Value, +} + +impl DelayBuilder { + /// Creates a new [`DelayBuilder`] with the default settings. + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Sets the amount of time the input audio is delayed by. + #[must_use = "This method consumes self and returns a modified DelayBuilder, so the return value should be used"] + pub fn delay_time(self, delay_time: Duration) -> Self { + Self { delay_time, ..self } + } + + /// Sets the amount of feedback. + #[must_use = "This method consumes self and returns a modified DelayBuilder, so the return value should be used"] + pub fn feedback(self, feedback: impl Into>) -> Self { + Self { + feedback: feedback.into(), + ..self + } + } + + /// Adds an effect to the feedback loop. + pub fn add_feedback_effect(&mut self, builder: B) -> B::Handle { + let (effect, handle) = builder.build(); + self.feedback_effects.push(effect); + handle + } + + /// Adds an effect to the feedback loop and returns the [`DelayBuilder`]. + /// + /// If you need a handle to the newly added effect, use [`DelayBuilder::add_feedback_effect`]. + pub fn with_feedback_effect(mut self, builder: B) -> Self { + self.add_feedback_effect(builder); + self + } + + /// Sets how much dry (unprocessed) signal should be blended + /// with the wet (processed) signal. `0.0` means only the dry + /// signal will be heard. `1.0` means only the wet signal will + /// be heard. + #[must_use = "This method consumes self and returns a modified DelayBuilder, so the return value should be used"] + pub fn mix(self, mix: impl Into>) -> Self { + Self { + mix: mix.into(), + ..self + } + } +} + +impl Default for DelayBuilder { + fn default() -> Self { + Self { + delay_time: Duration::from_millis(500), + feedback: Value::Fixed(Decibels(-6.0)), + feedback_effects: vec![], + mix: Value::Fixed(Mix(0.5)), + } + } +} + +impl EffectBuilder for DelayBuilder { + type Handle = DelayHandle; + + fn build(self) -> (Box, Self::Handle) { + let (command_writers, command_readers) = command_writers_and_readers(); + ( + Box::new(Delay::new(self, command_readers)), + DelayHandle { command_writers }, + ) + } +} diff --git a/third_party/kira/src/effect/delay/handle.rs b/third_party/kira/src/effect/delay/handle.rs new file mode 100644 index 00000000..ba2fc170 --- /dev/null +++ b/third_party/kira/src/effect/delay/handle.rs @@ -0,0 +1,20 @@ +use crate::{Decibels, Mix, command::handle_param_setters}; + +use super::CommandWriters; + +/// Controls a delay effect. +#[derive(Debug)] +pub struct DelayHandle { + pub(super) command_writers: CommandWriters, +} + +impl DelayHandle { + handle_param_setters! { + /// Sets the amount of feedback. + feedback: Decibels, + + /// Sets how much dry (unprocessed) signal should be blended + /// with the wet (processed) signal. + mix: Mix, + } +} diff --git a/third_party/kira/src/effect/distortion.rs b/third_party/kira/src/effect/distortion.rs new file mode 100644 index 00000000..f273e0c7 --- /dev/null +++ b/third_party/kira/src/effect/distortion.rs @@ -0,0 +1,88 @@ +//! Makes a sound harsher and noisier. + +mod builder; +mod handle; + +pub use builder::*; +pub use handle::*; + +use crate::{ + Decibels, Mix, Parameter, + command::{ValueChangeCommand, read_commands_into_parameters}, + command_writers_and_readers, + effect::Effect, + frame::Frame, + info::Info, +}; + +/// Different types of distortion. +#[derive(Debug, Copy, Clone, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub enum DistortionKind { + /// The signal will be clamped to the -1.0 to 1.0 range. + /// + /// This creates a harsh distortion when the signal leaves + /// the -1.0 to 1.0 range. + HardClip, + /// The signal will be kept in the -1.0 to 1.0 range, + /// and the slope will gradually decrease as it reaches + /// -1.0 or 1.0. + /// + /// This creates a smoother distortion that gradually + /// becomes more prominent as the signal becomes louder. + SoftClip, +} + +impl Default for DistortionKind { + fn default() -> Self { + Self::HardClip + } +} + +struct Distortion { + command_readers: CommandReaders, + kind: DistortionKind, + drive: Parameter, + mix: Parameter, +} + +impl Effect for Distortion { + fn on_start_processing(&mut self) { + if let Some(kind) = self.command_readers.set_kind.read() { + self.kind = kind; + } + read_commands_into_parameters!(self, drive, mix); + } + + fn process(&mut self, input: &mut [Frame], dt: f64, info: &Info) { + self.drive.update(dt * input.len() as f64, info); + self.mix.update(dt * input.len() as f64, info); + + let num_frames = input.len(); + for (i, frame) in input.iter_mut().enumerate() { + let time_in_chunk = (i + 1) as f64 / num_frames as f64; + let drive = self.drive.interpolated_value(time_in_chunk).as_amplitude(); + let mix = self.mix.interpolated_value(time_in_chunk).0.clamp(0.0, 1.0); + + let mut output = *frame * drive; + output = match self.kind { + DistortionKind::HardClip => { + Frame::new(output.left.clamp(-1.0, 1.0), output.right.clamp(-1.0, 1.0)) + } + DistortionKind::SoftClip => Frame::new( + output.left / (1.0 + output.left.abs()), + output.right / (1.0 + output.right.abs()), + ), + }; + output /= drive; + + *frame = output * mix.sqrt() + *frame * (1.0 - mix).sqrt() + } + } +} + +command_writers_and_readers! { + set_kind: DistortionKind, + set_drive: ValueChangeCommand, + set_mix: ValueChangeCommand, +} diff --git a/third_party/kira/src/effect/distortion/builder.rs b/third_party/kira/src/effect/distortion/builder.rs new file mode 100644 index 00000000..ecafd1a8 --- /dev/null +++ b/third_party/kira/src/effect/distortion/builder.rs @@ -0,0 +1,82 @@ +use crate::{ + Decibels, Mix, Parameter, Value, + effect::{Effect, EffectBuilder}, +}; + +use super::{Distortion, DistortionKind, command_writers_and_readers, handle::DistortionHandle}; + +/// Configures a distortion effect. +#[derive(Debug, Copy, Clone, PartialEq)] +pub struct DistortionBuilder { + /// The kind of distortion to use. + pub kind: DistortionKind, + /// The factor to multiply the signal by before applying + /// the distortion. + pub drive: Value, + /// How much dry (unprocessed) signal should be blended + /// with the wet (processed) signal. + pub mix: Value, +} + +impl DistortionBuilder { + /// Creates a new [`DistortionBuilder`] with the default settings. + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Sets the kind of distortion to use. + #[must_use = "This method consumes self and returns a modified DistortionBuilder, so the return value should be used"] + pub fn kind(self, kind: DistortionKind) -> Self { + Self { kind, ..self } + } + + /// Sets the factor to multiply the signal by before applying + /// the distortion. + #[must_use = "This method consumes self and returns a modified DistortionBuilder, so the return value should be used"] + pub fn drive(self, drive: impl Into>) -> Self { + Self { + drive: drive.into(), + ..self + } + } + + /// Sets how much dry (unprocessed) signal should be blended + /// with the wet (processed) signal. `0.0` means only the dry + /// signal will be heard. `1.0` means only the wet signal will + /// be heard. + #[must_use = "This method consumes self and returns a modified DistortionBuilder, so the return value should be used"] + pub fn mix(self, mix: impl Into>) -> Self { + Self { + mix: mix.into(), + ..self + } + } +} + +impl Default for DistortionBuilder { + fn default() -> Self { + Self { + kind: Default::default(), + drive: Value::Fixed(Decibels::IDENTITY), + mix: Value::Fixed(Mix::WET), + } + } +} + +impl EffectBuilder for DistortionBuilder { + type Handle = DistortionHandle; + + fn build(self) -> (Box, Self::Handle) { + let (command_writers, command_readers) = command_writers_and_readers(); + ( + Box::new(Distortion { + command_readers, + kind: self.kind, + drive: Parameter::new(self.drive, Decibels::IDENTITY), + mix: Parameter::new(self.mix, Mix::WET), + }), + DistortionHandle { command_writers }, + ) + } +} diff --git a/third_party/kira/src/effect/distortion/handle.rs b/third_party/kira/src/effect/distortion/handle.rs new file mode 100644 index 00000000..9da34128 --- /dev/null +++ b/third_party/kira/src/effect/distortion/handle.rs @@ -0,0 +1,25 @@ +use crate::{Decibels, Mix, command::handle_param_setters}; + +use super::{CommandWriters, DistortionKind}; + +/// Controls a distortion effect. +#[derive(Debug)] +pub struct DistortionHandle { + pub(super) command_writers: CommandWriters, +} + +impl DistortionHandle { + /// Sets the kind of distortion to use. + pub fn set_kind(&mut self, kind: DistortionKind) { + self.command_writers.set_kind.write(kind) + } + + handle_param_setters! { + /// Sets how much distortion should be applied. + drive: Decibels, + + /// Sets how much dry (unprocessed) signal should be blended + /// with the wet (processed) signal. + mix: Mix, + } +} diff --git a/third_party/kira/src/effect/eq_filter.rs b/third_party/kira/src/effect/eq_filter.rs new file mode 100644 index 00000000..aebdd164 --- /dev/null +++ b/third_party/kira/src/effect/eq_filter.rs @@ -0,0 +1,183 @@ +//! Adjusts the volume of a frequency range of a sound. + +// Code is based on https://cytomic.com/files/dsp/SvfLinearTrapOptimised2.pdf + +mod builder; +mod handle; + +pub use builder::*; +pub use handle::*; + +use std::f64::consts::PI; + +use crate::{ + Decibels, Parameter, + command::{ValueChangeCommand, read_commands_into_parameters}, + command_writers_and_readers, + frame::Frame, + info::Info, +}; + +use super::Effect; + +const MIN_Q: f64 = 0.01; + +struct EqFilter { + command_readers: CommandReaders, + kind: EqFilterKind, + frequency: Parameter, + gain: Parameter, + q: Parameter, + ic1eq: Frame, + ic2eq: Frame, +} + +impl EqFilter { + #[must_use] + fn new(builder: EqFilterBuilder, command_readers: CommandReaders) -> Self { + Self { + command_readers, + kind: builder.kind, + frequency: Parameter::new(builder.frequency, 500.0), + gain: Parameter::new(builder.gain, Decibels::IDENTITY), + q: Parameter::new(builder.q, 1.0), + ic1eq: Frame::ZERO, + ic2eq: Frame::ZERO, + } + } +} + +impl Effect for EqFilter { + fn on_start_processing(&mut self) { + if let Some(kind) = self.command_readers.set_kind.read() { + self.kind = kind; + } + read_commands_into_parameters!(self, frequency, gain, q); + } + + fn process(&mut self, input: &mut [Frame], dt: f64, info: &Info) { + self.frequency.update(dt * input.len() as f64, info); + self.gain.update(dt * input.len() as f64, info); + self.q.update(dt * input.len() as f64, info); + + let num_frames = input.len(); + for (i, frame) in input.iter_mut().enumerate() { + let time_in_chunk = (i + 1) as f64 / num_frames as f64; + let frequency = self.frequency.interpolated_value(time_in_chunk); + let q = self.q.interpolated_value(time_in_chunk); + let gain = self.gain.interpolated_value(time_in_chunk); + + let Coefficients { + a1, + a2, + a3, + m0, + m1, + m2, + } = Coefficients::calculate(self.kind, frequency, q, gain, dt); + let v3 = *frame - self.ic2eq; + let v1 = self.ic1eq * (a1 as f32) + v3 * (a2 as f32); + let v2 = self.ic2eq + self.ic1eq * (a2 as f32) + v3 * (a3 as f32); + self.ic1eq = v1 * 2.0 - self.ic1eq; + self.ic2eq = v2 * 2.0 - self.ic2eq; + *frame = *frame * (m0 as f32) + v1 * (m1 as f32) + v2 * (m2 as f32) + } + } +} + +/// The shape of the frequency adjustment curve. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub enum EqFilterKind { + /// Frequencies around the user-defined frequency are adjusted. + Bell, + /// Frequencies around and lower than the user-defined frequency are adjusted. + LowShelf, + /// Frequencies around and higher than the user-defined frequency are adjusted. + HighShelf, +} + +struct Coefficients { + a1: f64, + a2: f64, + a3: f64, + m0: f64, + m1: f64, + m2: f64, +} + +impl Coefficients { + #[must_use] + fn calculate(kind: EqFilterKind, frequency: f64, q: f64, gain: Decibels, dt: f64) -> Self { + // In my testing, the filter goes unstable when the frequency exceeds half the sample rate, + // so I'm clamping this value to 0.5 + let relative_frequency = (frequency * dt).clamp(0.0001, 0.5); + let q = q.max(MIN_Q); + match kind { + EqFilterKind::Bell => { + let a = 10.0f64.powf(gain.0 as f64 / 40.0); + let g = (PI * relative_frequency).tan(); + let k = 1.0 / (q * a); + let a1 = 1.0 / (1.0 + g * (g + k)); + let a2 = g * a1; + let a3 = g * a2; + let m0 = 1.0; + let m1 = k * (a * a - 1.0); + let m2 = 0.0; + Self { + a1, + a2, + a3, + m0, + m1, + m2, + } + } + EqFilterKind::LowShelf => { + let a = 10.0f64.powf(gain.0 as f64 / 40.0); + let g = (PI * relative_frequency).tan() / a.sqrt(); + let k = 1.0 / q; + let a1 = 1.0 / (1.0 + g * (g + k)); + let a2 = g * a1; + let a3 = g * a2; + let m0 = 1.0; + let m1 = k * (a - 1.0); + let m2 = a * a - 1.0; + Self { + a1, + a2, + a3, + m0, + m1, + m2, + } + } + EqFilterKind::HighShelf => { + let a = 10.0f64.powf(gain.0 as f64 / 40.0); + let g = (PI * relative_frequency).tan() * a.sqrt(); + let k = 1.0 / q; + let a1 = 1.0 / (1.0 + g * (g + k)); + let a2 = g * a1; + let a3 = g * a2; + let m0 = a * a; + let m1 = k * (1.0 - a) * a; + let m2 = 1.0 - a * a; + Self { + a1, + a2, + a3, + m0, + m1, + m2, + } + } + } + } +} + +command_writers_and_readers! { + set_kind: EqFilterKind, + set_frequency: ValueChangeCommand, + set_gain: ValueChangeCommand, + set_q: ValueChangeCommand, +} diff --git a/third_party/kira/src/effect/eq_filter/builder.rs b/third_party/kira/src/effect/eq_filter/builder.rs new file mode 100644 index 00000000..2ebfac57 --- /dev/null +++ b/third_party/kira/src/effect/eq_filter/builder.rs @@ -0,0 +1,49 @@ +use crate::{Decibels, Value, effect::EffectBuilder}; + +use super::{EqFilter, EqFilterHandle, EqFilterKind, command_writers_and_readers}; + +/// Configures an EQ filter. +pub struct EqFilterBuilder { + /// The shape of the frequency adjustment curve. + pub kind: EqFilterKind, + /// The "center" or "corner" of the frequency range to adjust in Hz + /// (for bell or shelf curves, respectively). + pub frequency: Value, + /// The volume adjustment for frequencies in the specified range (in decibels). + pub gain: Value, + /// The width of the frequency range to adjust. + /// + /// A higher Q value results in a narrower range of frequencies being adjusted. + /// The value should be greater than `0.0`. + pub q: Value, +} + +impl EqFilterBuilder { + /// Creates a new `EqFilterBuilder`. + #[must_use] + pub fn new( + kind: EqFilterKind, + frequency: impl Into>, + gain: impl Into>, + q: impl Into>, + ) -> Self { + Self { + kind, + frequency: frequency.into(), + gain: gain.into(), + q: q.into(), + } + } +} + +impl EffectBuilder for EqFilterBuilder { + type Handle = EqFilterHandle; + + fn build(self) -> (Box, Self::Handle) { + let (command_writers, command_readers) = command_writers_and_readers(); + ( + Box::new(EqFilter::new(self, command_readers)), + EqFilterHandle { command_writers }, + ) + } +} diff --git a/third_party/kira/src/effect/eq_filter/handle.rs b/third_party/kira/src/effect/eq_filter/handle.rs new file mode 100644 index 00000000..adea0452 --- /dev/null +++ b/third_party/kira/src/effect/eq_filter/handle.rs @@ -0,0 +1,31 @@ +use crate::{Decibels, command::handle_param_setters}; + +use super::{CommandWriters, EqFilterKind}; + +/// Controls an EQ filter. +#[derive(Debug)] +pub struct EqFilterHandle { + pub(super) command_writers: CommandWriters, +} + +impl EqFilterHandle { + /// Sets the shape of the frequency adjustment curve. + pub fn set_kind(&mut self, kind: EqFilterKind) { + self.command_writers.set_kind.write(kind) + } + + handle_param_setters! { + /// Sets the "center" or "corner" of the frequency range to adjust in Hz + /// (for bell or shelf curves, respectively). + frequency: f64, + + /// Sets the volume adjustment for frequencies in the specified range (in decibels). + gain: Decibels, + + /// Sets the width of the frequency range to adjust. + /// + /// A higher Q value results in a narrower range of frequencies being adjusted. + /// The value should be greater than `0.0`. + q: f64, + } +} diff --git a/third_party/kira/src/effect/filter.rs b/third_party/kira/src/effect/filter.rs new file mode 100644 index 00000000..bee0c67a --- /dev/null +++ b/third_party/kira/src/effect/filter.rs @@ -0,0 +1,113 @@ +//! Removes frequencies from a sound. + +mod builder; +mod handle; + +pub use builder::*; +pub use handle::*; + +use std::f64::consts::PI; + +use crate::{ + Mix, Parameter, + command::{ValueChangeCommand, read_commands_into_parameters}, + command_writers_and_readers, + effect::Effect, + frame::Frame, + info::Info, +}; + +// This filter code is based on the filter code from baseplug: +// https://github.com/wrl/baseplug/blob/trunk/examples/svf/svf_simper.rs + +/// The frequencies that the filter will remove. +#[derive(Debug, Copy, Clone, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub enum FilterMode { + /// Removes frequencies above the cutoff frequency. + LowPass, + /// Removes frequencies above and below the cutoff frequency. + BandPass, + /// Removes frequencies below the cutoff frequency. + HighPass, + /// Removes frequencies around the cutoff frequency. + Notch, +} + +struct Filter { + command_readers: CommandReaders, + mode: FilterMode, + cutoff: Parameter, + resonance: Parameter, + mix: Parameter, + ic1eq: Frame, + ic2eq: Frame, +} + +impl Filter { + /// Creates a new filter. + #[must_use] + fn new(builder: FilterBuilder, command_readers: CommandReaders) -> Self { + Self { + command_readers, + mode: builder.mode, + cutoff: Parameter::new(builder.cutoff, 1000.0), + resonance: Parameter::new(builder.resonance, 0.0), + mix: Parameter::new(builder.mix, Mix(1.0)), + ic1eq: Frame::ZERO, + ic2eq: Frame::ZERO, + } + } +} + +impl Effect for Filter { + fn on_start_processing(&mut self) { + if let Some(mode) = self.command_readers.set_mode.read() { + self.mode = mode; + } + read_commands_into_parameters!(self, cutoff, resonance, mix); + } + + fn process(&mut self, input: &mut [Frame], dt: f64, info: &Info) { + self.cutoff.update(dt * input.len() as f64, info); + self.resonance.update(dt * input.len() as f64, info); + self.mix.update(dt * input.len() as f64, info); + + let num_frames = input.len(); + for (i, frame) in input.iter_mut().enumerate() { + let time_in_chunk = (i + 1) as f64 / num_frames as f64; + let cutoff = self.cutoff.interpolated_value(time_in_chunk); + let resonance = self + .resonance + .interpolated_value(time_in_chunk) + .clamp(0.0, 1.0); + let mix = self.mix.interpolated_value(time_in_chunk).0.clamp(0.0, 1.0); + + let sample_rate = 1.0 / dt; + let g = (PI * (cutoff / sample_rate).clamp(0.0001, 0.5)).tan(); + let k = 2.0 - (1.9 * resonance); + let a1 = 1.0 / (1.0 + (g * (g + k))); + let a2 = g * a1; + let a3 = g * a2; + let v3 = *frame - self.ic2eq; + let v1 = (self.ic1eq * (a1 as f32)) + (v3 * (a2 as f32)); + let v2 = self.ic2eq + (self.ic1eq * (a2 as f32)) + (v3 * (a3 as f32)); + self.ic1eq = (v1 * 2.0) - self.ic1eq; + self.ic2eq = (v2 * 2.0) - self.ic2eq; + let output = match self.mode { + FilterMode::LowPass => v2, + FilterMode::BandPass => v1, + FilterMode::HighPass => *frame - v1 * (k as f32) - v2, + FilterMode::Notch => *frame - v1 * (k as f32), + }; + *frame = output * mix.sqrt() + *frame * (1.0 - mix).sqrt() + } + } +} + +command_writers_and_readers!( + set_mode: FilterMode, + set_cutoff: ValueChangeCommand, + set_resonance: ValueChangeCommand, + set_mix: ValueChangeCommand, +); diff --git a/third_party/kira/src/effect/filter/builder.rs b/third_party/kira/src/effect/filter/builder.rs new file mode 100644 index 00000000..b40866a2 --- /dev/null +++ b/third_party/kira/src/effect/filter/builder.rs @@ -0,0 +1,90 @@ +use crate::{ + Mix, Value, + effect::{Effect, EffectBuilder}, +}; + +use super::{Filter, FilterHandle, FilterMode, command_writers_and_readers}; + +/// Configures a filter effect. +#[derive(Debug, Copy, Clone, PartialEq)] +pub struct FilterBuilder { + /// The frequencies that the filter will remove. + pub mode: FilterMode, + /// The cutoff frequency of the filter (in hertz). + pub cutoff: Value, + /// The resonance of the filter. + /// + /// The resonance is a feedback effect that produces + /// a distinctive "ringing" sound. + pub resonance: Value, + /// How much dry (unprocessed) signal should be blended + /// with the wet (processed) signal. + pub mix: Value, +} + +impl FilterBuilder { + /// Creates a new [`FilterBuilder`] with the default settings. + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Sets the frequencies that the filter will remove. + #[must_use = "This method consumes self and returns a modified FilterBuilder, so the return value should be used"] + pub fn mode(self, mode: FilterMode) -> Self { + Self { mode, ..self } + } + + /// Sets the cutoff frequency of the filter (in hertz). + #[must_use = "This method consumes self and returns a modified FilterBuilder, so the return value should be used"] + pub fn cutoff(self, cutoff: impl Into>) -> Self { + Self { + cutoff: cutoff.into(), + ..self + } + } + + /// Sets the resonance of the filter. + #[must_use = "This method consumes self and returns a modified FilterBuilder, so the return value should be used"] + pub fn resonance(self, resonance: impl Into>) -> Self { + Self { + resonance: resonance.into(), + ..self + } + } + + /// Sets how much dry (unprocessed) signal should be blended + /// with the wet (processed) signal. `0.0` means only the dry + /// signal will be heard. `1.0` means only the wet signal will + /// be heard. + #[must_use = "This method consumes self and returns a modified FilterBuilder, so the return value should be used"] + pub fn mix(self, mix: impl Into>) -> Self { + Self { + mix: mix.into(), + ..self + } + } +} + +impl Default for FilterBuilder { + fn default() -> Self { + Self { + mode: FilterMode::LowPass, + cutoff: Value::Fixed(1000.0), + resonance: Value::Fixed(0.0), + mix: Value::Fixed(Mix::WET), + } + } +} + +impl EffectBuilder for FilterBuilder { + type Handle = FilterHandle; + + fn build(self) -> (Box, Self::Handle) { + let (command_writers, command_readers) = command_writers_and_readers(); + ( + Box::new(Filter::new(self, command_readers)), + FilterHandle { command_writers }, + ) + } +} diff --git a/third_party/kira/src/effect/filter/handle.rs b/third_party/kira/src/effect/filter/handle.rs new file mode 100644 index 00000000..f3cc0cb9 --- /dev/null +++ b/third_party/kira/src/effect/filter/handle.rs @@ -0,0 +1,28 @@ +use crate::{Mix, command::handle_param_setters}; + +use super::{CommandWriters, FilterMode}; + +/// Controls a filter effect. +#[derive(Debug)] +pub struct FilterHandle { + pub(super) command_writers: CommandWriters, +} + +impl FilterHandle { + /// Sets the frequencies that the filter will remove. + pub fn set_mode(&mut self, mode: FilterMode) { + self.command_writers.set_mode.write(mode) + } + + handle_param_setters! { + /// Sets the cutoff frequency of the filter (in hertz). + cutoff: f64, + + /// Sets the resonance of the filter. + resonance: f64, + + /// Sets how much dry (unprocessed) signal should be blended + /// with the wet (processed) signal. + mix: Mix, + } +} diff --git a/third_party/kira/src/effect/panning_control.rs b/third_party/kira/src/effect/panning_control.rs new file mode 100644 index 00000000..2d4daa98 --- /dev/null +++ b/third_party/kira/src/effect/panning_control.rs @@ -0,0 +1,51 @@ +//! Adjusts the panning of audio. + +mod builder; +mod handle; + +pub use builder::*; +pub use handle::*; + +use crate::{ + Panning, Parameter, + command::{ValueChangeCommand, read_commands_into_parameters}, + command_writers_and_readers, + frame::Frame, + info::Info, +}; + +use super::Effect; + +struct PanningControl { + command_readers: CommandReaders, + panning: Parameter, +} + +impl PanningControl { + #[must_use] + fn new(builder: PanningControlBuilder, command_readers: CommandReaders) -> Self { + Self { + command_readers, + panning: Parameter::new(builder.0, Panning::CENTER), + } + } +} + +impl Effect for PanningControl { + fn on_start_processing(&mut self) { + read_commands_into_parameters!(self, panning); + } + + fn process(&mut self, input: &mut [Frame], dt: f64, info: &Info) { + self.panning.update(dt * input.len() as f64, info); + let num_frames = input.len(); + for (i, frame) in input.iter_mut().enumerate() { + let time_in_chunk = (i + 1) as f64 / num_frames as f64; + *frame = frame.panned(self.panning.interpolated_value(time_in_chunk)) + } + } +} + +command_writers_and_readers! { + set_panning: ValueChangeCommand, +} diff --git a/third_party/kira/src/effect/panning_control/builder.rs b/third_party/kira/src/effect/panning_control/builder.rs new file mode 100644 index 00000000..9c1a5caa --- /dev/null +++ b/third_party/kira/src/effect/panning_control/builder.rs @@ -0,0 +1,25 @@ +use crate::{Panning, Value, effect::EffectBuilder}; + +use super::{PanningControl, PanningControlHandle, command_writers_and_readers}; + +/// Configures a panning control effect. +#[derive(Debug, Copy, Clone, PartialEq)] +pub struct PanningControlBuilder(pub Value); + +impl Default for PanningControlBuilder { + fn default() -> Self { + Self(Value::Fixed(Panning::CENTER)) + } +} + +impl EffectBuilder for PanningControlBuilder { + type Handle = PanningControlHandle; + + fn build(self) -> (Box, Self::Handle) { + let (command_writers, command_readers) = command_writers_and_readers(); + ( + Box::new(PanningControl::new(self, command_readers)), + PanningControlHandle { command_writers }, + ) + } +} diff --git a/third_party/kira/src/effect/panning_control/handle.rs b/third_party/kira/src/effect/panning_control/handle.rs new file mode 100644 index 00000000..e06b98e7 --- /dev/null +++ b/third_party/kira/src/effect/panning_control/handle.rs @@ -0,0 +1,16 @@ +use crate::{Panning, command::handle_param_setters}; + +use super::CommandWriters; + +/// Controls a panning control effect. +#[derive(Debug)] +pub struct PanningControlHandle { + pub(super) command_writers: CommandWriters, +} + +impl PanningControlHandle { + handle_param_setters! { + /// Sets the panning adjustment to apply to input audio. + panning: Panning, + } +} diff --git a/third_party/kira/src/effect/reverb.rs b/third_party/kira/src/effect/reverb.rs new file mode 100644 index 00000000..612ddb3e --- /dev/null +++ b/third_party/kira/src/effect/reverb.rs @@ -0,0 +1,191 @@ +//! Adds reverberations to a sound. Useful for simulating room tones. + +mod builder; +mod handle; + +pub use builder::*; +pub use handle::*; + +use crate::{ + Mix, Parameter, + command::{ValueChangeCommand, read_commands_into_parameters}, + command_writers_and_readers, + effect::Effect, + frame::Frame, + info::Info, +}; +use all_pass::AllPassFilter; +use comb::CombFilter; + +mod all_pass; +mod comb; + +const NUM_COMB_FILTERS: usize = 8; +const NUM_ALL_PASS_FILTERS: usize = 4; +const GAIN: f32 = 0.015; +const STEREO_SPREAD: usize = 23; + +#[derive(Debug)] +enum ReverbState { + Uninitialized, + Initialized { + comb_filters: [(CombFilter, CombFilter); NUM_COMB_FILTERS], + all_pass_filters: [(AllPassFilter, AllPassFilter); NUM_ALL_PASS_FILTERS], + }, +} + +// This code is based on Freeverb by Jezar at Dreampoint, found here: +// http://blog.bjornroche.com/2012/06/freeverb-original-public-domain-code-by.html +struct Reverb { + command_readers: CommandReaders, + feedback: Parameter, + damping: Parameter, + stereo_width: Parameter, + mix: Parameter, + state: ReverbState, +} + +impl Reverb { + /// Creates a new `Reverb` effect. + #[must_use] + fn new(settings: ReverbBuilder, command_readers: CommandReaders) -> Self { + Self { + command_readers, + feedback: Parameter::new(settings.feedback, 0.9), + damping: Parameter::new(settings.damping, 0.1), + stereo_width: Parameter::new(settings.stereo_width, 1.0), + mix: Parameter::new(settings.mix, Mix(0.5)), + state: ReverbState::Uninitialized, + } + } + + fn init_filters(&mut self, sample_rate: u32) { + const REFERENCE_SAMPLE_RATE: u32 = 44100; + + let adjust_buffer_size = |buffer_size: usize| -> usize { + let sample_rate_factor = (sample_rate as f64) / (REFERENCE_SAMPLE_RATE as f64); + ((buffer_size as f64) * sample_rate_factor) as usize + }; + + self.state = ReverbState::Initialized { + comb_filters: [ + ( + CombFilter::new(adjust_buffer_size(1116)), + CombFilter::new(adjust_buffer_size(1116 + STEREO_SPREAD)), + ), + ( + CombFilter::new(adjust_buffer_size(1188)), + CombFilter::new(adjust_buffer_size(1188 + STEREO_SPREAD)), + ), + ( + CombFilter::new(adjust_buffer_size(1277)), + CombFilter::new(adjust_buffer_size(1277 + STEREO_SPREAD)), + ), + ( + CombFilter::new(adjust_buffer_size(1356)), + CombFilter::new(adjust_buffer_size(1356 + STEREO_SPREAD)), + ), + ( + CombFilter::new(adjust_buffer_size(1422)), + CombFilter::new(adjust_buffer_size(1422 + STEREO_SPREAD)), + ), + ( + CombFilter::new(adjust_buffer_size(1491)), + CombFilter::new(adjust_buffer_size(1491 + STEREO_SPREAD)), + ), + ( + CombFilter::new(adjust_buffer_size(1557)), + CombFilter::new(adjust_buffer_size(1557 + STEREO_SPREAD)), + ), + ( + CombFilter::new(adjust_buffer_size(1617)), + CombFilter::new(adjust_buffer_size(1617 + STEREO_SPREAD)), + ), + ], + all_pass_filters: [ + ( + AllPassFilter::new(adjust_buffer_size(556)), + AllPassFilter::new(adjust_buffer_size(556 + STEREO_SPREAD)), + ), + ( + AllPassFilter::new(adjust_buffer_size(441)), + AllPassFilter::new(adjust_buffer_size(441 + STEREO_SPREAD)), + ), + ( + AllPassFilter::new(adjust_buffer_size(341)), + AllPassFilter::new(adjust_buffer_size(341 + STEREO_SPREAD)), + ), + ( + AllPassFilter::new(adjust_buffer_size(225)), + AllPassFilter::new(adjust_buffer_size(225 + STEREO_SPREAD)), + ), + ], + } + } +} + +impl Effect for Reverb { + fn init(&mut self, sample_rate: u32, _internal_buffer_size: usize) { + self.init_filters(sample_rate); + } + + fn on_change_sample_rate(&mut self, sample_rate: u32) { + self.init_filters(sample_rate); + } + + fn on_start_processing(&mut self) { + read_commands_into_parameters!(self, feedback, damping, stereo_width, mix); + } + + fn process(&mut self, input: &mut [Frame], dt: f64, info: &Info) { + if let ReverbState::Initialized { + comb_filters, + all_pass_filters, + } = &mut self.state + { + self.feedback.update(dt * input.len() as f64, info); + self.damping.update(dt * input.len() as f64, info); + self.stereo_width.update(dt * input.len() as f64, info); + self.mix.update(dt * input.len() as f64, info); + + let feedback = self.feedback.value() as f32; + let damping = self.damping.value() as f32; + + let num_frames = input.len(); + for (i, frame) in input.iter_mut().enumerate() { + let time_in_chunk = (i + 1) as f64 / num_frames as f64; + let stereo_width = self.stereo_width.interpolated_value(time_in_chunk) as f32; + let mix = self.mix.interpolated_value(time_in_chunk).0.clamp(0.0, 1.0); + + let mut output = Frame::ZERO; + let mono_input = (frame.left + frame.right) * GAIN; + // accumulate comb filters in parallel + for comb_filter in comb_filters.iter_mut() { + output.left += comb_filter.0.process(mono_input, feedback, damping); + output.right += comb_filter.1.process(mono_input, feedback, damping); + } + // feed through all-pass filters in series + for all_pass_filter in all_pass_filters.iter_mut() { + output.left = all_pass_filter.0.process(output.left); + output.right = all_pass_filter.1.process(output.right); + } + let wet_1 = stereo_width / 2.0 + 0.5; + let wet_2 = (1.0 - stereo_width) / 2.0; + let output = Frame::new( + output.left * wet_1 + output.right * wet_2, + output.right * wet_1 + output.left * wet_2, + ); + *frame = output * mix.sqrt() + *frame * (1.0 - mix).sqrt() + } + } else { + panic!("Reverb should be initialized before the first process call") + } + } +} + +command_writers_and_readers! { + set_feedback: ValueChangeCommand, + set_damping: ValueChangeCommand, + set_stereo_width: ValueChangeCommand, + set_mix: ValueChangeCommand, +} diff --git a/third_party/kira/src/effect/reverb/all_pass.rs b/third_party/kira/src/effect/reverb/all_pass.rs new file mode 100644 index 00000000..4463b1c0 --- /dev/null +++ b/third_party/kira/src/effect/reverb/all_pass.rs @@ -0,0 +1,27 @@ +const FEEDBACK: f32 = 0.5; + +#[derive(Debug)] +pub struct AllPassFilter { + buffer: Vec, + current_index: usize, +} + +impl AllPassFilter { + #[must_use] + pub fn new(buffer_size: usize) -> Self { + Self { + buffer: vec![0.0; buffer_size], + current_index: 0, + } + } + + #[must_use] + pub fn process(&mut self, input: f32) -> f32 { + let buffer_output = self.buffer[self.current_index]; + let output = -input + buffer_output; + self.buffer[self.current_index] = input + buffer_output * FEEDBACK; + self.current_index += 1; + self.current_index %= self.buffer.len(); + output + } +} diff --git a/third_party/kira/src/effect/reverb/builder.rs b/third_party/kira/src/effect/reverb/builder.rs new file mode 100644 index 00000000..21daf2a4 --- /dev/null +++ b/third_party/kira/src/effect/reverb/builder.rs @@ -0,0 +1,96 @@ +use crate::{ + Mix, Value, + effect::{Effect, EffectBuilder}, +}; + +use super::{Reverb, ReverbHandle, command_writers_and_readers}; + +/// Configures a reverb effect. +#[derive(Debug, Copy, Clone, PartialEq)] +pub struct ReverbBuilder { + /// How much the room reverberates. A higher value will + /// result in a bigger sounding room. 1.0 gives an infinitely + /// reverberating room. + pub feedback: Value, + /// How quickly high frequencies disappear from the reverberation. + pub damping: Value, + /// The stereo width of the reverb effect (0.0 being fully mono, + /// 1.0 being fully stereo). + pub stereo_width: Value, + /// How much dry (unprocessed) signal should be blended + /// with the wet (processed) signal. + pub mix: Value, +} + +impl ReverbBuilder { + /// Creates a new [`ReverbBuilder`] with the default settings. + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Sets how much the room reverberates. A higher value will + /// result in a bigger sounding room. 1.0 gives an infinitely + /// reverberating room. + #[must_use = "This method consumes self and returns a modified ReverbBuilder, so the return value should be used"] + pub fn feedback(self, feedback: impl Into>) -> Self { + Self { + feedback: feedback.into(), + ..self + } + } + + /// Sets how quickly high frequencies disappear from the reverberation. + #[must_use = "This method consumes self and returns a modified ReverbBuilder, so the return value should be used"] + pub fn damping(self, damping: impl Into>) -> Self { + Self { + damping: damping.into(), + ..self + } + } + + /// Sets the stereo width of the reverb effect (0.0 being fully mono, + /// 1.0 being fully stereo). + #[must_use = "This method consumes self and returns a modified ReverbBuilder, so the return value should be used"] + pub fn stereo_width(self, stereo_width: impl Into>) -> Self { + Self { + stereo_width: stereo_width.into(), + ..self + } + } + + /// Sets how much dry (unprocessed) signal should be blended + /// with the wet (processed) signal. `0.0` means only the dry + /// signal will be heard. `1.0` means only the wet signal will + /// be heard. + #[must_use = "This method consumes self and returns a modified ReverbBuilder, so the return value should be used"] + pub fn mix(self, mix: impl Into>) -> Self { + Self { + mix: mix.into(), + ..self + } + } +} + +impl Default for ReverbBuilder { + fn default() -> Self { + Self { + feedback: Value::Fixed(0.9), + damping: Value::Fixed(0.1), + stereo_width: Value::Fixed(1.0), + mix: Value::Fixed(Mix(0.5)), + } + } +} + +impl EffectBuilder for ReverbBuilder { + type Handle = ReverbHandle; + + fn build(self) -> (Box, Self::Handle) { + let (command_writers, command_readers) = command_writers_and_readers(); + ( + Box::new(Reverb::new(self, command_readers)), + ReverbHandle { command_writers }, + ) + } +} diff --git a/third_party/kira/src/effect/reverb/comb.rs b/third_party/kira/src/effect/reverb/comb.rs new file mode 100644 index 00000000..3cff1e61 --- /dev/null +++ b/third_party/kira/src/effect/reverb/comb.rs @@ -0,0 +1,27 @@ +#[derive(Debug)] +pub struct CombFilter { + filter_store: f32, + buffer: Vec, + current_index: usize, +} + +impl CombFilter { + #[must_use] + pub fn new(buffer_size: usize) -> Self { + Self { + filter_store: 0.0, + buffer: vec![0.0; buffer_size], + current_index: 0, + } + } + + #[must_use] + pub fn process(&mut self, input: f32, feedback: f32, damp: f32) -> f32 { + let output = self.buffer[self.current_index]; + self.filter_store = output * (1.0 - damp) + self.filter_store * damp; + self.buffer[self.current_index] = input + self.filter_store * feedback; + self.current_index += 1; + self.current_index %= self.buffer.len(); + output + } +} diff --git a/third_party/kira/src/effect/reverb/handle.rs b/third_party/kira/src/effect/reverb/handle.rs new file mode 100644 index 00000000..2d966e76 --- /dev/null +++ b/third_party/kira/src/effect/reverb/handle.rs @@ -0,0 +1,29 @@ +use crate::{Mix, command::handle_param_setters}; + +use super::CommandWriters; + +/// Controls a reverb effect. +#[derive(Debug)] +pub struct ReverbHandle { + pub(super) command_writers: CommandWriters, +} + +impl ReverbHandle { + handle_param_setters! { + /// Sets how much the room reverberates. A higher value will + /// result in a bigger sounding room. 1.0 gives an infinitely + /// reverberating room. + feedback: f64, + + /// Sets how quickly high frequencies disappear from the reverberation. + damping: f64, + + /// Sets the stereo width of the reverb effect (0.0 being fully mono, + /// 1.0 being fully stereo). + stereo_width: f64, + + /// Sets how much dry (unprocessed) signal should be blended + /// with the wet (processed) signal. + mix: Mix, + } +} diff --git a/third_party/kira/src/effect/volume_control.rs b/third_party/kira/src/effect/volume_control.rs new file mode 100644 index 00000000..aabd8f42 --- /dev/null +++ b/third_party/kira/src/effect/volume_control.rs @@ -0,0 +1,51 @@ +//! Adjusts the volume of audio. + +mod builder; +mod handle; + +pub use builder::*; +pub use handle::*; + +use crate::{ + Decibels, Parameter, + command::{ValueChangeCommand, read_commands_into_parameters}, + command_writers_and_readers, + frame::Frame, + info::Info, +}; + +use super::Effect; + +struct VolumeControl { + command_readers: CommandReaders, + volume: Parameter, +} + +impl VolumeControl { + #[must_use] + fn new(builder: VolumeControlBuilder, command_readers: CommandReaders) -> Self { + Self { + command_readers, + volume: Parameter::new(builder.0, Decibels::IDENTITY), + } + } +} + +impl Effect for VolumeControl { + fn on_start_processing(&mut self) { + read_commands_into_parameters!(self, volume); + } + + fn process(&mut self, input: &mut [Frame], dt: f64, info: &Info) { + self.volume.update(dt * input.len() as f64, info); + let num_frames = input.len(); + for (i, frame) in input.iter_mut().enumerate() { + let time_in_chunk = (i + 1) as f64 / num_frames as f64; + *frame *= self.volume.interpolated_value(time_in_chunk).as_amplitude(); + } + } +} + +command_writers_and_readers! { + set_volume: ValueChangeCommand, +} diff --git a/third_party/kira/src/effect/volume_control/builder.rs b/third_party/kira/src/effect/volume_control/builder.rs new file mode 100644 index 00000000..d1d9e7ea --- /dev/null +++ b/third_party/kira/src/effect/volume_control/builder.rs @@ -0,0 +1,33 @@ +use crate::{Decibels, Value, effect::EffectBuilder}; + +use super::{VolumeControl, VolumeControlHandle, command_writers_and_readers}; + +/// Configures a volume control effect. +#[derive(Debug, Copy, Clone, PartialEq)] +pub struct VolumeControlBuilder(pub Value); + +impl VolumeControlBuilder { + /// Creates a new [`VolumeControlBuilder`]. + #[must_use] + pub fn new(volume: impl Into>) -> Self { + Self(volume.into()) + } +} + +impl Default for VolumeControlBuilder { + fn default() -> Self { + Self(Value::Fixed(Decibels::IDENTITY)) + } +} + +impl EffectBuilder for VolumeControlBuilder { + type Handle = VolumeControlHandle; + + fn build(self) -> (Box, Self::Handle) { + let (command_writers, command_readers) = command_writers_and_readers(); + ( + Box::new(VolumeControl::new(self, command_readers)), + VolumeControlHandle { command_writers }, + ) + } +} diff --git a/third_party/kira/src/effect/volume_control/handle.rs b/third_party/kira/src/effect/volume_control/handle.rs new file mode 100644 index 00000000..4d2673ee --- /dev/null +++ b/third_party/kira/src/effect/volume_control/handle.rs @@ -0,0 +1,16 @@ +use crate::{Decibels, command::handle_param_setters}; + +use super::CommandWriters; + +/// Controls a volume control effect. +#[derive(Debug)] +pub struct VolumeControlHandle { + pub(super) command_writers: CommandWriters, +} + +impl VolumeControlHandle { + handle_param_setters! { + /// Sets the volume adjustment to apply to input audio. + volume: Decibels, + } +} diff --git a/third_party/kira/src/error.rs b/third_party/kira/src/error.rs new file mode 100644 index 00000000..4041c8f9 --- /dev/null +++ b/third_party/kira/src/error.rs @@ -0,0 +1,45 @@ +use std::{ + error::Error, + fmt::{Display, Formatter}, +}; + +/// Errors that can occur when playing a sound. +#[derive(Debug)] +pub enum PlaySoundError { + /// Could not play a sound because the maximum number of sounds has been reached. + SoundLimitReached, + /// An error occurred when initializing the sound. + IntoSoundError(E), +} + +impl Display for PlaySoundError { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + PlaySoundError::SoundLimitReached => f.write_str( + "Could not play a sound because the maximum number of sounds has been reached.", + ), + PlaySoundError::IntoSoundError(_) => { + f.write_str("An error occurred when initializing the sound.") + } + } + } +} + +impl Error for PlaySoundError {} + +/// An error that is returned when a resource cannot be added because the +/// maximum capacity for that resource has been reached. +/// +/// You can adjust these capacities using [`Capacities`](crate::Capacities). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct ResourceLimitReached; + +impl Display for ResourceLimitReached { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str( + "Could not add a resource because the maximum capacity for that resource has been reached", + ) + } +} + +impl Error for ResourceLimitReached {} diff --git a/third_party/kira/src/frame.rs b/third_party/kira/src/frame.rs new file mode 100644 index 00000000..941cf505 --- /dev/null +++ b/third_party/kira/src/frame.rs @@ -0,0 +1,155 @@ +use std::{ + f32::consts::SQRT_2, + ops::{Add, AddAssign, Div, DivAssign, Mul, MulAssign, Neg, Sub, SubAssign}, +}; + +use crate::Panning; + +/// A stereo audio sample. +#[derive(Debug, Copy, Clone, PartialEq, Default)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct Frame { + /// The sample for the left channel. + pub left: f32, + /// The sample for the right channel. + pub right: f32, +} + +impl Frame { + /// A [`Frame`] with both the left and right samples + /// set to `0.0`. + pub const ZERO: Frame = Frame { + left: 0.0, + right: 0.0, + }; + + /// Creates a frame with the given left and right values. + #[must_use] + pub fn new(left: f32, right: f32) -> Self { + Self { left, right } + } + + /// Creates a frame with both the left and right channels set + /// to the same value. + #[must_use] + pub fn from_mono(value: f32) -> Self { + Self::new(value, value) + } + + /// Pans a frame to the left or right. + /// + /// A panning of -1.0 represents a hard left panning, a panning of 1.0 + /// represents a hard right panning. + #[allow(clippy::float_cmp)] + #[must_use = "This method returns a new Frame and does not mutate the original value"] + pub fn panned(self, panning: Panning) -> Self { + // adding a special case for center panning improves + // performance in the sound playback benchmarks by + // about 3% + if panning == Panning::CENTER { + return self; + } + let panning = panning.0.clamp(-1.0, 1.0); + let left_right_mix = (panning + 1.0) * 0.5; + Self::new( + self.left * (1.0 - left_right_mix).sqrt(), + self.right * left_right_mix.sqrt(), + ) * SQRT_2 + } + + /// Returns the frame mixed down to mono. + #[must_use = "This method returns a new Frame and does not mutate the original value"] + pub fn as_mono(self) -> Self { + Self::from_mono((self.left + self.right) / 2.0) + } +} + +impl Add for Frame { + type Output = Self; + + fn add(self, rhs: Self) -> Self::Output { + Self::new(self.left + rhs.left, self.right + rhs.right) + } +} + +impl AddAssign for Frame { + fn add_assign(&mut self, rhs: Self) { + self.left += rhs.left; + self.right += rhs.right; + } +} + +impl Sub for Frame { + type Output = Self; + + fn sub(self, rhs: Self) -> Self::Output { + Self::new(self.left - rhs.left, self.right - rhs.right) + } +} + +impl SubAssign for Frame { + fn sub_assign(&mut self, rhs: Self) { + self.left -= rhs.left; + self.right -= rhs.right; + } +} + +impl Mul for Frame { + type Output = Self; + + fn mul(self, rhs: f32) -> Self::Output { + Self::new(self.left * rhs, self.right * rhs) + } +} + +impl MulAssign for Frame { + fn mul_assign(&mut self, rhs: f32) { + self.left *= rhs; + self.right *= rhs; + } +} + +impl Div for Frame { + type Output = Self; + + fn div(self, rhs: f32) -> Self::Output { + Self::new(self.left / rhs, self.right / rhs) + } +} + +impl DivAssign for Frame { + fn div_assign(&mut self, rhs: f32) { + self.left /= rhs; + self.right /= rhs; + } +} + +impl Neg for Frame { + type Output = Self; + + fn neg(self) -> Self::Output { + Self::new(-self.left, -self.right) + } +} + +/// Given a previous frame, a current frame, the two next frames, +/// and a position `x` from 0.0 to 1.0 between the current frame +/// and next frame, get an approximated frame. +// This is the 4-point, 3rd-order Hermite interpolation x-form +// algorithm from "Polynomial Interpolators for High-Quality +// Resampling of Oversampled Audio" by Olli Niemitalo, p. 43: +// http://yehar.com/blog/wp-content/uploads/2009/08/deip.pdf +#[must_use] +pub fn interpolate_frame( + previous: Frame, + current: Frame, + next_1: Frame, + next_2: Frame, + fraction: f32, +) -> Frame { + let c0 = current; + let c1 = (next_1 - previous) * 0.5; + let c2 = previous - current * 2.5 + next_1 * 2.0 - next_2 * 0.5; + let c3 = (next_2 - previous) * 0.5 + (current - next_1) * 1.5; + ((c3 * fraction + c2) * fraction + c1) * fraction + c0 +} diff --git a/third_party/kira/src/info.rs b/third_party/kira/src/info.rs new file mode 100644 index 00000000..4b2f7348 --- /dev/null +++ b/third_party/kira/src/info.rs @@ -0,0 +1,294 @@ +/*! + * Types for providing info about resources to trait implementors. + * + * You'll only need this if you're implementing one of Kira's traits, + * like [`Sound`](crate::sound::Sound) or [`Effect`](crate::effect::Effect). + */ + +use atomic_arena::Arena; +use glam::{Quat, Vec3}; + +use crate::{ + clock::{Clock, ClockId, ClockTime, State as ClockState}, + listener::{Listener, ListenerId}, + modulator::{Modulator, ModulatorId}, +}; + +/// Provides info about resources on the audio thread. +/// +/// You'll only need this if you're implementing one of Kira's traits, +/// like [`Sound`](crate::sound::Sound) or [`Effect`](crate::effect::Effect). +pub struct Info<'a> { + kind: InfoKind<'a>, + spatial_track_info: Option, +} + +impl<'a> Info<'a> { + pub(crate) fn new( + clocks: &'a Arena, + modulators: &'a Arena>, + listeners: &'a Arena, + spatial_track_info: Option, + ) -> Self { + Self { + kind: InfoKind::Real { + clocks, + modulators, + listeners, + }, + spatial_track_info, + } + } + + /// Gets information about the clock with the given ID if it + /// exists, returns `None` otherwise. + #[must_use] + pub fn clock_info(&self, id: ClockId) -> Option { + match &self.kind { + InfoKind::Real { clocks, .. } => clocks.get(id.0).map(|clock| ClockInfo { + ticking: clock.ticking(), + time: ClockTime { + clock: id, + ticks: match clock.state() { + ClockState::NotStarted => 0, + ClockState::Started { ticks, .. } => ticks, + }, + fraction: match clock.state() { + ClockState::NotStarted => 0.0, + ClockState::Started { + fractional_position, + .. + } => fractional_position, + }, + }, + }), + InfoKind::Mock { clock_info, .. } => clock_info.get(id.0).copied(), + } + } + + /// Returns whether something with the given start time should + /// start now, later, or never given the current state of the clocks. + #[must_use] + pub fn when_to_start(&self, time: ClockTime) -> WhenToStart { + if let Some(clock_info) = self.clock_info(time.clock) { + if clock_info.ticking && clock_info.time >= time { + WhenToStart::Now + } else { + WhenToStart::Later + } + } else { + WhenToStart::Never + } + } + + /// Gets the value of the modulator with the given ID if it + /// exists, returns `None` otherwise. + #[must_use] + pub fn modulator_value(&self, id: ModulatorId) -> Option { + match &self.kind { + InfoKind::Real { modulators, .. } => { + modulators.get(id.0).map(|modulator| modulator.value()) + } + InfoKind::Mock { + modulator_values, .. + } => modulator_values.get(id.0).copied(), + } + } + + /// Gets information about the listener linked to the current spatial track + /// if there is one. + #[must_use] + pub fn listener_info(&self) -> Option { + self.spatial_track_info.and_then(|spatial_track_info| { + let listener_id = spatial_track_info.listener_id; + match &self.kind { + InfoKind::Real { listeners, .. } => { + listeners.get(listener_id.0).map(|listener| ListenerInfo { + position: listener.position.value().into(), + orientation: listener.orientation.value().into(), + previous_position: listener.position.previous_value().into(), + previous_orientation: listener.orientation.previous_value().into(), + }) + } + InfoKind::Mock { listener_info, .. } => listener_info.get(listener_id.0).copied(), + } + }) + } + + /// If this is called from an effect on a spatial track, returns the distance + /// of the spatial track's listener from the spatial track. Otherwise, returns + /// `None`. + pub fn listener_distance(&self) -> Option { + self.spatial_track_info.zip(self.listener_info()).map( + |(spatial_track_info, listener_info)| { + Vec3::from(listener_info.position).distance(spatial_track_info.position) + }, + ) + } +} + +/// Information about the current state of a [clock](super::clock). +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct ClockInfo { + /// Whether the clock is currently running. + pub ticking: bool, + /// The current time of the clock. + pub time: ClockTime, +} + +/// When something should start given the current state +/// of the clocks. +/// +/// The "something" in question can be anything that +/// would start at a given [`StartTime`](crate::StartTime). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum WhenToStart { + /// The thing should start now. + Now, + /// The thing should start later because the appropriate + /// clock isn't ticking or hasn't reached the target tick + /// yet. + Later, + /// The thing will never start because the clock it depends + /// on no longer exists. + Never, +} + +/// Information about a listener. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct ListenerInfo { + /// The position of the listener. + pub position: mint::Vector3, + /// The rotation of the listener. + pub orientation: mint::Quaternion, + /// The position of the listener prior to the last update. + pub previous_position: mint::Vector3, + /// The rotation of the listener prior to the last update. + pub previous_orientation: mint::Quaternion, +} + +impl ListenerInfo { + /// Returns the interpolated position between the previous and current + /// position of the listener. + pub fn interpolated_position(self, amount: f32) -> mint::Vector3 { + let position: Vec3 = self.position.into(); + let previous_position: Vec3 = self.previous_position.into(); + previous_position.lerp(position, amount).into() + } + + /// Returns the interpolated orientation between the previous and current + /// orientation of the listener. + pub fn interpolated_orientation(self, amount: f32) -> mint::Quaternion { + let orientation: Quat = self.orientation.into(); + let previous_orientation: Quat = self.previous_orientation.into(); + previous_orientation.lerp(orientation, amount).into() + } +} + +/// Generates a fake `Info` with arbitrary data. Useful for writing unit tests. +pub struct MockInfoBuilder { + clock_info: Arena, + modulator_values: Arena, + listener_info: Arena, + spatial_track_info: Option, +} + +impl MockInfoBuilder { + /// Creates a new `MockInfoBuilder`. + pub fn new() -> Self { + Self { + clock_info: Arena::new(100), + modulator_values: Arena::new(100), + listener_info: Arena::new(100), + spatial_track_info: None, + } + } + + /// Adds a fake clock with the given ticking state and time. Returns a fake + /// `ClockId`. + pub fn add_clock(&mut self, ticking: bool, ticks: u64, fraction: f64) -> ClockId { + let id = ClockId( + self.clock_info + .controller() + .try_reserve() + .expect("clock info arena is full"), + ); + self.clock_info + .insert_with_key( + id.0, + ClockInfo { + ticking, + time: ClockTime { + clock: id, + ticks, + fraction, + }, + }, + ) + .unwrap(); + id + } + + /// Adds a fake modulator outputting the given value. Returns a fake `ModulatorId`. + pub fn add_modulator(&mut self, value: f64) -> ModulatorId { + let id = ModulatorId( + self.modulator_values + .controller() + .try_reserve() + .expect("modulator info arena is full"), + ); + self.modulator_values.insert_with_key(id.0, value).unwrap(); + id + } + + /// Adds a fake listener at the given position and orientation. Returns a fake `ListenerId`. + pub fn add_listener(&mut self, listener_info: ListenerInfo) -> ListenerId { + let id = ListenerId( + self.listener_info + .controller() + .try_reserve() + .expect("listener info arena is full"), + ); + self.listener_info + .insert_with_key(id.0, listener_info) + .unwrap(); + id + } + + /// Consumes the `MockInfoProvider` and returns a fake `Info`. + pub fn build(self) -> Info<'static> { + Info { + kind: InfoKind::Mock { + clock_info: self.clock_info, + modulator_values: self.modulator_values, + listener_info: self.listener_info, + }, + spatial_track_info: self.spatial_track_info, + } + } +} + +impl Default for MockInfoBuilder { + fn default() -> Self { + Self::new() + } +} + +enum InfoKind<'a> { + Real { + clocks: &'a Arena, + modulators: &'a Arena>, + listeners: &'a Arena, + }, + Mock { + clock_info: Arena, + modulator_values: Arena, + listener_info: Arena, + }, +} + +#[derive(Debug, Clone, Copy, PartialEq)] +pub(crate) struct SpatialTrackInfo { + pub position: Vec3, + pub listener_id: ListenerId, +} diff --git a/third_party/kira/src/lib.rs b/third_party/kira/src/lib.rs new file mode 100644 index 00000000..cd054d39 --- /dev/null +++ b/third_party/kira/src/lib.rs @@ -0,0 +1,256 @@ +/*! +# Kira + +Kira is a backend-agnostic library to create expressive audio for games. It provides +tweens for smoothly adjusting properties of sounds, a flexible [mixer](track) +for applying [effects](effect) to audio, a [clock] system for precisely timing audio events, +and [spatial audio](crate::track#spatial-tracks) support. + +To get started, create an [`AudioManager`] and use it to [play](AudioManager::play) a +[`StaticSoundData`](crate::sound::static_sound::StaticSoundData) or +[`StreamingSoundData`](crate::sound::streaming::StreamingSoundData). + +## Examples + +Playing a sound multiple times simultaneously: + +```rust, no_run +# extern crate kira; +# +use kira::{ + AudioManager, AudioManagerSettings, DefaultBackend, + sound::static_sound::{StaticSoundData, StaticSoundSettings}, +}; + +// Create an audio manager. This plays sounds and manages resources. +let mut manager = AudioManager::::new(AudioManagerSettings::default())?; +let sound_data = StaticSoundData::from_file("sound.ogg")?; +manager.play(sound_data.clone())?; +// After a couple seconds... +manager.play(sound_data.clone())?; +// Cloning the sound data will not use any extra memory. +# Result::<(), Box>::Ok(()) +``` + +Gradually speeding up a sound over time: + +```rust, no_run +# extern crate kira; +# +use std::time::Duration; + +use kira::{ + AudioManager, AudioManagerSettings, DefaultBackend, + sound::static_sound::{StaticSoundData, StaticSoundSettings}, + Tween, +}; + +let mut manager = AudioManager::::new(AudioManagerSettings::default())?; +let sound_data = StaticSoundData::from_file("sound.ogg")?; +let mut sound = manager.play(sound_data)?; +// Start smoothly adjusting the playback rate parameter. +sound.set_playback_rate( + 2.0, + Tween { + duration: Duration::from_secs(3), + ..Default::default() + }, +); +# Result::<(), Box>::Ok(()) +``` + +Playing a sound with a low-pass filter applied (this makes the +audio sound muffled): + +```rust, no_run +# extern crate kira; +# +use kira::{ + AudioManager, AudioManagerSettings, DefaultBackend, + sound::static_sound::{StaticSoundData, StaticSoundSettings}, + track::TrackBuilder, + effect::filter::FilterBuilder, +}; + +let mut manager = AudioManager::::new(AudioManagerSettings::default())?; +// Create a mixer sub-track with a filter. +let mut track = manager.add_sub_track({ + let mut builder = TrackBuilder::new(); + builder.add_effect(FilterBuilder::new().cutoff(1000.0)); + builder +})?; +// Play the sound on the track. +let sound_data = StaticSoundData::from_file("sound.ogg")?; +track.play(sound_data)?; +# Result::<(), Box>::Ok(()) +``` + +Playing sounds in time with a musical beat: + +```rust, no_run +# extern crate kira; +# +use kira::{ + AudioManager, AudioManagerSettings, DefaultBackend, + sound::static_sound::{StaticSoundData, StaticSoundSettings}, + clock::ClockSpeed, +}; + +const TEMPO: f64 = 120.0; + +let mut manager = AudioManager::::new(AudioManagerSettings::default())?; +// Create a clock that ticks 120 times per second. In this case, +// each tick is one musical beat. We can use a tick to represent any +// arbitrary amount of time. +let mut clock = manager.add_clock(ClockSpeed::TicksPerMinute(TEMPO))?; +// Play a sound 2 ticks (beats) from now. +let sound_data_1 = StaticSoundData::from_file("sound1.ogg")? + .start_time(clock.time() + 2); +manager.play(sound_data_1)?; +// Play a different sound 4 ticks (beats) from now. +let sound_data_2 = StaticSoundData::from_file("sound2.ogg")? + .start_time(clock.time() + 4); +manager.play(sound_data_2)?; +// Start the clock. +clock.start(); +# Result::<(), Box>::Ok(()) +``` + +## Features + +The Kira crate has the following feature flags: + +- `cpal` (enabled by default) - enables the cpal backend and makes it the default for + audio managers. This allows Kira to talk to the operating system to output audio. Most + users should leave this enabled. +- `symphonia` (enabled by default) - allows loading and streaming audio from common + audio formats, like MP3 and WAV. + - Audio formats: `aiff`, `caf`, `isomp4`, `mkv`, `ogg`, `wav` - these correspond to the + Symphonia features of the same name + - Audio codecs: `aac`, `adpcm`, `alac`, `he-aac`, `he-aac-v2`, `flac`, `mp1`, `mp2`, `mp3`, + `mpa`, `opus', `pcm`, `vorbis`, `wavpack` - these correspond to the Symphonia features + of the same name + - `mp3`, `ogg`, `vorbis`, `flac`, `wav`, and `pcm` are enabled by default. + - [See Symphonia's documentation for more information about these flags.](https://github.com/pdeljanov/Symphonia?tab=readme-ov-file#formats-demuxers) +- `serde` - adds `Serialize` and `Deserialize` implementations for the following types: + - [`Capacities`] + - [`ClockSpeed`](crate::clock::ClockSpeed) + - [`DistortionKind`](crate::effect::distortion::DistortionKind) + - [`Easing`] + - [`EndPosition`](crate::sound::EndPosition) + - [`EqFilterKind`](crate::effect::eq_filter::EqFilterKind) + - [`FilterMode`](crate::effect::filter::FilterMode) + - [`Frame`] + - [`Mapping`] + - [`PlaybackPosition`](crate::sound::PlaybackPosition) + - [`PlaybackRate`] + - [`PlaybackState`](crate::sound::PlaybackState) + - [`Region`](crate::sound::Region) + - [`Decibels`] + - [`Waveform`](crate::modulator::lfo::Waveform) +- `assert_no_alloc` - uses the [`assert_no_alloc`](https://crates.io/crates/assert_no_alloc) crate + to cause panics if memory is allocated or deallocated on the audio thread. This is mainly useful + for people developing Kira itself. + +## Performance using the `dev` profile + +By default, Rust programs run with the `dev` profile are not optimized. This can +lead to poor performance of audio playback and long loading times for audio +files. You can alleviate this by building Kira and its audio-related +dependencies with a higher optimization level. Add the following to your +Cargo.toml: + +```toml +[profile.dev.package.kira] +opt-level = 3 + +[profile.dev.package.cpal] +opt-level = 3 + +[profile.dev.package.symphonia] +opt-level = 3 + +[profile.dev.package.symphonia-bundle-mp3] +opt-level = 3 + +[profile.dev.package.symphonia-format-ogg] +opt-level = 3 + +[profile.dev.package.symphonia-codec-vorbis] +opt-level = 3 + +[profile.dev.package.symphonia-bundle-flac] +opt-level = 3 + +[profile.dev.package.symphonia-format-wav] +opt-level = 3 + +[profile.dev.package.symphonia-codec-pcm] +opt-level = 3 +``` + +You can also build all of your projects with a higher optimization level by +using this snippet instead: + +```toml +[profile.dev.package."*"] +opt-level = 3 +``` + +Building dependencies with a higher optimization level does increase compile +times, but only when compiling your project from scratch. If you only make +changes to your crate, you're not recompiling the dependencies, so you don't +suffer from the longer compilation step in that case. Building dependencies +optimized and the main crate unoptimized can be a good balance of performance +and compile times for games. +*/ + +#![cfg_attr(docsrs, feature(doc_cfg))] +#![allow(clippy::collapsible_if)] +#![allow(clippy::collapsible_else_if)] +#![allow(clippy::doc_lazy_continuation)] +#![allow(clippy::large_enum_variant)] +#![allow(clippy::enum_variant_names)] +#![warn(clippy::todo)] +#![warn(missing_docs)] +#![allow(clippy::tabs_in_doc_comments)] + +pub mod backend; +pub mod clock; +pub mod command; +mod decibels; +pub mod effect; +mod error; +mod frame; +pub mod info; +pub mod listener; +mod manager; +mod mix; +pub mod modulator; +mod panning; +mod parameter; +mod playback_rate; +mod playback_state_manager; +mod semitones; +pub mod sound; +mod start_time; +#[cfg(test)] +mod test_helpers; +pub mod track; +mod tween; +mod value; + +pub use backend::DefaultBackend; +pub use decibels::*; +pub use error::*; +pub use frame::*; +pub use manager::*; +pub use mix::*; +pub use panning::*; +pub use parameter::*; +pub use pastey; +pub use playback_rate::*; +pub use semitones::*; +pub use start_time::*; +pub use tween::*; +pub use value::*; diff --git a/third_party/kira/src/listener.rs b/third_party/kira/src/listener.rs new file mode 100644 index 00000000..6041fa7f --- /dev/null +++ b/third_party/kira/src/listener.rs @@ -0,0 +1,109 @@ +/*! +Types related to spatial listeners. + +To create a listener, use [`AudioManager::add_listener`](crate::AudioManager::add_listener). + +For more information, see the documentation on [spatial mixer tracks](crate::track#spatial-tracks). +*/ + +mod handle; + +use atomic_arena::Key; +pub use handle::*; + +use std::sync::{ + Arc, + atomic::{AtomicBool, Ordering}, +}; + +use glam::{Quat, Vec3}; + +use crate::{ + Parameter, Value, + command::{ValueChangeCommand, read_commands_into_parameters}, + command_writers_and_readers, + info::Info, +}; + +/// A unique identifier for a listener. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct ListenerId(pub(crate) Key); + +pub(crate) struct Listener { + pub shared: Arc, + pub position: Parameter, + pub orientation: Parameter, + pub command_readers: CommandReaders, +} + +impl Listener { + pub fn new( + id: ListenerId, + position: Value, + orientation: Value, + ) -> (Self, ListenerHandle) { + let shared = Arc::new(ListenerShared::new()); + let (command_writers, command_readers) = command_writers_and_readers(); + ( + Self { + shared: shared.clone(), + position: Parameter::new(position, Vec3::ZERO), + orientation: Parameter::new(orientation, Quat::IDENTITY), + command_readers, + }, + ListenerHandle { + id, + shared, + command_writers, + }, + ) + } + + pub fn on_start_processing(&mut self) { + read_commands_into_parameters!(self, position, orientation); + } + + pub(crate) fn update(&mut self, dt: f64, info: &Info) { + self.position.update(dt, info); + self.orientation.update(dt, info); + } +} + +impl Default for Listener { + fn default() -> Self { + let (_, command_readers) = command_writers_and_readers(); + Self { + shared: Arc::new(ListenerShared::new()), + position: Parameter::new(Value::Fixed(Vec3::ZERO), Vec3::ZERO), + orientation: Parameter::new(Value::Fixed(Quat::IDENTITY), Quat::IDENTITY), + command_readers, + } + } +} + +#[derive(Debug)] +pub(crate) struct ListenerShared { + removed: AtomicBool, +} + +impl ListenerShared { + pub fn new() -> Self { + Self { + removed: AtomicBool::new(false), + } + } + + #[must_use] + pub fn is_marked_for_removal(&self) -> bool { + self.removed.load(Ordering::SeqCst) + } + + pub fn mark_for_removal(&self) { + self.removed.store(true, Ordering::SeqCst); + } +} + +command_writers_and_readers! { + set_position: ValueChangeCommand, + set_orientation: ValueChangeCommand, +} diff --git a/third_party/kira/src/listener/handle.rs b/third_party/kira/src/listener/handle.rs new file mode 100644 index 00000000..fb55a418 --- /dev/null +++ b/third_party/kira/src/listener/handle.rs @@ -0,0 +1,62 @@ +use std::sync::Arc; + +use crate::{Tween, Value, command::ValueChangeCommand}; + +use super::{CommandWriters, ListenerId, ListenerShared}; + +/// Controls a listener. +/// +/// When a [`ListenerHandle`] is dropped, the corresponding +/// listener will be removed. +#[derive(Debug)] +pub struct ListenerHandle { + pub(crate) id: ListenerId, + pub(crate) shared: Arc, + pub(crate) command_writers: CommandWriters, +} + +impl ListenerHandle { + /// Gets the unique identifier for this listener. + pub fn id(&self) -> ListenerId { + self.id + } + + /// Sets the location of the listener in the spatial scene. + pub fn set_position(&mut self, position: impl Into>>, tween: Tween) { + let position: Value> = position.into(); + self.command_writers.set_position.write(ValueChangeCommand { + target: position.to_(), + tween, + }) + } + + /// Sets the rotation of the listener. + /// + /// An unrotated listener should face in the negative Z direction with + /// positive X to the right and positive Y up. + pub fn set_orientation( + &mut self, + orientation: impl Into>>, + tween: Tween, + ) { + let orientation: Value> = orientation.into(); + self.command_writers + .set_orientation + .write(ValueChangeCommand { + target: orientation.to_(), + tween, + }) + } +} + +impl Drop for ListenerHandle { + fn drop(&mut self) { + self.shared.mark_for_removal(); + } +} + +impl From<&ListenerHandle> for ListenerId { + fn from(value: &ListenerHandle) -> Self { + value.id() + } +} diff --git a/third_party/kira/src/manager.rs b/third_party/kira/src/manager.rs new file mode 100644 index 00000000..0a63ffe1 --- /dev/null +++ b/third_party/kira/src/manager.rs @@ -0,0 +1,330 @@ +//! The main entrypoint for controlling audio from gameplay code. +//! +//! In order to play audio, you'll need to create an [`AudioManager`]. +//! The [`AudioManager`] keeps track of playing sounds and manages other +//! resources like clocks, mixer tracks, and spatial scenes. Once the +//! [`AudioManager`] is dropped, its audio output will be stopped. + +mod settings; + +pub use settings::*; + +use std::sync::{Arc, atomic::Ordering}; + +use crate::{ + PlaySoundError, ResourceLimitReached, Value, + backend::{ + Backend, DefaultBackend, Renderer, RendererShared, + resources::{ResourceControllers, create_resources}, + }, + clock::{Clock, ClockHandle, ClockId, ClockSpeed}, + listener::{Listener, ListenerHandle, ListenerId}, + modulator::{ModulatorBuilder, ModulatorId}, + sound::SoundData, + track::{ + MainTrackHandle, SendTrackBuilder, SendTrackHandle, SendTrackId, SpatialTrackBuilder, + SpatialTrackHandle, TrackBuilder, TrackHandle, + }, +}; + +/// Controls audio from gameplay code. +pub struct AudioManager { + backend: B, + resource_controllers: ResourceControllers, + renderer_shared: Arc, + internal_buffer_size: usize, +} + +impl AudioManager { + /** + Creates a new [`AudioManager`]. + + # Examples + + Create an [`AudioManager`] using the [`DefaultBackend`] and the + default settings: + + ```no_run + use kira::{AudioManager, AudioManagerSettings, DefaultBackend}; + + let audio_manager = AudioManager::::new(AudioManagerSettings::default())?; + # Result::<(), Box>::Ok(()) + ``` + + Create an [`AudioManager`] with a reverb effect on the main mixer track: + + ```no_run + use kira::{ + AudioManager, AudioManagerSettings, DefaultBackend, + track::MainTrackBuilder, + effect::reverb::ReverbBuilder, + }; + + let audio_manager = AudioManager::::new(AudioManagerSettings { + main_track_builder: MainTrackBuilder::new().with_effect(ReverbBuilder::new()), + ..Default::default() + })?; + # Result::<(), Box>::Ok(()) + ``` + */ + pub fn new(settings: AudioManagerSettings) -> Result { + let (mut backend, sample_rate) = + B::setup(settings.backend_settings, settings.internal_buffer_size)?; + let renderer_shared = Arc::new(RendererShared::new(sample_rate)); + let (resources, resource_controllers) = create_resources( + settings.capacities, + settings.main_track_builder, + sample_rate, + settings.internal_buffer_size, + ); + let renderer = Renderer::new( + renderer_shared.clone(), + settings.internal_buffer_size, + resources, + ); + backend.start(renderer)?; + Ok(Self { + backend, + resource_controllers, + renderer_shared, + internal_buffer_size: settings.internal_buffer_size, + }) + } + + /** + Plays a sound. + + # Examples + + ```no_run + # use kira::{AudioManager, AudioManagerSettings, DefaultBackend}; + use kira::sound::static_sound::{StaticSoundData, StaticSoundSettings}; + + # let mut manager = AudioManager::::new(AudioManagerSettings::default())?; + let sound_data = StaticSoundData::from_file("sound.ogg")?; + manager.play(sound_data)?; + # Result::<(), Box>::Ok(()) + ``` + */ + pub fn play( + &mut self, + sound_data: D, + ) -> Result> { + self.main_track().play(sound_data) + } + + /// Creates a mixer sub-track. + pub fn add_sub_track( + &mut self, + builder: TrackBuilder, + ) -> Result { + let (mut track, handle) = + builder.build(self.renderer_shared.clone(), self.internal_buffer_size); + track.init_effects(self.renderer_shared.sample_rate.load(Ordering::SeqCst)); + self.resource_controllers + .sub_track_controller + .insert(track)?; + Ok(handle) + } + + /// Adds a spatial mixer sub-track. + pub fn add_spatial_sub_track( + &mut self, + listener: impl Into, + position: impl Into>>, + builder: SpatialTrackBuilder, + ) -> Result { + let (mut track, handle) = builder.build( + self.renderer_shared.clone(), + self.internal_buffer_size, + listener.into(), + position.into().to_(), + ); + track.init_effects(self.renderer_shared.sample_rate.load(Ordering::SeqCst)); + self.resource_controllers + .sub_track_controller + .insert(track)?; + Ok(handle) + } + + /// Creates a mixer send track. + pub fn add_send_track( + &mut self, + builder: SendTrackBuilder, + ) -> Result { + let key = self + .resource_controllers + .send_track_controller + .try_reserve()?; + let id = SendTrackId(key); + let (mut track, handle) = builder.build(id, self.internal_buffer_size); + track.init_effects(self.renderer_shared.sample_rate.load(Ordering::SeqCst)); + self.resource_controllers + .send_track_controller + .insert_with_key(key, track); + Ok(handle) + } + + /** + Creates a clock. + + # Examples + + ```no_run + # use kira::{AudioManager, AudioManagerSettings, DefaultBackend}; + use kira::clock::ClockSpeed; + + # let mut manager = AudioManager::::new(AudioManagerSettings::default())?; + let clock = manager.add_clock(ClockSpeed::TicksPerMinute(120.0))?; + # Result::<(), Box>::Ok(()) + ``` + */ + pub fn add_clock( + &mut self, + speed: impl Into>, + ) -> Result { + let key = self.resource_controllers.clock_controller.try_reserve()?; + let id = ClockId(key); + let (clock, handle) = Clock::new(speed.into(), id); + self.resource_controllers + .clock_controller + .insert_with_key(key, clock); + Ok(handle) + } + + /** + Creates a modulator. + + # Examples + + ```no_run + # use kira::{AudioManager, AudioManagerSettings, DefaultBackend}; + use kira::modulator::lfo::LfoBuilder; + + # let mut manager = AudioManager::::new(AudioManagerSettings::default())?; + let modulator = manager.add_modulator(LfoBuilder::new())?; + # Result::<(), Box>::Ok(()) + ``` + */ + pub fn add_modulator( + &mut self, + builder: Builder, + ) -> Result { + let key = self + .resource_controllers + .modulator_controller + .try_reserve()?; + let id = ModulatorId(key); + let (modulator, handle) = builder.build(id); + self.resource_controllers + .modulator_controller + .insert_with_key(key, modulator); + Ok(handle) + } + + /** + Creates a listener. + + # Examples + + ```no_run + # use kira::{AudioManager, AudioManagerSettings, DefaultBackend}; + # let mut manager = AudioManager::::new(AudioManagerSettings::default())?; + // This example uses `glam`, but you can use any math library that has interoperability + // with `mint`. + let listener = manager.add_listener(glam::vec3(0.0, 0.0, 0.0), glam::Quat::IDENTITY)?; + # Result::<(), Box>::Ok(()) + ``` + */ + pub fn add_listener( + &mut self, + position: impl Into>>, + orientation: impl Into>>, + ) -> Result { + let key = self + .resource_controllers + .listener_controller + .try_reserve()?; + let id = ListenerId(key); + let (listener, handle) = Listener::new(id, position.into().to_(), orientation.into().to_()); + self.resource_controllers + .listener_controller + .insert_with_key(key, listener); + Ok(handle) + } + + /** + Returns a handle to the main mixer track. + + # Examples + + Use the main track handle to adjust the volume of all audio: + + ```no_run + # use kira::{AudioManager, AudioManagerSettings, DefaultBackend}; + use kira::Tween; + + # let mut manager = AudioManager::::new(AudioManagerSettings::default())?; + manager.main_track().set_volume(-6.0, Tween::default()); + # Result::<(), Box>::Ok(()) + ``` + */ + #[must_use] + pub fn main_track(&mut self) -> &mut MainTrackHandle { + &mut self.resource_controllers.main_track_handle + } + + /// Returns the number of mixer sub-tracks that can exist at a time. + #[must_use] + pub fn sub_track_capacity(&self) -> usize { + self.resource_controllers.sub_track_controller.capacity() + } + + /// Returns the number of mixer send tracks that can exist at a time. + #[must_use] + pub fn send_track_capacity(&self) -> usize { + self.resource_controllers.send_track_controller.capacity() + } + + /// Returns the number of clocks that can exist at a time. + #[must_use] + pub fn clock_capacity(&self) -> usize { + self.resource_controllers.clock_controller.capacity() + } + + /// Returns the number of modulators that can exist at a time. + #[must_use] + pub fn modulator_capacity(&self) -> usize { + self.resource_controllers.modulator_controller.capacity() + } + + /// Returns the number of mixer sub-tracks that currently exist. + #[must_use] + pub fn num_sub_tracks(&self) -> usize { + self.resource_controllers.sub_track_controller.len() + } + + /// Returns the number of mixer send tracks that currently exist. + #[must_use] + pub fn num_send_tracks(&self) -> usize { + self.resource_controllers.send_track_controller.len() + } + + /// Returns the number of clocks that currently exist. + #[must_use] + pub fn num_clocks(&self) -> usize { + self.resource_controllers.clock_controller.len() + } + + /// Returns the number of modulators that currently exist. + #[must_use] + pub fn num_modulators(&self) -> usize { + self.resource_controllers.modulator_controller.len() + } + + /// Returns a mutable reference to this manager's backend. + #[must_use] + pub fn backend_mut(&mut self) -> &mut B { + &mut self.backend + } +} diff --git a/third_party/kira/src/manager/settings.rs b/third_party/kira/src/manager/settings.rs new file mode 100644 index 00000000..1352ee31 --- /dev/null +++ b/third_party/kira/src/manager/settings.rs @@ -0,0 +1,65 @@ +use crate::track::MainTrackBuilder; + +use crate::backend::Backend; + +/// Specifies how many of each resource type an audio context +/// can have. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct Capacities { + /// The maximum number of mixer sub-tracks that can exist at a time. + pub sub_track_capacity: usize, + /// The maximum number of mixer send tracks that can exist at a time. + pub send_track_capacity: usize, + /// The maximum number of clocks that can exist at a time. + pub clock_capacity: usize, + /// The maximum number of modulators that can exist at a time. + pub modulator_capacity: usize, + /// The maximum number of listeners that can exist at a time. + pub listener_capacity: usize, +} + +impl Default for Capacities { + fn default() -> Self { + Self { + sub_track_capacity: 128, + send_track_capacity: 16, + clock_capacity: 8, + modulator_capacity: 16, + listener_capacity: 8, + } + } +} + +/// Settings for an [`AudioManager`](super::AudioManager). +pub struct AudioManagerSettings { + /// Specifies how many of each resource type an audio context + /// can have. + pub capacities: Capacities, + /// Configures the main mixer track. + pub main_track_builder: MainTrackBuilder, + /// Determines how often modulators and clocks will be updated (in samples). + /// + /// At the default size of 128 samples, at a sample rate of 44100hz, + /// modulators and clocks will update about every 3 milliseconds. + /// + /// Decreasing this value increases the precision of clocks and modulators + /// at the expense of higher CPU usage. + pub internal_buffer_size: usize, + /// Configures the backend. + pub backend_settings: B::Settings, +} + +impl Default for AudioManagerSettings +where + B::Settings: Default, +{ + fn default() -> Self { + Self { + capacities: Capacities::default(), + main_track_builder: MainTrackBuilder::default(), + internal_buffer_size: 128, + backend_settings: B::Settings::default(), + } + } +} diff --git a/third_party/kira/src/mix.rs b/third_party/kira/src/mix.rs new file mode 100644 index 00000000..c59c813c --- /dev/null +++ b/third_party/kira/src/mix.rs @@ -0,0 +1,129 @@ +use std::ops::{ + Add, AddAssign, Div, DivAssign, Mul, MulAssign, Neg, Rem, RemAssign, Sub, SubAssign, +}; + +use crate::{Value, tween::Tweenable}; + +#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(transparent))] +/** +An amount to blend the "dry" and "wet" outputs from an effect. + +The "dry" signal is the audio before the effect is applied. +The "wet" signal is the audio after the effect is applied. + +Valid mix values range from `0.0` to `1.0`, where `0.0` is +the dry signal only, `1.0` is the wet signal only, and `0.5` +is an equal mix of both. +*/ +pub struct Mix(pub f32); + +impl Mix { + /// Only output the dry signal. + pub const DRY: Self = Self(0.0); + /// Only output the wet signal. + pub const WET: Self = Self(1.0); +} + +impl Tweenable for Mix { + fn interpolate(a: Self, b: Self, amount: f64) -> Self { + Self(Tweenable::interpolate(a.0, b.0, amount)) + } +} + +impl From for Mix { + fn from(value: f32) -> Self { + Self(value) + } +} + +impl From for Value { + fn from(value: f32) -> Self { + Self::Fixed(Mix(value)) + } +} + +impl From for Value { + fn from(value: Mix) -> Self { + Self::Fixed(value) + } +} + +impl Add for Mix { + type Output = Mix; + + fn add(self, rhs: Mix) -> Self::Output { + Self(self.0 + rhs.0) + } +} + +impl AddAssign for Mix { + fn add_assign(&mut self, rhs: Mix) { + self.0 += rhs.0; + } +} + +impl Sub for Mix { + type Output = Mix; + + fn sub(self, rhs: Mix) -> Self::Output { + Self(self.0 - rhs.0) + } +} + +impl SubAssign for Mix { + fn sub_assign(&mut self, rhs: Mix) { + self.0 -= rhs.0; + } +} + +impl Mul for Mix { + type Output = Mix; + + fn mul(self, rhs: f32) -> Self::Output { + Self(self.0 * rhs) + } +} + +impl MulAssign for Mix { + fn mul_assign(&mut self, rhs: f32) { + self.0 *= rhs; + } +} + +impl Div for Mix { + type Output = Mix; + + fn div(self, rhs: f32) -> Self::Output { + Self(self.0 / rhs) + } +} + +impl DivAssign for Mix { + fn div_assign(&mut self, rhs: f32) { + self.0 /= rhs; + } +} + +impl Neg for Mix { + type Output = Mix; + + fn neg(self) -> Self::Output { + Self(-self.0) + } +} + +impl Rem for Mix { + type Output = Mix; + + fn rem(self, rhs: f32) -> Self::Output { + Self(self.0 % rhs) + } +} + +impl RemAssign for Mix { + fn rem_assign(&mut self, rhs: f32) { + self.0 %= rhs; + } +} diff --git a/third_party/kira/src/modulator.rs b/third_party/kira/src/modulator.rs new file mode 100644 index 00000000..aa1071ee --- /dev/null +++ b/third_party/kira/src/modulator.rs @@ -0,0 +1,205 @@ +/*! +Global values that parameters (like volume and playback rate) can be linked to. + +Any type that implements [`ModulatorBuilder`] can be added to an audio manager by +using [`AudioManager::add_modulator`](crate::AudioManager::add_modulator). + +If needed, you can create custom modulators by implementing the [`ModulatorBuilder`] +and [`Modulator`] traits. + +# Why modulators? + +Many properties of things in Kira, like the volumes of sounds, can be smoothly +transitioned from one value to another without the use of modulators. Modulators +become handy when: + +- You want to control multiple properties of objects in lockstep +- You need to change a property in a way that's more complicated than a simple + transition + +# Tweener example + +Let's say we have a music track with two layers that play simultaneously: +drums and piano. When the player character enters water, we want the music +to sound "underwater", so we'll fade out the drums and make the piano sound +more muffled using a low-pass filter. + +For this situation, a [`tweener`] is appropriate. Tweeners hold a value +that doesn't change until we tell it to, and the value can be smoothly +transitioned. + +The tweener is an input value that will generate multiple output values: +the drums volume and piano filter frequency. When the tweener is set to +`0.0`, that represents that the player is not underwater, and when it's +`1.0`, the player is submerged. (These are arbitrary values.) + +| Tweener value | Drums volume | Piano filter frequency | +|---------------|--------------|------------------------| +| 0.0 | 1.0 | 20,000 Hz | +| 1.0 | 0.0 | 500 Hz | + +First, let's create the tweener: + +```no_run +use kira::{ + AudioManager, AudioManagerSettings, DefaultBackend, + modulator::tweener::TweenerBuilder, +}; + +let mut manager = AudioManager::::new(AudioManagerSettings::default())?; +let mut tweener = manager.add_modulator(TweenerBuilder { initial_value: 0.0 })?; +# Result::<(), Box>::Ok(()) +``` + +Next, we'll create a mixer track with a low-pass filter effect. The piano will play +on this track so we can make it sound more or less muffled. + +```no_run +# use kira::{ +# AudioManager, AudioManagerSettings, DefaultBackend, +# modulator::tweener::TweenerBuilder, +# }; +use kira::{ + effect::filter::FilterBuilder, + Mapping, Value, Easing, + track::TrackBuilder, +}; + +# let mut manager = AudioManager::::new(AudioManagerSettings::default())?; +# let mut tweener = manager.add_modulator(TweenerBuilder { initial_value: 0.0 })?; +let filter_builder = FilterBuilder::new() + .cutoff(Value::from_modulator(&tweener, Mapping { + input_range: (0.0, 1.0), + output_range: (20_000.0, 500.0), + easing: Easing::Linear, + })); +let mut piano_track = manager.add_sub_track(TrackBuilder::new().with_effect(filter_builder))?; +# Result::<(), Box>::Ok(()) +``` + +We use a `Mapping` to map the input values of the tweener to the output values +of the filter cutoff frequency. + +Finally, we'll play the sounds: + +```no_run +# use kira::{ +# AudioManager, AudioManagerSettings, DefaultBackend, +# modulator::tweener::TweenerBuilder, +# effect::filter::FilterBuilder, +# track::TrackBuilder, +# Mapping, Value, Easing, +# Decibels, +# }; +use kira::sound::static_sound::StaticSoundData; + +# let mut manager = AudioManager::::new(AudioManagerSettings::default())?; +# let mut tweener = manager.add_modulator(TweenerBuilder { initial_value: 0.0 })?; +# let filter_builder = FilterBuilder::new() +# .cutoff(Value::from_modulator(&tweener, Mapping { +# input_range: (0.0, 1.0), +# output_range: (20_000.0, 500.0), +# easing: Easing::Linear, +# })); +# let mut piano_track = manager.add_sub_track(TrackBuilder::new().with_effect(filter_builder))?; +piano_track.play(StaticSoundData::from_file("piano.ogg")?)?; +manager.play( + StaticSoundData::from_file("drums.ogg")? + .volume(Value::from_modulator(&tweener, Mapping { + input_range: (0.0, 1.0), + output_range: (Decibels::IDENTITY, Decibels::SILENCE), + easing: Easing::Linear, + })) +)?; +# Result::<(), Box>::Ok(()) +``` + +Notice how we also use a `Mapping` to map the input range of the tweener to the +output values of the sound volume. + +Once the player goes underwater, we can smoothly transition the tweener's value from +`0.0` to `1.0`, which will automatically change the drum volume and piano filter frequency. + +```no_run +# use kira::{ +# AudioManager, AudioManagerSettings, DefaultBackend, +# modulator::tweener::TweenerBuilder, +# track::TrackBuilder, +# effect::filter::FilterBuilder, +# sound::static_sound::StaticSoundData, +# Mapping, Value, Easing, +# Decibels, +# }; +use kira::Tween; +use std::time::Duration; + +# let mut manager = AudioManager::::new(AudioManagerSettings::default())?; +# let mut tweener = manager.add_modulator(TweenerBuilder { initial_value: 0.0 })?; +# let filter_builder = FilterBuilder::new() +# .cutoff(Value::from_modulator(&tweener, Mapping { +# input_range: (0.0, 1.0), +# output_range: (20_000.0, 500.0), +# easing: Easing::Linear, +# })); +# let mut piano_track = manager.add_sub_track(TrackBuilder::new().with_effect(filter_builder))?; +# piano_track.play(StaticSoundData::from_file("piano.ogg")?)?; +# manager.play( +# StaticSoundData::from_file("drums.ogg")? +# .volume(Value::from_modulator(&tweener, Mapping { +# input_range: (0.0, 1.0), +# output_range: (Decibels::IDENTITY, Decibels::SILENCE), +# easing: Easing::Linear, +# })) +# )?; +tweener.set(1.0, Tween { + duration: Duration::from_secs(3), + ..Default::default() +}); +# Result::<(), Box>::Ok(()) +``` + +*/ + +pub mod lfo; +pub mod tweener; + +use atomic_arena::Key; + +use crate::info::Info; + +/// Configures a modulator. +pub trait ModulatorBuilder { + /// Allows the user to control the modulator from gameplay code. + type Handle; + + /// Creates the modulator and a handle to the modulator. + #[must_use] + fn build(self, id: ModulatorId) -> (Box, Self::Handle); +} + +/// Produces a stream of values that a parameter can be linked to. +pub trait Modulator: Send { + /// Called whenever a new batch of audio samples is requested by the backend. + /// + /// This is a good place to put code that needs to run fairly frequently, + /// but not for every single audio sample. + fn on_start_processing(&mut self) {} + + /// Updates the modulator. + /// + /// `dt` is the time that's elapsed since the previous round of + /// processing (in seconds). + fn update(&mut self, dt: f64, info: &Info); + + /// Returns the current output of the modulator. + #[must_use] + fn value(&self) -> f64; + + /// Whether the modulator can be removed from the audio context. + #[must_use] + fn finished(&self) -> bool; +} + +/// A unique identifier for a modulator. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct ModulatorId(pub(crate) Key); diff --git a/third_party/kira/src/modulator/lfo.rs b/third_party/kira/src/modulator/lfo.rs new file mode 100644 index 00000000..9ff715d9 --- /dev/null +++ b/third_party/kira/src/modulator/lfo.rs @@ -0,0 +1,142 @@ +//! Oscillates back and forth. + +#[cfg(test)] +mod test; + +mod builder; +mod handle; + +pub use builder::*; +pub use handle::*; + +use std::{ + f64::consts::TAU, + sync::{ + Arc, + atomic::{AtomicBool, Ordering}, + }, +}; + +use crate::{ + Parameter, + command::{ValueChangeCommand, read_commands_into_parameters}, + command_writers_and_readers, + info::Info, +}; + +use super::Modulator; + +struct Lfo { + waveform: Waveform, + frequency: Parameter, + amplitude: Parameter, + offset: Parameter, + command_readers: CommandReaders, + shared: Arc, + phase: f64, + value: f64, +} + +impl Lfo { + #[must_use] + fn new(builder: &LfoBuilder, command_readers: CommandReaders, shared: Arc) -> Self { + Self { + waveform: builder.waveform, + frequency: Parameter::new(builder.frequency, 2.0), + amplitude: Parameter::new(builder.amplitude, 1.0), + offset: Parameter::new(builder.offset, 0.0), + command_readers, + shared, + phase: builder.starting_phase / TAU, + value: 0.0, + } + } +} + +impl Modulator for Lfo { + fn on_start_processing(&mut self) { + read_commands_into_parameters!(self, frequency, amplitude, offset); + if let Some(waveform) = self.command_readers.set_waveform.read() { + self.waveform = waveform; + } + if let Some(phase) = self.command_readers.set_phase.read() { + self.phase = phase / TAU; + } + } + + fn update(&mut self, dt: f64, info: &Info) { + self.frequency.update(dt, info); + self.amplitude.update(dt, info); + self.offset.update(dt, info); + self.phase += dt * self.frequency.value(); + self.phase %= 1.0; + self.value = self.offset.value() + self.amplitude.value() * self.waveform.value(self.phase); + } + + fn value(&self) -> f64 { + self.value + } + + fn finished(&self) -> bool { + self.shared.removed.load(Ordering::SeqCst) + } +} + +/// Describes an oscillation pattern. +#[derive(Debug, Clone, Copy, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub enum Waveform { + /// The value moves back and forth smoothly. + Sine, + /// The value moves back and forth at a constant speed. + Triangle, + /// The value moves gradually in one direction, then abruptly jumps in the other. + Saw, + /// The value jumps back and forth between two values. + Pulse { + /// The ratio between how much time the oscillator spends on one value vs. the other. + /// + /// This should be a number between `0.0` and `1.0`. A value of `0.5` means the oscillator + /// spends an equal amount of time at both values. + width: f64, + }, +} + +impl Waveform { + #[must_use] + fn value(self, phase: f64) -> f64 { + match self { + Waveform::Sine => (phase * TAU).sin(), + Waveform::Triangle => ((phase + 0.75).fract() - 0.5).abs() * 4.0 - 1.0, + Waveform::Saw => (phase + 0.5).fract() * 2.0 - 1.0, + Waveform::Pulse { width } => { + if phase < width { + 1.0 + } else { + -1.0 + } + } + } + } +} + +#[derive(Debug)] +struct LfoShared { + removed: AtomicBool, +} + +impl LfoShared { + fn new() -> Self { + Self { + removed: AtomicBool::new(false), + } + } +} + +command_writers_and_readers! { + set_waveform: Waveform, + set_frequency: ValueChangeCommand, + set_amplitude: ValueChangeCommand, + set_offset: ValueChangeCommand, + set_phase: f64, +} diff --git a/third_party/kira/src/modulator/lfo/builder.rs b/third_party/kira/src/modulator/lfo/builder.rs new file mode 100644 index 00000000..0e1ecacd --- /dev/null +++ b/third_party/kira/src/modulator/lfo/builder.rs @@ -0,0 +1,117 @@ +use std::sync::Arc; + +use crate::{ + Value, + modulator::{Modulator, ModulatorBuilder, ModulatorId}, +}; + +use super::{Lfo, LfoShared, Waveform, command_writers_and_readers, handle::LfoHandle}; + +/// Configures an LFO modulator. +pub struct LfoBuilder { + /// The oscillation pattern. + pub waveform: Waveform, + /// How quickly the value oscillates. + pub frequency: Value, + /// How much the value oscillates. + /// + /// An amplitude of `2.0` means the modulator will reach a maximum + /// value of `2.0` and a minimum value of `-2.0`. + pub amplitude: Value, + /// The constant value the modulator is offset by. + /// + /// An LFO with an offset of `1.0` and an amplitude of `0.5` will reach + /// a maximum value of `1.5` and a minimum value of `0.5`. + pub offset: Value, + /// The phase the LFO should start at (in radians). + /// + /// This determines when in the oscillation the modulator will start. + pub starting_phase: f64, +} + +impl LfoBuilder { + /// Creates a new [`LfoBuilder`] with the default settings. + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Sets the oscillation pattern. + #[must_use = "This method consumes self and returns a modified LfoBuilder, so the return value should be used"] + pub fn waveform(self, waveform: Waveform) -> Self { + Self { waveform, ..self } + } + + /// Sets how quickly the value oscillates. + #[must_use = "This method consumes self and returns a modified LfoBuilder, so the return value should be used"] + pub fn frequency(self, frequency: impl Into>) -> Self { + Self { + frequency: frequency.into(), + ..self + } + } + + /// Sets how much the value oscillates. + /// + /// An amplitude of `2.0` means the modulator will reach a maximum + /// value of `2.0` and a minimum value of `-2.0`. + #[must_use = "This method consumes self and returns a modified LfoBuilder, so the return value should be used"] + pub fn amplitude(self, amplitude: impl Into>) -> Self { + Self { + amplitude: amplitude.into(), + ..self + } + } + + /// Sets a constant value that the modulator is offset by. + /// + /// An LFO with an offset of `1.0` and an amplitude of `0.5` will reach + /// a maximum value of `1.5` and a minimum value of `0.5`. + #[must_use = "This method consumes self and returns a modified LfoBuilder, so the return value should be used"] + pub fn offset(self, offset: impl Into>) -> Self { + Self { + offset: offset.into(), + ..self + } + } + + /// Sets the phase the LFO should start at (in radians). + /// + /// This determines when in the oscillation the modulator will start. + #[must_use = "This method consumes self and returns a modified LfoBuilder, so the return value should be used"] + pub fn starting_phase(self, starting_phase: f64) -> Self { + Self { + starting_phase, + ..self + } + } +} + +impl Default for LfoBuilder { + fn default() -> Self { + Self { + waveform: Waveform::Sine, + frequency: Value::Fixed(2.0), + amplitude: Value::Fixed(1.0), + offset: Value::Fixed(0.0), + starting_phase: 0.0, + } + } +} + +impl ModulatorBuilder for LfoBuilder { + type Handle = LfoHandle; + + fn build(self, id: ModulatorId) -> (Box, Self::Handle) { + let (command_writers, command_readers) = command_writers_and_readers(); + let shared = Arc::new(LfoShared::new()); + ( + Box::new(Lfo::new(&self, command_readers, shared.clone())), + LfoHandle { + id, + command_writers, + shared, + }, + ) + } +} diff --git a/third_party/kira/src/modulator/lfo/handle.rs b/third_party/kira/src/modulator/lfo/handle.rs new file mode 100644 index 00000000..89bf0305 --- /dev/null +++ b/third_party/kira/src/modulator/lfo/handle.rs @@ -0,0 +1,60 @@ +use std::sync::{Arc, atomic::Ordering}; + +use crate::{command::handle_param_setters, modulator::ModulatorId}; + +use super::{CommandWriters, LfoShared, Waveform}; + +/// Controls an LFO modulator. +#[derive(Debug)] +pub struct LfoHandle { + pub(super) id: ModulatorId, + pub(super) command_writers: CommandWriters, + pub(super) shared: Arc, +} + +impl LfoHandle { + /// Returns the unique identifier for the modulator. + #[must_use] + pub fn id(&self) -> ModulatorId { + self.id + } + + /// Sets the oscillation pattern. + pub fn set_waveform(&mut self, waveform: Waveform) { + self.command_writers.set_waveform.write(waveform) + } + + handle_param_setters! { + /// Sets how quickly the value oscillates. + frequency: f64, + + /// Sets how much the value oscillates. + /// + /// An amplitude of `2.0` means the modulator will reach a maximum + /// value of `2.0` and a minimum value of `-2.0`. + amplitude: f64, + + /// Sets a constant value that the modulator is offset by. + /// + /// An LFO with an offset of `1.0` and an amplitude of `0.5` will reach + /// a maximum value of `1.5` and a minimum value of `0.5`. + offset: f64, + } + + /// Sets the phase of the LFO (in radians). + pub fn set_phase(&mut self, phase: f64) { + self.command_writers.set_phase.write(phase) + } +} + +impl Drop for LfoHandle { + fn drop(&mut self) { + self.shared.removed.store(true, Ordering::SeqCst); + } +} + +impl From<&LfoHandle> for ModulatorId { + fn from(value: &LfoHandle) -> Self { + value.id + } +} diff --git a/third_party/kira/src/modulator/lfo/test.rs b/third_party/kira/src/modulator/lfo/test.rs new file mode 100644 index 00000000..4a588fc9 --- /dev/null +++ b/third_party/kira/src/modulator/lfo/test.rs @@ -0,0 +1,56 @@ +use std::f64::consts::FRAC_1_SQRT_2; + +use approx::assert_relative_eq; + +use crate::modulator::lfo::Waveform; + +#[test] +fn sine() { + test_waveform( + Waveform::Sine, + [ + 0.0, + FRAC_1_SQRT_2, + 1.0, + FRAC_1_SQRT_2, + 0.0, + -FRAC_1_SQRT_2, + -1.0, + -FRAC_1_SQRT_2, + ], + ); +} + +#[test] +fn triangle() { + test_waveform( + Waveform::Triangle, + [0.0, 0.5, 1.0, 0.5, 0.0, -0.5, -1.0, -0.5], + ); +} + +#[test] +fn saw() { + test_waveform( + Waveform::Saw, + [0.0, 0.25, 0.5, 0.75, -1.0, -0.75, -0.5, -0.25], + ); +} + +#[test] +fn pulse() { + test_waveform( + Waveform::Pulse { width: 0.25 }, + [1.0, 1.0, -1.0, -1.0, -1.0, -1.0, -1.0, -1.0], + ); + test_waveform( + Waveform::Pulse { width: 0.75 }, + [1.0, 1.0, 1.0, 1.0, 1.0, 1.0, -1.0, -1.0], + ); +} + +fn test_waveform(waveform: Waveform, values: [f64; 8]) { + for (i, value) in values.iter().enumerate() { + assert_relative_eq!(waveform.value(i as f64 / 8.0), *value); + } +} diff --git a/third_party/kira/src/modulator/tweener.rs b/third_party/kira/src/modulator/tweener.rs new file mode 100644 index 00000000..438eab7f --- /dev/null +++ b/third_party/kira/src/modulator/tweener.rs @@ -0,0 +1,134 @@ +//! Smoothly transitions values to other values. + +mod builder; +mod handle; + +#[cfg(test)] +mod test; + +use std::{ + sync::{ + Arc, + atomic::{AtomicBool, Ordering}, + }, + time::Duration, +}; + +pub use builder::*; +pub use handle::*; + +use crate::{ + StartTime, Tween, Tweenable, command_writers_and_readers, + info::{Info, WhenToStart}, +}; + +use super::Modulator; + +struct Tweener { + state: State, + value: f64, + command_readers: CommandReaders, + shared: Arc, +} + +impl Tweener { + #[must_use] + fn new( + initial_value: f64, + command_readers: CommandReaders, + shared: Arc, + ) -> Self { + Self { + state: State::Idle, + value: initial_value, + command_readers, + shared, + } + } + + fn set(&mut self, target: f64, tween: Tween) { + self.state = State::Tweening { + values: (self.value, target), + time: 0.0, + tween, + } + } +} + +impl Modulator for Tweener { + fn on_start_processing(&mut self) { + if let Some((target, tween)) = self.command_readers.set.read() { + self.set(target, tween); + } + } + + fn update(&mut self, dt: f64, info: &Info) { + if let State::Tweening { + values, + time, + tween, + } = &mut self.state + { + let started = match &mut tween.start_time { + StartTime::Immediate => true, + StartTime::Delayed(time_remaining) => { + if time_remaining.is_zero() { + true + } else { + *time_remaining = + time_remaining.saturating_sub(Duration::from_secs_f64(dt)); + false + } + } + StartTime::ClockTime(clock_time) => { + info.when_to_start(*clock_time) == WhenToStart::Now + } + }; + if !started { + return; + } + *time += dt; + if *time >= tween.duration.as_secs_f64() { + self.value = values.1; + self.state = State::Idle; + } else { + self.value = Tweenable::interpolate(values.0, values.1, tween.value(*time)); + } + } + } + + fn value(&self) -> f64 { + self.value + } + + fn finished(&self) -> bool { + self.shared.removed.load(Ordering::SeqCst) + } +} + +enum State { + Idle, + Tweening { + values: (f64, f64), + time: f64, + tween: Tween, + }, +} + +#[derive(Debug)] +struct TweenerShared { + removed: AtomicBool, +} + +impl TweenerShared { + #[must_use] + fn new() -> Self { + Self { + removed: AtomicBool::new(false), + } + } +} + +command_writers_and_readers! { + set: (f64, Tween), +} diff --git a/third_party/kira/src/modulator/tweener/builder.rs b/third_party/kira/src/modulator/tweener/builder.rs new file mode 100644 index 00000000..4d2d6fc4 --- /dev/null +++ b/third_party/kira/src/modulator/tweener/builder.rs @@ -0,0 +1,32 @@ +use std::sync::Arc; + +use crate::modulator::{Modulator, ModulatorBuilder, ModulatorId}; + +use super::{Tweener, TweenerHandle, TweenerShared, command_writers_and_readers}; + +/// Configures a tweener. +pub struct TweenerBuilder { + /// The initial value of the tweener. + pub initial_value: f64, +} + +impl ModulatorBuilder for TweenerBuilder { + type Handle = TweenerHandle; + + fn build(self, id: ModulatorId) -> (Box, Self::Handle) { + let (command_writers, command_readers) = command_writers_and_readers(); + let shared = Arc::new(TweenerShared::new()); + ( + Box::new(Tweener::new( + self.initial_value, + command_readers, + shared.clone(), + )), + TweenerHandle { + id, + command_writers, + shared, + }, + ) + } +} diff --git a/third_party/kira/src/modulator/tweener/handle.rs b/third_party/kira/src/modulator/tweener/handle.rs new file mode 100644 index 00000000..ce80aaa8 --- /dev/null +++ b/third_party/kira/src/modulator/tweener/handle.rs @@ -0,0 +1,38 @@ +use std::sync::{Arc, atomic::Ordering}; + +use crate::{modulator::ModulatorId, tween::Tween}; + +use super::{CommandWriters, TweenerShared}; + +/// Controls a tweener. +#[derive(Debug)] +pub struct TweenerHandle { + pub(super) id: ModulatorId, + pub(super) command_writers: CommandWriters, + pub(super) shared: Arc, +} + +impl TweenerHandle { + /// Returns the unique identifier for the modulator. + pub fn id(&self) -> ModulatorId { + self.id + } + + /// Starts a transition from the current value to a target value with + /// the given tween. + pub fn set(&mut self, target: f64, tween: Tween) { + self.command_writers.set.write((target, tween)) + } +} + +impl From<&TweenerHandle> for ModulatorId { + fn from(handle: &TweenerHandle) -> Self { + handle.id + } +} + +impl Drop for TweenerHandle { + fn drop(&mut self) { + self.shared.removed.store(true, Ordering::SeqCst); + } +} diff --git a/third_party/kira/src/modulator/tweener/test.rs b/third_party/kira/src/modulator/tweener/test.rs new file mode 100644 index 00000000..280d35cc --- /dev/null +++ b/third_party/kira/src/modulator/tweener/test.rs @@ -0,0 +1,149 @@ +use std::time::Duration; + +use atomic_arena::Arena; + +use crate::info::MockInfoBuilder; +use crate::{ + StartTime, Tween, + clock::ClockTime, + modulator::{ModulatorBuilder, ModulatorId, tweener::TweenerBuilder}, +}; + +/// Tests that the basic tweening behavior of a `Tweener` +/// works properly. +#[test] +#[allow(clippy::float_cmp)] +fn tweening() { + let (mut tweener, mut handle) = + TweenerBuilder { initial_value: 0.0 }.build(generate_fake_modulator_id()); + let info = MockInfoBuilder::new().build(); + + // value should not be changing yet + for _ in 0..3 { + tweener.update(1.0, &info); + assert_eq!(tweener.value(), 0.0); + } + + handle.set( + 1.0, + Tween { + duration: Duration::from_secs(2), + ..Default::default() + }, + ); + tweener.on_start_processing(); + + tweener.update(1.0, &info); + assert_eq!(tweener.value(), 0.5); + tweener.update(1.0, &info); + assert_eq!(tweener.value(), 1.0); + tweener.update(1.0, &info); + assert_eq!(tweener.value(), 1.0); +} + +/// Tests that a `Tweener` with a delayed start time waits for +/// that time before it begins tweening. +#[test] +#[allow(clippy::float_cmp)] +fn waits_for_delay() { + let info = MockInfoBuilder::new().build(); + + let (mut tweener, mut handle) = + TweenerBuilder { initial_value: 0.0 }.build(generate_fake_modulator_id()); + handle.set( + 1.0, + Tween { + start_time: StartTime::Delayed(Duration::from_secs(2)), + duration: Duration::from_secs(1), + ..Default::default() + }, + ); + tweener.on_start_processing(); + + // value should not be changing yet + for _ in 0..2 { + assert_eq!(tweener.value(), 0.0); + tweener.update(1.0, &info); + } + + // the tween should start now + tweener.update(1.0, &info); + assert_eq!(tweener.value(), 1.0); +} + +/// Tests that a `Tweener` with a clock time set as +/// the start time waits for that time before it +/// begins tweening. +#[test] +#[allow(clippy::float_cmp)] +fn waits_for_start_time() { + let (mut tweener, mut handle) = + TweenerBuilder { initial_value: 0.0 }.build(generate_fake_modulator_id()); + let mut info_builder = MockInfoBuilder::new(); + let clock_id_1 = info_builder.add_clock(true, 0, 0.0); + let info = info_builder.build(); + + handle.set( + 1.0, + Tween { + start_time: StartTime::ClockTime(ClockTime { + clock: clock_id_1, + ticks: 2, + fraction: 0.0, + }), + duration: Duration::from_secs(1), + ..Default::default() + }, + ); + tweener.on_start_processing(); + + // value should not be changing yet + for _ in 0..3 { + tweener.update(1.0, &info); + assert_eq!(tweener.value(), 0.0); + } + + let info = { + let mut builder = MockInfoBuilder::new(); + builder.add_clock(true, 1, 0.0); + builder.add_clock(true, 0, 0.0); + builder.build() + }; + + // the tween is set to start at tick 2, so it should not + // start yet + for _ in 0..3 { + tweener.update(1.0, &info); + assert_eq!(tweener.value(), 0.0); + } + + let info = { + let mut builder = MockInfoBuilder::new(); + builder.add_clock(true, 1, 0.0); + builder.add_clock(true, 2, 0.0); + builder.build() + }; + + // a different clock reached tick 2, so the tween should not + // start yet + for _ in 0..3 { + tweener.update(1.0, &info); + assert_eq!(tweener.value(), 0.0); + } + + let info = { + let mut builder = MockInfoBuilder::new(); + builder.add_clock(true, 2, 0.0); + builder.add_clock(true, 2, 0.0); + builder.build() + }; + + // the tween should start now + tweener.update(1.0, &info); + assert_eq!(tweener.value(), 1.0); +} + +fn generate_fake_modulator_id() -> ModulatorId { + let arena = Arena::<()>::new(1); + ModulatorId(arena.controller().try_reserve().unwrap()) +} diff --git a/third_party/kira/src/panning.rs b/third_party/kira/src/panning.rs new file mode 100644 index 00000000..203b81bc --- /dev/null +++ b/third_party/kira/src/panning.rs @@ -0,0 +1,134 @@ +use std::ops::{ + Add, AddAssign, Div, DivAssign, Mul, MulAssign, Neg, Rem, RemAssign, Sub, SubAssign, +}; + +use crate::{Value, tween::Tweenable}; + +#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(transparent))] +/// The stereo positioning of a sound. +/// +/// Valid panning values range from `-1.0` to `1.0`. A value of `-1.0` +/// will cause a sound to only be output from the left speaker. A value +/// of `1.0` will cause a sound to only be output from the right speaker. +/// A value of `0.0` will cause a sound to be played at an equal volume +/// from both speakers. +pub struct Panning(pub f32); + +impl Panning { + /// Play the sound from the left speaker only. + pub const LEFT: Self = Self(-1.0); + /// Play the sound from both speakers at the same volume. + pub const CENTER: Self = Self(0.0); + /// Play the sound from the right speaker only. + pub const RIGHT: Self = Self(1.0); +} + +impl Default for Panning { + fn default() -> Self { + Self::CENTER + } +} + +impl Tweenable for Panning { + fn interpolate(a: Self, b: Self, amount: f64) -> Self { + Self(Tweenable::interpolate(a.0, b.0, amount)) + } +} + +impl From for Panning { + fn from(value: f32) -> Self { + Self(value) + } +} + +impl From for Value { + fn from(value: f32) -> Self { + Self::Fixed(Panning(value)) + } +} + +impl From for Value { + fn from(value: Panning) -> Self { + Self::Fixed(value) + } +} + +impl Add for Panning { + type Output = Panning; + + fn add(self, rhs: Panning) -> Self::Output { + Self(self.0 + rhs.0) + } +} + +impl AddAssign for Panning { + fn add_assign(&mut self, rhs: Panning) { + self.0 += rhs.0; + } +} + +impl Sub for Panning { + type Output = Panning; + + fn sub(self, rhs: Panning) -> Self::Output { + Self(self.0 - rhs.0) + } +} + +impl SubAssign for Panning { + fn sub_assign(&mut self, rhs: Panning) { + self.0 -= rhs.0; + } +} + +impl Mul for Panning { + type Output = Panning; + + fn mul(self, rhs: f32) -> Self::Output { + Self(self.0 * rhs) + } +} + +impl MulAssign for Panning { + fn mul_assign(&mut self, rhs: f32) { + self.0 *= rhs; + } +} + +impl Div for Panning { + type Output = Panning; + + fn div(self, rhs: f32) -> Self::Output { + Self(self.0 / rhs) + } +} + +impl DivAssign for Panning { + fn div_assign(&mut self, rhs: f32) { + self.0 /= rhs; + } +} + +impl Neg for Panning { + type Output = Panning; + + fn neg(self) -> Self::Output { + Self(-self.0) + } +} + +impl Rem for Panning { + type Output = Panning; + + fn rem(self, rhs: f32) -> Self::Output { + Self(self.0 % rhs) + } +} + +impl RemAssign for Panning { + fn rem_assign(&mut self, rhs: f32) { + self.0 %= rhs; + } +} diff --git a/third_party/kira/src/parameter.rs b/third_party/kira/src/parameter.rs new file mode 100644 index 00000000..789acb6c --- /dev/null +++ b/third_party/kira/src/parameter.rs @@ -0,0 +1,179 @@ +#[cfg(test)] +mod test; + +use std::time::Duration; + +use crate::{ + StartTime, Tween, Tweenable, Value, + command::{CommandReader, ValueChangeCommand}, + info::{Info, WhenToStart}, +}; + +/// Manages and updates a value that can be smoothly transitioned +/// and linked to modulators. +/// +/// You'll only need to use this if you're creating your own +/// [`Sound`](crate::sound::Sound), [`Effect`](crate::effect::Effect), +/// or [`Modulator`](crate::modulator::Modulator) implementations. If you +/// want to adjust a parameter of something from gameplay code (such as the +/// volume of a sound or the speed of a clock), use the functions on that +/// object's handle. +#[derive(Clone)] +pub struct Parameter { + state: State, + raw_value: T, + previous_raw_value: T, + stagnant: bool, +} + +impl Parameter { + /// Creates a new [`Parameter`] with an initial [`Value`]. + /// + /// The `default_raw_value` is used if the parameter is linked to a modulator + /// that doesn't exist. + #[must_use] + pub fn new(initial_value: Value, default_raw_value: T) -> Self { + let raw_value = match initial_value { + Value::Fixed(value) => value, + Value::FromModulator { .. } => default_raw_value, + Value::FromListenerDistance { .. } => default_raw_value, + }; + Self { + state: State::Idle { + value: initial_value, + }, + raw_value, + previous_raw_value: raw_value, + stagnant: matches!(initial_value, Value::Fixed(_)), + } + } + + /// Returns the current actual value of the parameter. + #[must_use] + pub fn value(&self) -> T { + self.raw_value + } + + /// Returns the previous actual value of the parameter. + #[must_use] + pub fn previous_value(&self) -> T { + self.previous_raw_value + } + + /// Returns the interpolated value between the previous and current + /// actual value of the parameter. + #[must_use] + pub fn interpolated_value(&self, amount: f64) -> T { + T::interpolate(self.previous_raw_value, self.raw_value, amount) + } + + /// Starts a transition from the current value to the target value. + pub fn set(&mut self, target: Value, tween: Tween) { + self.stagnant = false; + self.state = State::Tweening { + start: self.value(), + target, + time: 0.0, + tween, + }; + } + + /// Reads a [`ValueChangeCommand`] from a [`CommandReader`], and if there is one, + /// sets the parameter with the value and tween. + pub fn read_command(&mut self, command_reader: &mut CommandReader>) + where + T: Send, + { + if let Some(ValueChangeCommand { target, tween }) = command_reader.read() { + self.set(target, tween); + } + } + + /// Updates any in-progress transitions and keeps the value up-to-date + /// with any linked modulators. + /// + /// Returns `true` if a transition just finished after this update. + pub fn update(&mut self, dt: f64, info: &Info) -> JustFinishedTween { + self.previous_raw_value = self.raw_value; + if self.stagnant { + return false; + } + let just_finished_tween = self.update_tween(dt, info); + if let Some(raw_value) = self.calculate_new_raw_value(info) { + self.raw_value = raw_value; + } + just_finished_tween + } + + fn update_tween(&mut self, dt: f64, info: &Info) -> JustFinishedTween { + if let State::Tweening { + target, + time, + tween, + .. + } = &mut self.state + { + let started = match &mut tween.start_time { + StartTime::Immediate => true, + StartTime::Delayed(time_remaining) => { + if time_remaining.is_zero() { + true + } else { + *time_remaining = + time_remaining.saturating_sub(Duration::from_secs_f64(dt)); + false + } + } + StartTime::ClockTime(clock_time) => { + info.when_to_start(*clock_time) == WhenToStart::Now + } + }; + if !started { + return false; + } + *time += dt; + if *time >= tween.duration.as_secs_f64() { + if matches!(target, Value::Fixed(_)) { + self.stagnant = true; + } + self.state = State::Idle { value: *target }; + return true; + } + } + false + } + + fn calculate_new_raw_value(&self, info: &Info) -> Option { + match &self.state { + State::Idle { value } => value.raw_value(info), + State::Tweening { + start, + target, + time, + tween, + } => { + if tween.duration.is_zero() { + return None; + } + target + .raw_value(info) + .map(|target| T::interpolate(*start, target, tween.value(*time))) + } + } + } +} + +#[derive(Clone)] +enum State { + Idle { + value: Value, + }, + Tweening { + start: T, + target: Value, + time: f64, + tween: Tween, + }, +} + +type JustFinishedTween = bool; diff --git a/third_party/kira/src/parameter/test.rs b/third_party/kira/src/parameter/test.rs new file mode 100644 index 00000000..2d694cba --- /dev/null +++ b/third_party/kira/src/parameter/test.rs @@ -0,0 +1,168 @@ +use std::time::Duration; + +use crate::{Easing, Mapping, StartTime, Tween, Value, clock::ClockTime, info::MockInfoBuilder}; + +use super::Parameter; + +/// Tests that the basic tweening behavior of a `Parameter` +/// works properly. +#[test] +#[allow(clippy::float_cmp)] +fn tweening() { + let mut parameter = Parameter::new(Value::Fixed(0.0), 0.0); + let info = MockInfoBuilder::new().build(); + + // value should not be changing yet + for _ in 0..3 { + assert_eq!(parameter.value(), 0.0); + assert!(!parameter.update(1.0, &info)); + } + + parameter.set( + Value::Fixed(1.0), + Tween { + duration: Duration::from_secs(2), + ..Default::default() + }, + ); + + assert!(!parameter.update(1.0, &info)); + assert_eq!(parameter.value(), 0.5); + assert!(parameter.update(1.0, &info)); + assert_eq!(parameter.value(), 1.0); + assert!(!parameter.update(1.0, &info)); + assert_eq!(parameter.value(), 1.0); +} + +/// Tests that a `Parameter` with a delayed start time waits for +/// that time before it begins tweening. +#[test] +#[allow(clippy::float_cmp)] +fn waits_for_delay() { + let info = MockInfoBuilder::new().build(); + + let mut parameter = Parameter::new(Value::Fixed(0.0), 0.0); + parameter.set( + Value::Fixed(1.0), + Tween { + start_time: StartTime::Delayed(Duration::from_secs(2)), + duration: Duration::from_secs(1), + ..Default::default() + }, + ); + + // value should not be changing yet + for _ in 0..2 { + assert_eq!(parameter.value(), 0.0); + assert!(!parameter.update(1.0, &info)); + } + + // the tween should start now + assert!(parameter.update(1.0, &info)); + assert_eq!(parameter.value(), 1.0); +} + +/// Tests that a `Parameter` with a clock time set as +/// the start time waits for that time before it +/// begins tweening. +#[test] +#[allow(clippy::float_cmp)] +fn waits_for_start_time() { + let mut info_builder = MockInfoBuilder::new(); + let clock_id_1 = info_builder.add_clock(true, 0, 0.0); + let info = info_builder.build(); + + let mut parameter = Parameter::new(Value::Fixed(0.0), 0.0); + parameter.set( + Value::Fixed(1.0), + Tween { + start_time: StartTime::ClockTime(ClockTime { + clock: clock_id_1, + ticks: 2, + fraction: 0.0, + }), + duration: Duration::from_secs(1), + ..Default::default() + }, + ); + + // value should not be changing yet + for _ in 0..3 { + assert_eq!(parameter.value(), 0.0); + assert!(!parameter.update(1.0, &info)); + } + + let info = { + let mut builder = MockInfoBuilder::new(); + builder.add_clock(true, 1, 0.0); + builder.add_clock(true, 0, 0.0); + builder.build() + }; + + // the tween is set to start at tick 2, so it should not + // start yet + for _ in 0..3 { + assert_eq!(parameter.value(), 0.0); + assert!(!parameter.update(1.0, &info)); + } + + let info = { + let mut builder = MockInfoBuilder::new(); + builder.add_clock(true, 1, 0.0); + builder.add_clock(true, 2, 0.0); + builder.build() + }; + + // a different clock reached tick 2, so the tween should not + // start yet + for _ in 0..3 { + assert_eq!(parameter.value(), 0.0); + assert!(!parameter.update(1.0, &info)); + } + + let info = { + let mut builder = MockInfoBuilder::new(); + builder.add_clock(true, 2, 0.0); + builder.add_clock(true, 2, 0.0); + builder.build() + }; + + // the tween should start now + assert!(parameter.update(1.0, &info)); + assert_eq!(parameter.value(), 1.0); +} + +/// Tests that a parameter can smoothly tween from a fixed value +/// to the value of a modulator (while the modulator value is changing). +#[test] +#[allow(clippy::float_cmp)] +fn tweens_to_modulator_values() { + let mut info_builder = MockInfoBuilder::new(); + let modulator_id = info_builder.add_modulator(0.0); + let mut parameter = Parameter::new(Value::Fixed(0.0), 0.0); + parameter.set( + Value::FromModulator { + id: modulator_id, + mapping: Mapping { + input_range: (0.0, 1.0), + output_range: (0.0, 1.0), + easing: Easing::Linear, + }, + }, + Tween { + duration: Duration::from_secs(1), + ..Default::default() + }, + ); + + for i in 1..=4 { + let time = i as f64 / 4.0; + let info = { + let mut builder = MockInfoBuilder::new(); + builder.add_modulator(time); + builder.build() + }; + parameter.update(0.25, &info); + assert_eq!(parameter.value(), time * time); + } +} diff --git a/third_party/kira/src/playback_rate.rs b/third_party/kira/src/playback_rate.rs new file mode 100644 index 00000000..01a219cb --- /dev/null +++ b/third_party/kira/src/playback_rate.rs @@ -0,0 +1,122 @@ +use std::ops::{ + Add, AddAssign, Div, DivAssign, Mul, MulAssign, Neg, Rem, RemAssign, Sub, SubAssign, +}; + +use crate::{Value, tween::Tweenable}; + +#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(transparent))] +/// How quickly a sound should be played, where `1.0` is the default +/// playback rate. +/// +/// Changing the playback rate of a sound will affect both the speed and pitch. +pub struct PlaybackRate(pub f64); + +impl Default for PlaybackRate { + fn default() -> Self { + Self(1.0) + } +} + +impl Tweenable for PlaybackRate { + fn interpolate(a: Self, b: Self, amount: f64) -> Self { + Self(Tweenable::interpolate(a.0, b.0, amount)) + } +} + +impl From for PlaybackRate { + fn from(value: f64) -> Self { + Self(value) + } +} + +impl From for Value { + fn from(value: f64) -> Self { + Self::Fixed(PlaybackRate(value)) + } +} + +impl From for Value { + fn from(value: PlaybackRate) -> Self { + Self::Fixed(value) + } +} + +impl Add for PlaybackRate { + type Output = PlaybackRate; + + fn add(self, rhs: PlaybackRate) -> Self::Output { + Self(self.0 + rhs.0) + } +} + +impl AddAssign for PlaybackRate { + fn add_assign(&mut self, rhs: PlaybackRate) { + self.0 += rhs.0; + } +} + +impl Sub for PlaybackRate { + type Output = PlaybackRate; + + fn sub(self, rhs: PlaybackRate) -> Self::Output { + Self(self.0 - rhs.0) + } +} + +impl SubAssign for PlaybackRate { + fn sub_assign(&mut self, rhs: PlaybackRate) { + self.0 -= rhs.0; + } +} + +impl Mul for PlaybackRate { + type Output = PlaybackRate; + + fn mul(self, rhs: f64) -> Self::Output { + Self(self.0 * rhs) + } +} + +impl MulAssign for PlaybackRate { + fn mul_assign(&mut self, rhs: f64) { + self.0 *= rhs; + } +} + +impl Div for PlaybackRate { + type Output = PlaybackRate; + + fn div(self, rhs: f64) -> Self::Output { + Self(self.0 / rhs) + } +} + +impl DivAssign for PlaybackRate { + fn div_assign(&mut self, rhs: f64) { + self.0 /= rhs; + } +} + +impl Neg for PlaybackRate { + type Output = PlaybackRate; + + fn neg(self) -> Self::Output { + Self(-self.0) + } +} + +impl Rem for PlaybackRate { + type Output = PlaybackRate; + + fn rem(self, rhs: f64) -> Self::Output { + Self(self.0 % rhs) + } +} + +impl RemAssign for PlaybackRate { + fn rem_assign(&mut self, rhs: f64) { + self.0 %= rhs; + } +} diff --git a/third_party/kira/src/playback_state_manager.rs b/third_party/kira/src/playback_state_manager.rs new file mode 100644 index 00000000..465b37b9 --- /dev/null +++ b/third_party/kira/src/playback_state_manager.rs @@ -0,0 +1,138 @@ +use crate::{ + Decibels, StartTime, Tween, Value, info::Info, parameter::Parameter, sound::PlaybackState, +}; + +pub(crate) struct PlaybackStateManager { + state: State, + volume_fade: Parameter, +} + +impl PlaybackStateManager { + pub fn new(fade_in_tween: Option) -> Self { + Self { + state: State::Playing, + volume_fade: fade_in_tween + .map(|tween| { + let mut parameter = + Parameter::new(Value::Fixed(Decibels::SILENCE), Decibels::SILENCE); + parameter.set(Value::Fixed(Decibels::IDENTITY), tween); + parameter + }) + .unwrap_or_else(|| { + Parameter::new(Value::Fixed(Decibels::IDENTITY), Decibels::IDENTITY) + }), + } + } + + pub fn interpolated_fade_volume(&self, amount: f64) -> Decibels { + self.volume_fade.interpolated_value(amount) + } + + pub fn playback_state(&self) -> PlaybackState { + match self.state { + State::Playing => PlaybackState::Playing, + State::Pausing => PlaybackState::Pausing, + State::Paused => PlaybackState::Paused, + State::WaitingToResume { .. } => PlaybackState::WaitingToResume, + State::Resuming => PlaybackState::Resuming, + State::Stopping => PlaybackState::Stopping, + State::Stopped => PlaybackState::Stopped, + } + } + + pub fn pause(&mut self, fade_out_tween: Tween) { + if let State::Stopped = &self.state { + return; + } + self.state = State::Pausing; + self.volume_fade + .set(Value::Fixed(Decibels::SILENCE), fade_out_tween); + } + + pub fn resume(&mut self, start_time: StartTime, fade_in_tween: Tween) { + if let State::Stopped = &self.state { + return; + } + if let StartTime::Immediate = start_time { + self.state = State::Resuming; + self.volume_fade + .set(Value::Fixed(Decibels::IDENTITY), fade_in_tween); + } else { + self.state = State::WaitingToResume { + start_time, + fade_in_tween, + }; + } + } + + pub fn stop(&mut self, fade_out_tween: Tween) { + if let State::Stopped = &self.state { + return; + } + self.state = State::Stopping; + self.volume_fade + .set(Value::Fixed(Decibels::SILENCE), fade_out_tween); + } + + pub fn mark_as_stopped(&mut self) { + self.state = State::Stopped; + } + + pub fn update(&mut self, dt: f64, info: &Info) -> ChangedPlaybackState { + let finished = self.volume_fade.update(dt, info); + match &mut self.state { + State::Playing => {} + State::Pausing => { + if finished { + self.state = State::Paused; + return true; + } + } + State::Paused => {} + State::WaitingToResume { + start_time, + fade_in_tween, + } => { + let will_never_start = start_time.update(dt, info); + if will_never_start { + self.state = State::Stopped; + return true; + } + if *start_time == StartTime::Immediate { + let fade_in_tween = *fade_in_tween; + self.resume(StartTime::Immediate, fade_in_tween); + return true; + } + } + State::Resuming => { + if finished { + self.state = State::Playing; + return true; + } + } + State::Stopping => { + if finished { + self.state = State::Stopped; + return true; + } + } + State::Stopped => {} + } + false + } +} + +pub type ChangedPlaybackState = bool; + +enum State { + Playing, + Pausing, + Paused, + WaitingToResume { + start_time: StartTime, + fade_in_tween: Tween, + }, + Resuming, + Stopping, + Stopped, +} diff --git a/third_party/kira/src/semitones.rs b/third_party/kira/src/semitones.rs new file mode 100644 index 00000000..b195271f --- /dev/null +++ b/third_party/kira/src/semitones.rs @@ -0,0 +1,135 @@ +use std::ops::{ + Add, AddAssign, Div, DivAssign, Mul, MulAssign, Neg, Rem, RemAssign, Sub, SubAssign, +}; + +use crate::{PlaybackRate, Value, tween::Tweenable}; + +#[derive(Debug, Clone, Copy, PartialEq, PartialOrd, Default)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(transparent))] +/// A change in pitch in semitones. +/// +/// This can be used where [`PlaybackRate`](crate::PlaybackRate)s are expected to control +/// the pitch of a sound. +pub struct Semitones(pub f64); + +impl Tweenable for Semitones { + fn interpolate(a: Self, b: Self, amount: f64) -> Self { + Self(Tweenable::interpolate(a.0, b.0, amount)) + } +} + +impl From for Semitones { + fn from(value: f64) -> Self { + Self(value) + } +} + +impl From for PlaybackRate { + fn from(value: Semitones) -> Self { + PlaybackRate(2.0f64.powf(value.0 / 12.0)) + } +} + +impl From for Value { + fn from(value: Semitones) -> Self { + Self::Fixed(value.into()) + } +} + +impl Add for Semitones { + type Output = Semitones; + + fn add(self, rhs: Semitones) -> Self::Output { + Self(self.0 + rhs.0) + } +} + +impl AddAssign for Semitones { + fn add_assign(&mut self, rhs: Semitones) { + self.0 += rhs.0; + } +} + +impl Sub for Semitones { + type Output = Semitones; + + fn sub(self, rhs: Semitones) -> Self::Output { + Self(self.0 - rhs.0) + } +} + +impl SubAssign for Semitones { + fn sub_assign(&mut self, rhs: Semitones) { + self.0 -= rhs.0; + } +} + +impl Mul for Semitones { + type Output = Semitones; + + fn mul(self, rhs: f64) -> Self::Output { + Self(self.0 * rhs) + } +} + +impl MulAssign for Semitones { + fn mul_assign(&mut self, rhs: f64) { + self.0 *= rhs; + } +} + +impl Div for Semitones { + type Output = Semitones; + + fn div(self, rhs: f64) -> Self::Output { + Self(self.0 / rhs) + } +} + +impl DivAssign for Semitones { + fn div_assign(&mut self, rhs: f64) { + self.0 /= rhs; + } +} + +impl Neg for Semitones { + type Output = Semitones; + + fn neg(self) -> Self::Output { + Self(-self.0) + } +} + +impl Rem for Semitones { + type Output = Semitones; + + fn rem(self, rhs: f64) -> Self::Output { + Self(self.0 % rhs) + } +} + +impl RemAssign for Semitones { + fn rem_assign(&mut self, rhs: f64) { + self.0 %= rhs; + } +} + +#[cfg(test)] +#[test] +#[allow(clippy::float_cmp)] +fn test() { + /// A table of semitone differences to pitch factors. + /// Values calculated from http://www.sengpielaudio.com/calculator-centsratio.htm + const TEST_CALCULATIONS: [(Semitones, PlaybackRate); 5] = [ + (Semitones(0.0), PlaybackRate(1.0)), + (Semitones(1.0), PlaybackRate(1.059463)), + (Semitones(2.0), PlaybackRate(1.122462)), + (Semitones(-1.0), PlaybackRate(0.943874)), + (Semitones(-2.0), PlaybackRate(0.890899)), + ]; + + for (semitones, playback_rate) in TEST_CALCULATIONS { + assert!((PlaybackRate::from(semitones).0 - playback_rate.0).abs() < 0.00001); + } +} diff --git a/third_party/kira/src/sound.rs b/third_party/kira/src/sound.rs new file mode 100644 index 00000000..d212d2a0 --- /dev/null +++ b/third_party/kira/src/sound.rs @@ -0,0 +1,204 @@ +/*! +Sources of audio. + +Any type that implements [`SoundData`] can be played using +[`AudioManager::play`](crate::AudioManager::play). Kira comes with two +[`SoundData`] implementations: + +- [`StaticSoundData`](static_sound::StaticSoundData), which loads an entire chunk of audio + into memory. This is more appropriate for short sounds, sounds you want to play multiple times, + or sounds where consistent start times are important. +- [`StreamingSoundData`](streaming::StreamingSoundData), which streams audio from a file or cursor + (only available on desktop platforms). This is more appropriate for long sounds that you only + play once at a time, like background music. Streaming sounds use less memory than static sounds. + +These two sound types should cover most use cases, but if you need something else, you can +create your own types that implement the [`SoundData`] and [`Sound`] traits. +*/ + +#[cfg(feature = "symphonia")] +mod error; +mod playback_position; +pub mod static_sound; +#[cfg(not(target_arch = "wasm32"))] +pub mod streaming; +#[cfg(feature = "symphonia")] +mod symphonia; +mod transport; + +use std::ops::{Range, RangeFrom, RangeFull, RangeTo}; + +#[cfg(feature = "symphonia")] +pub use error::*; +pub use playback_position::*; + +use crate::{frame::Frame, info::Info}; + +/// A source of audio that is loaded, but not yet playing. +pub trait SoundData { + /// Errors that can occur when starting the sound. + type Error; + + /// The type that can be used to control the sound once + /// it has started. + type Handle; + + /// Converts the loaded sound into a live, playing sound + /// and a handle to control it. + /// + /// The [`Sound`] implementation will be sent to the audio renderer + /// for playback, and the handle will be returned to the user by + /// [`AudioManager::play`](crate::AudioManager::play). + #[allow(clippy::type_complexity)] + fn into_sound(self) -> Result<(Box, Self::Handle), Self::Error>; +} + +/// An actively playing sound. +/// +/// For performance reasons, the methods of this trait should not allocate +/// or deallocate memory. +#[allow(unused_variables)] +pub trait Sound: Send { + /// Called whenever a new batch of audio samples is requested by the backend. + /// + /// This is a good place to put code that needs to run fairly frequently, + /// but not for every single audio sample. + fn on_start_processing(&mut self) {} + + /// Produces the next [`Frame`]s of audio. This should overwrite + /// the entire `out` slice with new audio. + /// + /// `dt` is the time between each frame (in seconds). + fn process(&mut self, out: &mut [Frame], dt: f64, info: &Info); + + /// Processes a single [`Frame`]. Mostly useful for testing. + /// + /// `dt` is the time elapsed since the previous frame (in seconds). + fn process_one(&mut self, dt: f64, info: &Info) -> Frame { + let mut out = [Frame::ZERO]; + self.process(&mut out, dt, info); + out[0] + } + + /// Returns `true` if the sound is finished and can be unloaded. + /// + /// For finite sounds, this will typically be when playback has reached the + /// end of the sound. For infinite sounds, this will typically be when the + /// handle for the sound is dropped. + #[must_use] + fn finished(&self) -> bool; +} + +/// The playback state of a sound. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub enum PlaybackState { + /// The sound is playing normally. + Playing, + /// The sound is fading out, and when the fade-out + /// is finished, playback will pause. + Pausing, + /// Playback is paused. + Paused, + /// The sound is paused, but is schedule to resume in the future. + WaitingToResume, + /// The sound is fading back in after being previously paused. + Resuming, + /// The sound is fading out, and when the fade-out + /// is finished, playback will stop. + Stopping, + /// The sound has stopped and can no longer be resumed. + Stopped, +} + +impl PlaybackState { + /// Whether the sound is advancing and outputting audio given + /// its current playback state. + pub fn is_advancing(self) -> bool { + match self { + PlaybackState::Playing => true, + PlaybackState::Pausing => true, + PlaybackState::Paused => false, + PlaybackState::WaitingToResume => false, + PlaybackState::Resuming => true, + PlaybackState::Stopping => true, + PlaybackState::Stopped => false, + } + } +} + +/// A portion of audio. +#[derive(Debug, Clone, Copy, PartialEq, Default)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct Region { + /// The starting time of the region (in seconds). + pub start: PlaybackPosition, + /// The (exclusive) ending time of the region. + pub end: EndPosition, +} + +impl> From> for Region { + fn from(range: RangeFrom) -> Self { + Self { + start: range.start.into(), + end: EndPosition::EndOfAudio, + } + } +} + +impl> From> for Region { + fn from(range: Range) -> Self { + Self { + start: range.start.into(), + end: EndPosition::Custom(range.end.into()), + } + } +} + +impl> From> for Region { + fn from(range: RangeTo) -> Self { + Self { + start: PlaybackPosition::Samples(0), + end: EndPosition::Custom(range.end.into()), + } + } +} + +impl From for Region { + fn from(_: RangeFull) -> Self { + Self { + start: PlaybackPosition::Samples(0), + end: EndPosition::EndOfAudio, + } + } +} + +/// A trait for types that can be converted into an `Option`. +pub trait IntoOptionalRegion { + /// Converts the type into an `Option`. + #[must_use] + fn into_optional_region(self) -> Option; +} + +impl> IntoOptionalRegion for T { + fn into_optional_region(self) -> Option { + Some(self.into()) + } +} + +impl IntoOptionalRegion for Option { + fn into_optional_region(self) -> Option { + self + } +} + +/// The ending time of a region of audio. +#[derive(Debug, Clone, Copy, PartialEq, Default)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub enum EndPosition { + /// The end of the audio data. + #[default] + EndOfAudio, + /// A user-defined time. + Custom(PlaybackPosition), +} diff --git a/third_party/kira/src/sound/error.rs b/third_party/kira/src/sound/error.rs new file mode 100644 index 00000000..9185293a --- /dev/null +++ b/third_party/kira/src/sound/error.rs @@ -0,0 +1,63 @@ +use std::fmt::Display; + +/// Errors that can occur when loading or streaming an audio file. +#[derive(Debug)] +#[cfg_attr(docsrs, doc(cfg(feature = "symphonia")))] +pub enum FromFileError { + /// Could not determine the default audio track in the file. + NoDefaultTrack, + /// Could not determine the sample rate of the audio. + UnknownSampleRate, + /// Could not determine the duration of the audio. + UnknownDuration, + /// The audio uses an unsupported channel configuration. Only + /// mono and stereo audio is supported. + UnsupportedChannelConfiguration, + /// An error occurred while reading the file from the filesystem. + IoError(std::io::Error), + /// An error occurred when parsing the file. + SymphoniaError(symphonia::core::errors::Error), +} + +impl Display for FromFileError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + FromFileError::NoDefaultTrack => { + f.write_str("Could not determine the default audio track") + } + FromFileError::UnknownSampleRate => { + f.write_str("Could not detect the sample rate of the audio") + } + FromFileError::UnknownDuration => { + f.write_str("Could not detect the duration of the audio") + } + FromFileError::UnsupportedChannelConfiguration => { + f.write_str("Only mono and stereo audio is supported") + } + FromFileError::IoError(error) => error.fmt(f), + FromFileError::SymphoniaError(error) => error.fmt(f), + } + } +} + +impl std::error::Error for FromFileError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + FromFileError::IoError(error) => Some(error), + FromFileError::SymphoniaError(error) => Some(error), + _ => None, + } + } +} + +impl From for FromFileError { + fn from(v: std::io::Error) -> Self { + Self::IoError(v) + } +} + +impl From for FromFileError { + fn from(v: symphonia::core::errors::Error) -> Self { + Self::SymphoniaError(v) + } +} diff --git a/third_party/kira/src/sound/playback_position.rs b/third_party/kira/src/sound/playback_position.rs new file mode 100644 index 00000000..2835e2fe --- /dev/null +++ b/third_party/kira/src/sound/playback_position.rs @@ -0,0 +1,34 @@ +#[cfg(test)] +mod test; + +/// A point in time in a piece of audio. +#[derive(Debug, Clone, Copy, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub enum PlaybackPosition { + /// The time in seconds. + Seconds(f64), + /// The time in samples (individual audio data points). + Samples(usize), +} + +impl PlaybackPosition { + #[must_use] + pub(crate) fn into_samples(self, sample_rate: u32) -> usize { + match self { + PlaybackPosition::Seconds(seconds) => (seconds * sample_rate as f64).round() as usize, + PlaybackPosition::Samples(samples) => samples, + } + } +} + +impl From for PlaybackPosition { + fn from(v: f64) -> Self { + Self::Seconds(v) + } +} + +impl Default for PlaybackPosition { + fn default() -> Self { + Self::Seconds(0.0) + } +} diff --git a/third_party/kira/src/sound/playback_position/test.rs b/third_party/kira/src/sound/playback_position/test.rs new file mode 100644 index 00000000..20176d3e --- /dev/null +++ b/third_party/kira/src/sound/playback_position/test.rs @@ -0,0 +1,13 @@ +use super::PlaybackPosition; + +#[test] +fn into_samples() { + const SAMPLE_RATE: u32 = 44100; + const SECONDS_TO_SAMPLES: &[(f64, usize)] = &[(8.786054, 387465), (0.061519, 2713)]; + for (seconds, samples) in SECONDS_TO_SAMPLES { + assert_eq!( + PlaybackPosition::Seconds(*seconds).into_samples(SAMPLE_RATE), + *samples + ); + } +} diff --git a/third_party/kira/src/sound/static_sound.rs b/third_party/kira/src/sound/static_sound.rs new file mode 100644 index 00000000..c068714e --- /dev/null +++ b/third_party/kira/src/sound/static_sound.rs @@ -0,0 +1,49 @@ +/*! +Playable chunks of audio that are loaded into memory all at once. + +To play a static sound, pass a [`StaticSoundData`] to +[`AudioManager::play`](crate::AudioManager::play). + +```no_run +use kira::{ + AudioManager, AudioManagerSettings, DefaultBackend, + sound::static_sound::{StaticSoundData, StaticSoundSettings}, +}; + +let mut manager = AudioManager::::new(AudioManagerSettings::default())?; +let sound_data = StaticSoundData::from_file("sound.ogg")?; +manager.play(sound_data)?; +# Result::<(), Box>::Ok(()) +``` + +Compared to streaming sounds, static sounds have lower CPU usage and shorter delays +when starting and seeking, but they use a lot more memory. +*/ + +mod data; +mod handle; +mod settings; +mod sound; + +pub use data::*; +pub use handle::*; +pub use settings::*; + +use crate::{ + Decibels, Panning, PlaybackRate, StartTime, command::ValueChangeCommand, + command_writers_and_readers, tween::Tween, +}; + +use super::Region; + +command_writers_and_readers! { + set_volume: ValueChangeCommand, + set_playback_rate: ValueChangeCommand, + set_panning: ValueChangeCommand, + set_loop_region: Option, + pause: Tween, + resume: (StartTime, Tween), + stop: Tween, + seek_by: f64, + seek_to: f64, +} diff --git a/third_party/kira/src/sound/static_sound/data.rs b/third_party/kira/src/sound/static_sound/data.rs new file mode 100644 index 00000000..f73e5ee3 --- /dev/null +++ b/third_party/kira/src/sound/static_sound/data.rs @@ -0,0 +1,442 @@ +#[cfg(feature = "symphonia")] +mod from_file; + +#[cfg(test)] +mod test; + +use std::{ + fmt::{Debug, Formatter}, + sync::Arc, + time::Duration, +}; + +use crate::{ + Decibels, Panning, PlaybackRate, StartTime, Tween, Value, + frame::Frame, + sound::{EndPosition, IntoOptionalRegion, PlaybackPosition, Region, Sound, SoundData}, +}; + +use super::{ + StaticSoundSettings, command_writers_and_readers, handle::StaticSoundHandle, sound::StaticSound, +}; + +/// A piece of audio loaded into memory all at once. +/// +/// These can be cheaply cloned, as the audio data is shared +/// among all clones. +#[derive(Clone, PartialEq)] +pub struct StaticSoundData { + /// The sample rate of the audio (in Hz). + pub sample_rate: u32, + /// The raw samples that make up the audio. + pub frames: Arc<[Frame]>, + /// Settings for the sound. + pub settings: StaticSoundSettings, + /** + The portion of the sound this [`StaticSoundData`] represents. + + Note that the [`StaticSoundData`] holds the entire piece of audio + it was originally given regardless of the value of `slice`, but + [`StaticSoundData::num_frames`], [`StaticSoundData::duration`], + and [`StaticSoundData::frame_at_index`] will all behave as if + this [`StaticSoundData`] only contained the specified portion of + audio. + */ + pub slice: Option<(usize, usize)>, +} + +impl StaticSoundData { + /** + Sets when the sound should start playing. + + This returns a cheap clone of the [`StaticSoundData`] with the modified start time. + + # Examples + + Configuring a sound to start 4 ticks after a clock's current time: + + ```no_run + use kira::{ + AudioManager, AudioManagerSettings, DefaultBackend, + sound::static_sound::{StaticSoundData, StaticSoundSettings}, + clock::ClockSpeed, + }; + + let mut manager = AudioManager::::new(AudioManagerSettings::default())?; + let clock_handle = manager.add_clock(ClockSpeed::TicksPerMinute(120.0))?; + let sound = StaticSoundData::from_file("sound.ogg")? + .start_time(clock_handle.time() + 4); + # Result::<(), Box>::Ok(()) + ``` + */ + #[must_use = "This method returns a modified StaticSoundData and does not mutate the original value"] + pub fn start_time(&self, start_time: impl Into) -> Self { + let mut new = self.clone(); + new.settings.start_time = start_time.into(); + new + } + + /// Sets where in the sound playback should start. + /// + /// This returns a cheap clone of the [`StaticSoundData`] with the modified start position. + #[must_use = "This method returns a modified StaticSoundData and does not mutate the original value"] + pub fn start_position(&self, start_position: impl Into) -> Self { + let mut new = self.clone(); + new.settings.start_position = start_position.into(); + new + } + + /// Sets whether the sound should be played in reverse. + /// + /// This returns a cheap clone of the [`StaticSoundData`] with the modified setting. + #[must_use = "This method returns a modified StaticSoundData and does not mutate the original value"] + pub fn reverse(&self, reverse: bool) -> Self { + let mut new = self.clone(); + new.settings.reverse = reverse; + new + } + + /** + Sets the portion of the sound that should be looped. + + This returns a cheap clone of the [`StaticSoundData`] with the modified loop region. + + # Examples + + Configure a sound to loop the portion from 3 seconds in to the end: + + ``` + # use kira::sound::static_sound::StaticSoundSettings; + let settings = StaticSoundSettings::new().loop_region(3.0..); + ``` + + Configure a sound to loop the portion from 2 to 4 seconds: + + ``` + # use kira::sound::static_sound::StaticSoundSettings; + let settings = StaticSoundSettings::new().loop_region(2.0..4.0); + ``` + */ + #[must_use = "This method returns a modified StaticSoundData and does not mutate the original value"] + pub fn loop_region(&self, loop_region: impl IntoOptionalRegion) -> Self { + let mut new = self.clone(); + new.settings.loop_region = loop_region.into_optional_region(); + new + } + + /** + Sets the volume of the sound. + + # Examples + + Set the volume to a fixed value: + + ```no_run + # use kira::sound::static_sound::StaticSoundData; + let sound = StaticSoundData::from_file("sound.ogg")?.volume(-6.0); + # Result::<(), Box>::Ok(()) + ``` + + Link the volume to a modulator: + + ```no_run + use kira::{ + AudioManager, AudioManagerSettings, DefaultBackend, + modulator::tweener::TweenerBuilder, + sound::static_sound::StaticSoundData, + Value, Mapping, Easing, + Decibels, + }; + + let mut manager = AudioManager::::new(AudioManagerSettings::default())?; + let tweener = manager.add_modulator(TweenerBuilder { + initial_value: 0.5, + })?; + let sound = StaticSoundData::from_file("sound.ogg")?.volume(Value::FromModulator { + id: tweener.id(), + mapping: Mapping { + input_range: (0.0, 1.0), + output_range: (Decibels::SILENCE, Decibels::IDENTITY), + easing: Easing::Linear, + }, + }); + # Result::<(), Box>::Ok(()) + ``` + */ + #[must_use = "This method returns a modified StaticSoundData and does not mutate the original value"] + pub fn volume(&self, volume: impl Into>) -> Self { + let mut new = self.clone(); + new.settings.volume = volume.into(); + new + } + + /** + Sets the playback rate of the sound. + + Changing the playback rate will change both the speed + and the pitch of the sound. + + # Examples + + Set the playback rate as a factor: + + ```no_run + # use kira::sound::static_sound::StaticSoundData; + let sound = StaticSoundData::from_file("sound.ogg")?.playback_rate(0.5); + # Result::<(), Box>::Ok(()) + ``` + + Set the playback rate as a change in semitones: + + ```no_run + # use kira::sound::static_sound::StaticSoundData; + use kira::Semitones; + let sound = StaticSoundData::from_file("sound.ogg")?.playback_rate(Semitones(-2.0)); + # Result::<(), Box>::Ok(()) + ``` + + Link the playback rate to a modulator: + + ```no_run + use kira::{ + AudioManager, AudioManagerSettings, DefaultBackend, + modulator::tweener::TweenerBuilder, + sound::static_sound::StaticSoundData, + Value, Easing, Mapping, + PlaybackRate, + }; + + let mut manager = AudioManager::::new(AudioManagerSettings::default())?; + let tweener = manager.add_modulator(TweenerBuilder { + initial_value: 0.5, + })?; + let sound = StaticSoundData::from_file("sound.ogg")?.playback_rate(Value::FromModulator { + id: tweener.id(), + mapping: Mapping { + input_range: (0.0, 1.0), + output_range: (PlaybackRate(0.0), PlaybackRate(1.0)), + easing: Easing::Linear, + }, + }); + # Result::<(), Box>::Ok(()) + ``` + */ + #[must_use = "This method returns a modified StaticSoundData and does not mutate the original value"] + pub fn playback_rate(&self, playback_rate: impl Into>) -> Self { + let mut new = self.clone(); + new.settings.playback_rate = playback_rate.into(); + new + } + + /** + Sets the panning of the sound, where 0 is hard left + and 1 is hard right. + + # Examples + + Set the panning to a fixed value: + + ``` no_run + # use kira::sound::static_sound::StaticSoundData; + let sound = StaticSoundData::from_file("sound.ogg")?.panning(-0.5); + # Result::<(), Box>::Ok(()) + ``` + + Link the panning to a modulator: + + ```no_run + use kira::{ + AudioManager, AudioManagerSettings, DefaultBackend, + modulator::tweener::TweenerBuilder, + sound::static_sound::StaticSoundData, + Value, Easing, Mapping, + Panning, + }; + + let mut manager = AudioManager::::new(AudioManagerSettings::default())?; + let tweener = manager.add_modulator(TweenerBuilder { + initial_value: -0.5, + })?; + let sound = StaticSoundData::from_file("sound.ogg")?.panning(Value::FromModulator { + id: tweener.id(), + mapping: Mapping { + input_range: (-1.0, 1.0), + output_range: (Panning::LEFT, Panning::RIGHT), + easing: Easing::Linear, + }, + }); + # Result::<(), Box>::Ok(()) + ``` + */ + #[must_use = "This method returns a modified StaticSoundData and does not mutate the original value"] + pub fn panning(&self, panning: impl Into>) -> Self { + let mut new = self.clone(); + new.settings.panning = panning.into(); + new + } + + /// Sets the tween used to fade in the sound from silence. + /// + /// This returns a cheap clone of the [`StaticSoundData`] with the modified fade in tween. + #[must_use = "This method returns a modified StaticSoundData and does not mutate the original value"] + pub fn fade_in_tween(&self, fade_in_tween: impl Into>) -> Self { + let mut new = self.clone(); + new.settings.fade_in_tween = fade_in_tween.into(); + new + } + + /// Returns a cheap clone of the `StaticSoundData` with the specified settings. + #[must_use = "This method returns a modified StaticSoundData and does not mutate the original value"] + pub fn with_settings(&self, settings: StaticSoundSettings) -> Self { + Self { + settings, + ..self.clone() + } + } + + /// Returns the number of frames in the [`StaticSoundData`]. + /// + /// If [`StaticSoundData::slice`] is `Some`, this will be the number + /// of frames in the slice. + #[must_use] + pub fn num_frames(&self) -> usize { + num_frames(&self.frames, self.slice) + } + + /// Returns the duration of the audio. + /// + /// If [`StaticSoundData::slice`] is `Some`, this will be the duration + /// of the slice. + #[must_use] + pub fn duration(&self) -> Duration { + Duration::from_secs_f64(self.num_frames() as f64 / self.sample_rate as f64) + } + + /// Returns the total duration of the audio, regardless of its slice. + #[must_use] + pub fn unsliced_duration(&self) -> Duration { + Duration::from_secs_f64(self.frames.len() as f64 / self.sample_rate as f64) + } + + /// Returns the nth [`Frame`] of audio in the [`StaticSoundData`]. + /// + /// If [`StaticSoundData::slice`] is `Some`, this will behave as if the [`StaticSoundData`] + /// only contained that portion of the audio. + #[must_use] + pub fn frame_at_index(&self, index: usize) -> Option { + frame_at_index(index, &self.frames, self.slice) + } + + /** + Sets the portion of the audio this [`StaticSoundData`] represents. + + This returns a cheap clone of the [`StaticSoundData`] with the modified slice. + + Note that the [`StaticSoundData`] holds the entire piece of audio it was originally + given regardless of the value of `slice`, but [`StaticSoundData::num_frames`], + [`StaticSoundData::duration`], and [`StaticSoundData::frame_at_index`] will all behave + as if this [`StaticSoundData`] only contained the specified portion of audio. + + # Example + + ``` + use kira::{ + sound::static_sound::{StaticSoundData, StaticSoundSettings}, + Frame, + }; + let sound = StaticSoundData { + sample_rate: 1, + frames: (0..10).map(|i| Frame::from_mono(i as f32)).collect(), + settings: StaticSoundSettings::default(), + slice: None, + }; + let sliced = sound.slice(3.0..6.0); + assert_eq!(sliced.num_frames(), 3); + assert_eq!(sliced.frame_at_index(0), Some(Frame::from_mono(3.0))); + assert_eq!(sliced.frame_at_index(1), Some(Frame::from_mono(4.0))); + ``` + */ + #[must_use = "This method returns a modified StaticSoundData and does not mutate the original value"] + pub fn slice(&self, region: impl IntoOptionalRegion) -> Self { + let mut new = self.clone(); + new.slice = region.into_optional_region().map(|Region { start, end }| { + let start = start.into_samples(self.sample_rate); + let end = match end { + EndPosition::EndOfAudio => self.frames.len(), + EndPosition::Custom(end) => end.into_samples(self.sample_rate), + }; + (start, end) + }); + new + } + + pub(super) fn split(self) -> (StaticSound, StaticSoundHandle) { + let (command_writers, command_readers) = command_writers_and_readers(); + let sound = StaticSound::new(self, command_readers); + let shared = sound.shared(); + ( + sound, + StaticSoundHandle { + command_writers, + shared, + }, + ) + } +} + +impl SoundData for StaticSoundData { + type Error = (); + + type Handle = StaticSoundHandle; + + #[allow(clippy::type_complexity)] + fn into_sound(self) -> Result<(Box, Self::Handle), Self::Error> { + let (sound, handle) = self.split(); + Ok((Box::new(sound), handle)) + } +} + +impl Debug for StaticSoundData { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + f.debug_struct("StaticSoundData") + .field("sample_rate", &self.sample_rate) + .field( + "frames", + &FramesDebug { + len: self.frames.len(), + }, + ) + .field("settings", &self.settings) + .finish() + } +} + +struct FramesDebug { + len: usize, +} + +impl Debug for FramesDebug { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + f.write_fmt(format_args!("[{} frames]", self.len)) + } +} + +pub(crate) fn num_frames(frames: &[Frame], slice: Option<(usize, usize)>) -> usize { + if let Some((start, end)) = slice { + end - start + } else { + frames.len() + } +} + +pub(crate) fn frame_at_index( + index: usize, + frames: &[Frame], + slice: Option<(usize, usize)>, +) -> Option { + if index >= num_frames(frames, slice) { + return None; + } + let start = slice.map(|(start, _)| start).unwrap_or_default(); + Some(frames[index + start]) +} diff --git a/third_party/kira/src/sound/static_sound/data/from_file.rs b/third_party/kira/src/sound/static_sound/data/from_file.rs new file mode 100644 index 00000000..9f67951f --- /dev/null +++ b/third_party/kira/src/sound/static_sound/data/from_file.rs @@ -0,0 +1,84 @@ +use std::io::Cursor; + +use symphonia::core::io::{MediaSource, MediaSourceStream}; + +use crate::sound::{ + FromFileError, static_sound::StaticSoundSettings, symphonia::load_frames_from_buffer_ref, +}; + +use super::StaticSoundData; + +impl StaticSoundData { + /// Loads an audio file into a [`StaticSoundData`]. + #[cfg(not(target_arch = "wasm32"))] + #[cfg_attr(docsrs, doc(cfg(all(feature = "symphonia", not(wasm32)))))] + pub fn from_file(path: impl AsRef) -> Result { + Self::from_media_source(std::fs::File::open(path)?) + } + + /// Loads a cursor wrapping audio file data into a [`StaticSoundData`]. + #[cfg_attr(docsrs, doc(cfg(feature = "symphonia")))] + pub fn from_cursor + Send + Sync + 'static>( + cursor: Cursor, + ) -> Result { + Self::from_media_source(cursor) + } + + /// Loads an audio file from a type that implements Symphonia's [`MediaSource`] + /// trait. + #[cfg_attr(docsrs, doc(cfg(feature = "symphonia")))] + pub fn from_media_source( + media_source: impl MediaSource + 'static, + ) -> Result { + Self::from_boxed_media_source(Box::new(media_source)) + } + + fn from_boxed_media_source(media_source: Box) -> Result { + let codecs = symphonia::default::get_codecs(); + let probe = symphonia::default::get_probe(); + let mss = MediaSourceStream::new(media_source, Default::default()); + let mut format_reader = probe + .format( + &Default::default(), + mss, + &Default::default(), + &Default::default(), + )? + .format; + let default_track = format_reader + .default_track() + .ok_or(FromFileError::NoDefaultTrack)?; + let default_track_id = default_track.id; + let codec_params = &default_track.codec_params; + let sample_rate = codec_params + .sample_rate + .ok_or(FromFileError::UnknownSampleRate)?; + let mut decoder = codecs.make(codec_params, &Default::default())?; + let mut frames = vec![]; + loop { + match format_reader.next_packet() { + Ok(packet) => { + if default_track_id == packet.track_id() { + let buffer = decoder.decode(&packet)?; + frames.append(&mut load_frames_from_buffer_ref(&buffer)?); + } + } + Err(error) => match error { + symphonia::core::errors::Error::IoError(error) => { + if error.kind() == std::io::ErrorKind::UnexpectedEof { + break; + } + return Err(symphonia::core::errors::Error::IoError(error).into()); + } + error => return Err(error.into()), + }, + } + } + Ok(Self { + sample_rate, + frames: frames.into(), + settings: StaticSoundSettings::default(), + slice: None, + }) + } +} diff --git a/third_party/kira/src/sound/static_sound/data/test.rs b/third_party/kira/src/sound/static_sound/data/test.rs new file mode 100644 index 00000000..98cf9409 --- /dev/null +++ b/third_party/kira/src/sound/static_sound/data/test.rs @@ -0,0 +1,64 @@ +use std::{sync::Arc, time::Duration}; + +use crate::frame::Frame; + +use super::StaticSoundData; + +#[test] +fn duration() { + let static_sound = StaticSoundData { + sample_rate: 1, + frames: Arc::new([Frame::from_mono(0.0); 4]), + settings: Default::default(), + slice: None, + }; + assert_eq!(static_sound.duration(), Duration::from_secs(4)); +} + +#[test] +fn unsliced_duration() { + let static_sound = StaticSoundData { + sample_rate: 1, + frames: Arc::new([Frame::from_mono(0.0); 4]), + settings: Default::default(), + slice: Some((2, 3)), + }; + assert_eq!(static_sound.unsliced_duration(), Duration::from_secs(4)); +} + +#[test] +fn sliced_duration() { + let static_sound = StaticSoundData { + sample_rate: 1, + frames: Arc::new([Frame::from_mono(0.0); 4]), + settings: Default::default(), + slice: None, + }; + assert_eq!(static_sound.duration(), Duration::from_secs(4)); + + let static_sound = StaticSoundData { + sample_rate: 1, + frames: Arc::new([Frame::from_mono(0.0); 4]), + settings: Default::default(), + slice: Some((2, 3)), + }; + assert_eq!(static_sound.duration(), Duration::from_secs(1)); +} + +#[test] +fn slice() { + let static_sound = StaticSoundData { + sample_rate: 1, + frames: (0..10).map(|i| Frame::from_mono(i as f32)).collect(), + settings: Default::default(), + slice: None, + } + .slice(3.0..6.0); + for i in 0..3 { + assert_eq!( + static_sound.frame_at_index(i), + Some(Frame::from_mono(i as f32 + 3.0)) + ); + } + assert!(static_sound.frame_at_index(3).is_none()); +} diff --git a/third_party/kira/src/sound/static_sound/handle.rs b/third_party/kira/src/sound/static_sound/handle.rs new file mode 100644 index 00000000..9488bf60 --- /dev/null +++ b/third_party/kira/src/sound/static_sound/handle.rs @@ -0,0 +1,323 @@ +use std::sync::Arc; + +use crate::{ + Decibels, Panning, PlaybackRate, StartTime, Tween, + command::handle_param_setters, + sound::{IntoOptionalRegion, PlaybackState}, +}; + +use super::{CommandWriters, sound::Shared}; + +/// Controls a static sound. +#[derive(Debug)] +pub struct StaticSoundHandle { + pub(super) command_writers: CommandWriters, + pub(super) shared: Arc, +} + +impl StaticSoundHandle { + /// Returns the current playback state of the sound. + #[must_use] + pub fn state(&self) -> PlaybackState { + self.shared.state() + } + + /// Returns the current playback position of the sound (in seconds). + #[must_use] + pub fn position(&self) -> f64 { + self.shared.position() + } + + handle_param_setters! { + /** + Sets the volume of the sound. + + # Examples + + Set the volume of the sound immediately: + + ```no_run + # use kira::{ + # AudioManager, AudioManagerSettings, DefaultBackend, + # sound::static_sound::{StaticSoundData, StaticSoundSettings}, + # }; + # let mut manager = AudioManager::::new(AudioManagerSettings::default())?; + # let mut sound = manager.play(StaticSoundData::from_file("sound.ogg")?)?; + use kira::Tween; + + sound.set_volume(-6.0, Tween::default()); + # Result::<(), Box>::Ok(()) + ``` + + Smoothly transition the volume to a target volume: + + ```no_run + # use kira::{ + # AudioManager, AudioManagerSettings, DefaultBackend, + # sound::static_sound::{StaticSoundData, StaticSoundSettings}, + # }; + # let mut manager = AudioManager::::new(AudioManagerSettings::default())?; + # let mut sound = manager.play(StaticSoundData::from_file("sound.ogg")?)?; + use kira::Tween; + use std::time::Duration; + + sound.set_volume(-6.0, Tween { + duration: Duration::from_secs(3), + ..Default::default() + }); + # Result::<(), Box>::Ok(()) + ``` + + Link the volume to a modulator, smoothly transitioning from the current value: + + ```no_run + use kira::{ + AudioManager, AudioManagerSettings, DefaultBackend, + sound::static_sound::{StaticSoundData, StaticSoundSettings}, + modulator::tweener::TweenerBuilder, + Value, Tween, Mapping, Easing, + Decibels, + }; + use std::time::Duration; + + let mut manager = AudioManager::::new(AudioManagerSettings::default())?; + let tweener = manager.add_modulator(TweenerBuilder { + initial_value: 0.5, + })?; + let mut sound = manager.play(StaticSoundData::from_file("sound.ogg")?)?; + sound.set_volume(Value::FromModulator { + id: tweener.id(), + mapping: Mapping { + input_range: (0.0, 1.0), + output_range: (Decibels::SILENCE, Decibels::IDENTITY), + easing: Easing::Linear, + } + }, Tween { + duration: Duration::from_secs(3), + ..Default::default() + }); + # Result::<(), Box>::Ok(()) + ``` + */ + volume: Decibels, + + /** + Sets the playback rate of the sound. + + Changing the playback rate will change both the speed + and pitch of the sound. + + # Examples + + Set the playback rate of the sound immediately: + + ```no_run + # use kira::{ + # AudioManager, AudioManagerSettings, DefaultBackend, + # sound::static_sound::{StaticSoundData, StaticSoundSettings}, + # }; + # let mut manager = AudioManager::::new(AudioManagerSettings::default())?; + # let mut sound = manager.play(StaticSoundData::from_file("sound.ogg")?)?; + use kira::Tween; + + sound.set_playback_rate(0.5, Tween::default()); + # Result::<(), Box>::Ok(()) + ``` + + Smoothly transition the playback rate to a target value in semitones: + + ```no_run + # use kira::{ + # AudioManager, AudioManagerSettings, DefaultBackend, + # sound::static_sound::{StaticSoundData, StaticSoundSettings}, + # }; + # let mut manager = AudioManager::::new(AudioManagerSettings::default())?; + # let mut sound = manager.play(StaticSoundData::from_file("sound.ogg")?)?; + use kira::{Tween, Semitones}; + use std::time::Duration; + + sound.set_playback_rate(Semitones(-2.0), Tween { + duration: Duration::from_secs(3), + ..Default::default() + }); + # Result::<(), Box>::Ok(()) + ``` + + Link the playback rate to a modulator, smoothly transitioning from the current value: + + ```no_run + use kira::{ + AudioManager, AudioManagerSettings, DefaultBackend, + sound::static_sound::{StaticSoundData, StaticSoundSettings}, + modulator::tweener::TweenerBuilder, + Value, Easing, Mapping, Tween, + PlaybackRate, + }; + use std::time::Duration; + + let mut manager = AudioManager::::new(AudioManagerSettings::default())?; + let tweener = manager.add_modulator(TweenerBuilder { + initial_value: 0.5, + })?; + let mut sound = manager.play(StaticSoundData::from_file("sound.ogg")?)?; + sound.set_playback_rate(Value::FromModulator { + id: tweener.id(), + mapping: Mapping { + input_range: (0.0, 1.0), + output_range: (PlaybackRate(0.0), PlaybackRate(1.0)), + easing: Easing::Linear, + }, + }, Tween { + duration: Duration::from_secs(3), + ..Default::default() + }); + # Result::<(), Box>::Ok(()) + ``` + */ + playback_rate: PlaybackRate, + + /** + Sets the panning of the sound, where `-1.0` is hard left, + `0.0` is center, and `1.0` is hard right. + + # Examples + + Smoothly transition the panning to a target value: + + ```no_run + # use kira::{ + # AudioManager, AudioManagerSettings, DefaultBackend, + # sound::static_sound::{StaticSoundData, StaticSoundSettings}, + # }; + # let mut manager = AudioManager::::new(AudioManagerSettings::default())?; + # let mut sound = manager.play(StaticSoundData::from_file("sound.ogg")?)?; + use kira::Tween; + use std::time::Duration; + + sound.set_panning(-0.5, Tween { + duration: Duration::from_secs(3), + ..Default::default() + }); + # Result::<(), Box>::Ok(()) + ``` + + Link the panning to a modulator, smoothly transitioning from the current value: + + ```no_run + use kira::{ + AudioManager, AudioManagerSettings, DefaultBackend, + sound::static_sound::{StaticSoundData, StaticSoundSettings}, + modulator::tweener::TweenerBuilder, + Value, Easing, Mapping, Tween, + Panning, + }; + use std::time::Duration; + + let mut manager = AudioManager::::new(AudioManagerSettings::default())?; + let tweener = manager.add_modulator(TweenerBuilder { + initial_value: -0.5, + })?; + let mut sound = manager.play(StaticSoundData::from_file("sound.ogg")?)?; + sound.set_panning(Value::FromModulator { + id: tweener.id(), + mapping: Mapping { + input_range: (-1.0, 1.0), + output_range: (Panning::LEFT, Panning::RIGHT), + easing: Easing::Linear, + }, + }, Tween { + duration: Duration::from_secs(3), + ..Default::default() + }); + # Result::<(), Box>::Ok(()) + ``` + */ + panning: Panning, + } + + /** + Sets the portion of the sound that will play in a loop. + + # Examples + + Set the sound to loop the portion from 3 seconds in to the end: + + ```no_run + # use kira::{ + # AudioManager, AudioManagerSettings, DefaultBackend, + # sound::static_sound::{StaticSoundData, StaticSoundSettings}, + # }; + # let mut manager = AudioManager::::new(AudioManagerSettings::default())?; + # let mut sound = manager.play(StaticSoundData::from_file("sound.ogg")?)?; + sound.set_loop_region(3.0..); + # Result::<(), Box>::Ok(()) + ``` + + Set the sound to loop the portion from 2 to 4 seconds: + + ```no_run + # use kira::{ + # AudioManager, AudioManagerSettings, DefaultBackend, + # sound::static_sound::{StaticSoundData, StaticSoundSettings}, + # }; + # let mut manager = AudioManager::::new(AudioManagerSettings::default())?; + # let mut sound = manager.play(StaticSoundData::from_file("sound.ogg")?)?; + sound.set_loop_region(2.0..4.0); + # Result::<(), Box>::Ok(()) + ``` + + Set a sound that was previously looping to stop looping: + + ```no_run + # use kira::{ + # AudioManager, AudioManagerSettings, DefaultBackend, + # sound::static_sound::{StaticSoundData, StaticSoundSettings}, + # }; + # let mut manager = AudioManager::::new(AudioManagerSettings::default())?; + # let mut sound = manager.play(StaticSoundData::from_file("sound.ogg")?)?; + sound.set_loop_region(None); + # Result::<(), Box>::Ok(()) + ``` + */ + pub fn set_loop_region(&mut self, loop_region: impl IntoOptionalRegion) { + self.command_writers + .set_loop_region + .write(loop_region.into_optional_region()) + } + + /// Fades out the sound to silence with the given tween and then + /// pauses playback. + pub fn pause(&mut self, tween: Tween) { + self.command_writers.pause.write(tween) + } + + /// Resumes playback and fades in the sound from silence + /// with the given tween. + pub fn resume(&mut self, tween: Tween) { + self.resume_at(StartTime::Immediate, tween) + } + + /// Resumes playback at the given start time and fades in + /// the sound from silence with the given tween. + pub fn resume_at(&mut self, start_time: StartTime, tween: Tween) { + self.command_writers.resume.write((start_time, tween)) + } + + /// Fades out the sound to silence with the given tween and then + /// stops playback. + /// + /// Once the sound is stopped, it cannot be restarted. + pub fn stop(&mut self, tween: Tween) { + self.command_writers.stop.write(tween) + } + + /// Sets the playback position to the specified time in seconds. + pub fn seek_to(&mut self, position: f64) { + self.command_writers.seek_to.write(position) + } + + /// Moves the playback position by the specified amount of time in seconds. + pub fn seek_by(&mut self, amount: f64) { + self.command_writers.seek_by.write(amount) + } +} diff --git a/third_party/kira/src/sound/static_sound/settings.rs b/third_party/kira/src/sound/static_sound/settings.rs new file mode 100644 index 00000000..afc998a7 --- /dev/null +++ b/third_party/kira/src/sound/static_sound/settings.rs @@ -0,0 +1,129 @@ +use crate::{ + Decibels, Panning, PlaybackRate, StartTime, Tween, Value, + sound::{IntoOptionalRegion, PlaybackPosition, Region}, +}; + +/// Settings for a static sound. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct StaticSoundSettings { + /// When the sound should start playing. + pub start_time: StartTime, + /// Where in the sound playback should start. + pub start_position: PlaybackPosition, + /// The portion of the sound that should be looped. + pub loop_region: Option, + /// Whether the sound should be played in reverse. + pub reverse: bool, + /// The volume of the sound. + pub volume: Value, + /// The playback rate of the sound. + /// + /// Changing the playback rate will change both the speed + /// and the pitch of the sound. + pub playback_rate: Value, + /// The panning of the sound, where 0 is hard left + /// and 1 is hard right. + pub panning: Value, + /// An optional fade-in from silence. + pub fade_in_tween: Option, +} + +impl StaticSoundSettings { + /// Creates a new [`StaticSoundSettings`] with the default settings. + #[must_use] + pub fn new() -> Self { + Self { + start_time: StartTime::default(), + start_position: PlaybackPosition::Seconds(0.0), + reverse: false, + loop_region: None, + volume: Value::Fixed(Decibels::IDENTITY), + playback_rate: Value::Fixed(PlaybackRate(1.0)), + panning: Value::Fixed(Panning::CENTER), + fade_in_tween: None, + } + } + + /** Sets when the sound should start playing. */ + #[must_use = "This method consumes self and returns a modified StaticSoundSettings, so the return value should be used"] + pub fn start_time(self, start_time: impl Into) -> Self { + Self { + start_time: start_time.into(), + ..self + } + } + + /// Sets where in the sound playback should start. + #[must_use = "This method consumes self and returns a modified StaticSoundSettings, so the return value should be used"] + pub fn start_position(self, start_position: impl Into) -> Self { + Self { + start_position: start_position.into(), + ..self + } + } + + /// Sets whether the sound should be played in reverse. + #[must_use = "This method consumes self and returns a modified StaticSoundSettings, so the return value should be used"] + pub fn reverse(self, reverse: bool) -> Self { + Self { reverse, ..self } + } + + /** Sets the portion of the sound that should be looped. */ + #[must_use = "This method consumes self and returns a modified StaticSoundSettings, so the return value should be used"] + pub fn loop_region(self, loop_region: impl IntoOptionalRegion) -> Self { + Self { + loop_region: loop_region.into_optional_region(), + ..self + } + } + + /** Sets the volume of the sound. */ + #[must_use = "This method consumes self and returns a modified StaticSoundSettings, so the return value should be used"] + pub fn volume(self, volume: impl Into>) -> Self { + Self { + volume: volume.into(), + ..self + } + } + + /** + Sets the playback rate of the sound. + + Changing the playback rate will change both the speed + and the pitch of the sound. + */ + #[must_use = "This method consumes self and returns a modified StaticSoundSettings, so the return value should be used"] + pub fn playback_rate(self, playback_rate: impl Into>) -> Self { + Self { + playback_rate: playback_rate.into(), + ..self + } + } + + /** + Sets the panning of the sound, where 0 is hard left + and 1 is hard right. + */ + #[must_use = "This method consumes self and returns a modified StaticSoundSettings, so the return value should be used"] + pub fn panning(self, panning: impl Into>) -> Self { + Self { + panning: panning.into(), + ..self + } + } + + /// Sets the tween used to fade in the sound from silence. + #[must_use = "This method consumes self and returns a modified StaticSoundSettings, so the return value should be used"] + pub fn fade_in_tween(self, fade_in_tween: impl Into>) -> Self { + Self { + fade_in_tween: fade_in_tween.into(), + ..self + } + } +} + +impl Default for StaticSoundSettings { + fn default() -> Self { + Self::new() + } +} diff --git a/third_party/kira/src/sound/static_sound/sound.rs b/third_party/kira/src/sound/static_sound/sound.rs new file mode 100644 index 00000000..b25bd16f --- /dev/null +++ b/third_party/kira/src/sound/static_sound/sound.rs @@ -0,0 +1,276 @@ +mod resampler; + +#[cfg(test)] +mod test; + +use std::sync::{ + Arc, + atomic::{AtomicU8, AtomicU64, Ordering}, +}; + +use crate::{ + Decibels, Panning, Parameter, PlaybackRate, StartTime, Tween, + command::read_commands_into_parameters, + frame::Frame, + info::Info, + playback_state_manager::PlaybackStateManager, + sound::{PlaybackState, Sound, transport::Transport}, +}; + +use self::resampler::Resampler; + +use super::{CommandReaders, data::StaticSoundData, frame_at_index, num_frames}; + +pub(super) struct StaticSound { + command_readers: CommandReaders, + sample_rate: u32, + frames: Arc<[Frame]>, + slice: Option<(usize, usize)>, + reverse: bool, + playback_state_manager: PlaybackStateManager, + start_time: StartTime, + resampler: Resampler, + transport: Transport, + fractional_position: f64, + volume: Parameter, + playback_rate: Parameter, + panning: Parameter, + shared: Arc, +} + +impl StaticSound { + #[must_use] + pub fn new(data: StaticSoundData, command_readers: CommandReaders) -> Self { + let settings = data.settings; + let transport = Transport::new( + data.settings.start_position.into_samples(data.sample_rate), + data.settings.loop_region, + data.settings.reverse, + data.sample_rate, + data.num_frames(), + ); + let starting_frame_index = transport.position; + let position = starting_frame_index as f64 / data.sample_rate as f64; + let mut sound = Self { + command_readers, + sample_rate: data.sample_rate, + frames: data.frames, + slice: data.slice, + reverse: data.settings.reverse, + playback_state_manager: PlaybackStateManager::new(settings.fade_in_tween), + start_time: settings.start_time, + resampler: Resampler::new(starting_frame_index), + transport, + fractional_position: 0.0, + volume: Parameter::new(settings.volume, Decibels::IDENTITY), + playback_rate: Parameter::new(settings.playback_rate, PlaybackRate(1.0)), + panning: Parameter::new(settings.panning, Panning::CENTER), + shared: Arc::new(Shared { + state: AtomicU8::new(PlaybackState::Playing as u8), + position: AtomicU64::new(position.to_bits()), + }), + }; + // fill the resample buffer with 3 samples so playback can + // start immediately + for _ in 0..3 { + sound.update_position(); + } + sound + } + + pub(super) fn shared(&self) -> Arc { + self.shared.clone() + } + + fn update_shared_playback_state(&mut self) { + self.shared + .set_state(self.playback_state_manager.playback_state()); + } + + fn pause(&mut self, fade_out_tween: Tween) { + self.playback_state_manager.pause(fade_out_tween); + self.update_shared_playback_state(); + } + + fn resume(&mut self, start_time: StartTime, fade_in_tween: Tween) { + self.playback_state_manager + .resume(start_time, fade_in_tween); + self.update_shared_playback_state(); + } + + fn stop(&mut self, fade_out_tween: Tween) { + self.playback_state_manager.stop(fade_out_tween); + self.update_shared_playback_state(); + } + + #[must_use] + fn is_playing_backwards(&self) -> bool { + let mut is_playing_backwards = self.playback_rate.value().0.is_sign_negative(); + if self.reverse { + is_playing_backwards = !is_playing_backwards + } + is_playing_backwards + } + + /// Updates the current frame index by 1 and pushes a new sample to the resampler. + fn update_position(&mut self) { + self.push_frame_to_resampler(); + if self.is_playing_backwards() { + self.transport.decrement_position(); + } else { + self.transport + .increment_position(num_frames(&self.frames, self.slice)); + } + if !self.transport.playing && self.resampler.empty() { + self.playback_state_manager.mark_as_stopped(); + self.update_shared_playback_state(); + } + } + + fn seek_to_index(&mut self, index: usize) { + self.transport + .seek_to(index, num_frames(&self.frames, self.slice)); + // if the sound is playing, push a frame to the resample buffer + // to make sure it doesn't get skipped + if self.playback_state_manager.playback_state().is_advancing() { + self.push_frame_to_resampler(); + } + } + + fn push_frame_to_resampler(&mut self) { + let frame = self.transport.playing.then(|| { + frame_at_index(self.transport.position, &self.frames, self.slice).unwrap_or_default() + }); + self.resampler.push_frame(frame, self.transport.position); + } + + fn seek_by(&mut self, amount: f64) { + let current_position = self.transport.position as f64 / self.sample_rate as f64; + let position = current_position + amount; + let index = (position * self.sample_rate as f64) as usize; + self.seek_to_index(index); + } + + fn seek_to(&mut self, position: f64) { + let index = (position * self.sample_rate as f64) as usize; + self.seek_to_index(index); + } + + fn read_commands(&mut self) { + read_commands_into_parameters!(self, volume, playback_rate, panning); + if let Some(loop_region) = self.command_readers.set_loop_region.read() { + self.transport.set_loop_region( + loop_region, + self.sample_rate, + num_frames(&self.frames, self.slice), + ); + } + if let Some(tween) = self.command_readers.pause.read() { + self.pause(tween); + } + if let Some((start_time, tween)) = self.command_readers.resume.read() { + self.resume(start_time, tween); + } + if let Some(tween) = self.command_readers.stop.read() { + self.stop(tween); + } + if let Some(amount) = self.command_readers.seek_by.read() { + self.seek_by(amount); + } + if let Some(position) = self.command_readers.seek_to.read() { + self.seek_to(position); + } + } +} + +impl Sound for StaticSound { + fn on_start_processing(&mut self) { + let last_played_frame_position = self.resampler.current_frame_index(); + self.shared.position.store( + (last_played_frame_position as f64 / self.sample_rate as f64).to_bits(), + Ordering::SeqCst, + ); + self.read_commands(); + } + + fn process(&mut self, out: &mut [Frame], dt: f64, info: &Info) { + // update parameters + self.volume.update(dt * out.len() as f64, info); + self.playback_rate.update(dt * out.len() as f64, info); + self.panning.update(dt * out.len() as f64, info); + let changed_playback_state = self + .playback_state_manager + .update(dt * out.len() as f64, info); + if changed_playback_state { + self.update_shared_playback_state(); + } + + let will_never_start = self.start_time.update(dt * out.len() as f64, info); + if will_never_start { + self.playback_state_manager.mark_as_stopped(); + self.update_shared_playback_state(); + } + if self.start_time != StartTime::Immediate { + out.fill(Frame::ZERO); + return; + } + + if !self.playback_state_manager.playback_state().is_advancing() { + out.fill(Frame::ZERO); + return; + } + + // play back audio + let num_frames = out.len(); + for (i, frame) in out.iter_mut().enumerate() { + let time_in_chunk = (i + 1) as f64 / num_frames as f64; + let volume = self.volume.interpolated_value(time_in_chunk).as_amplitude(); + let fade_volume = self + .playback_state_manager + .interpolated_fade_volume(time_in_chunk) + .as_amplitude(); + let panning = self.panning.interpolated_value(time_in_chunk); + let playback_rate = self.playback_rate.interpolated_value(time_in_chunk); + let resampler_out = self.resampler.get(self.fractional_position as f32); + self.fractional_position += self.sample_rate as f64 * playback_rate.0.abs() * dt; + while self.fractional_position >= 1.0 { + self.fractional_position -= 1.0; + self.update_position(); + } + *frame = (resampler_out * fade_volume * volume).panned(panning); + } + } + + fn finished(&self) -> bool { + self.playback_state_manager.playback_state() == PlaybackState::Stopped + } +} + +#[derive(Debug)] +pub(super) struct Shared { + state: AtomicU8, + position: AtomicU64, +} + +impl Shared { + pub fn state(&self) -> PlaybackState { + match self.state.load(Ordering::SeqCst) { + 0 => PlaybackState::Playing, + 1 => PlaybackState::Pausing, + 2 => PlaybackState::Paused, + 3 => PlaybackState::WaitingToResume, + 4 => PlaybackState::Resuming, + 5 => PlaybackState::Stopping, + 6 => PlaybackState::Stopped, + _ => panic!("Invalid playback state"), + } + } + + pub fn set_state(&self, state: PlaybackState) { + self.state.store(state as u8, Ordering::SeqCst); + } + + pub fn position(&self) -> f64 { + f64::from_bits(self.position.load(Ordering::SeqCst)) + } +} diff --git a/third_party/kira/src/sound/static_sound/sound/resampler.rs b/third_party/kira/src/sound/static_sound/sound/resampler.rs new file mode 100644 index 00000000..3ba936c3 --- /dev/null +++ b/third_party/kira/src/sound/static_sound/sound/resampler.rs @@ -0,0 +1,71 @@ +use crate::frame::{Frame, interpolate_frame}; + +#[derive(Debug, Clone, Copy, PartialEq)] +struct RecentFrame { + /// A frame of audio. + frame: Frame, + /// The current frame index of the source sound at the + /// time this frame was pushed to the resampler. + frame_index: usize, +} + +pub(super) struct Resampler { + frames: [RecentFrame; 4], + time_until_empty: usize, +} + +impl Resampler { + #[must_use] + pub fn new(starting_frame_index: usize) -> Self { + Self { + frames: [RecentFrame { + frame: Frame::ZERO, + frame_index: starting_frame_index, + }; 4], + time_until_empty: 0, + } + } + + pub fn push_frame(&mut self, frame: Option, sample_index: usize) { + if frame.is_some() { + self.time_until_empty = 4; + } else { + self.time_until_empty = self.time_until_empty.saturating_sub(1); + } + let frame = frame.unwrap_or_default(); + self.frames.copy_within(1.., 0); + self.frames[self.frames.len() - 1] = RecentFrame { + frame, + frame_index: sample_index, + }; + } + + #[must_use] + pub fn get(&self, fractional_position: f32) -> Frame { + interpolate_frame( + self.frames[0].frame, + self.frames[1].frame, + self.frames[2].frame, + self.frames[3].frame, + fractional_position, + ) + } + + /// Returns the index of the frame in the source sound + /// that the user is currently hearing from this resampler. + /// + /// This is not the same as the most recently pushed frame. + /// The user mainly hears a frame between `self.frames[1]` and + /// `self.frames[2]`. `self.frames[0]` and `self.frames[3]` + /// are used to provide additional information to the interpolation + /// algorithm to get a smoother result. + #[must_use] + pub fn current_frame_index(&self) -> usize { + self.frames[1].frame_index + } + + #[must_use] + pub fn empty(&self) -> bool { + self.time_until_empty == 0 + } +} diff --git a/third_party/kira/src/sound/static_sound/sound/test.rs b/third_party/kira/src/sound/static_sound/sound/test.rs new file mode 100644 index 00000000..76824fb5 --- /dev/null +++ b/third_party/kira/src/sound/static_sound/sound/test.rs @@ -0,0 +1,841 @@ +use std::{sync::Arc, time::Duration}; + +use crate::{ + Decibels, Panning, StartTime, Tween, + clock::ClockTime, + frame::Frame, + info::MockInfoBuilder, + sound::{ + PlaybackState, Sound, + static_sound::{StaticSoundData, StaticSoundSettings}, + }, + test_helpers::expect_frame_soon, +}; + +/// Tests that a `StaticSound` will play all of its samples before finishing. +#[test] +fn plays_all_samples() { + let data = StaticSoundData { + sample_rate: 1, + frames: Arc::new([ + Frame::from_mono(1.0), + Frame::from_mono(2.0), + Frame::from_mono(3.0), + ]), + settings: StaticSoundSettings::new(), + slice: None, + }; + let (mut sound, _) = data.split(); + + assert!(!sound.finished()); + + for i in 1..=3 { + assert_eq!( + sound.process_one(1.0, &MockInfoBuilder::new().build()), + Frame::from_mono(i as f32).panned(Panning::CENTER) + ); + assert!(!sound.finished()); + } + + // give some time for the resample buffer to empty. in the meantime we should + // get silent output. + for _ in 0..10 { + assert_eq!( + sound.process_one(1.0, &MockInfoBuilder::new().build()), + Frame::from_mono(0.0).panned(Panning::CENTER) + ); + } + + // the sound should be finished and stopped by now + assert!(sound.finished()); + assert_eq!( + sound.playback_state_manager.playback_state(), + PlaybackState::Stopped + ); +} + +/// Tests that a `StaticSound` correctly reports its playback state +/// to be queried by StaticSoundHandle::state. +#[test] +fn reports_playback_state() { + let data = StaticSoundData { + sample_rate: 1, + frames: Arc::new([Frame::from_mono(0.0); 10]), + settings: StaticSoundSettings::new(), + slice: None, + }; + let (mut sound, handle) = data.split(); + + for _ in 0..20 { + assert_eq!( + handle.state(), + sound.playback_state_manager.playback_state() + ); + sound.process_one(1.0, &MockInfoBuilder::new().build()); + } +} + +/// Tests that a `StaticSound` correctly reports its playback state +/// to be queried by StaticSoundHandle::state. +#[test] +#[allow(clippy::float_cmp)] +fn reports_playback_position() { + let data = StaticSoundData { + sample_rate: 1, + frames: Arc::new([Frame::from_mono(0.0); 10]), + settings: StaticSoundSettings::new(), + slice: None, + }; + let (mut sound, handle) = data.split(); + + for i in 0..20 { + assert_eq!( + handle.position(), + i.clamp(0, 10) as f64 / sound.sample_rate as f64 + ); + sound.process_one(1.0, &MockInfoBuilder::new().build()); + sound.on_start_processing(); + } +} + +/// Tests that a `StaticSound` fades out fully before pausing +/// and fades back in when resuming. +#[test] +#[allow(clippy::float_cmp)] +fn pauses_and_resumes_with_fades() { + let data = StaticSoundData { + sample_rate: 1, + frames: Arc::new([Frame::from_mono(1.0); 100]), + settings: StaticSoundSettings::new(), + slice: None, + }; + let (mut sound, mut handle) = data.split(); + + sound.process_one(1.0, &MockInfoBuilder::new().build()); + assert_eq!( + sound.playback_state_manager.playback_state(), + PlaybackState::Playing + ); + + handle.pause(Tween { + duration: Duration::from_secs(4), + ..Default::default() + }); + sound.on_start_processing(); + assert_eq!( + sound.playback_state_manager.playback_state(), + PlaybackState::Pausing + ); + + // allow for a few samples of delay because of the resampling, but the + // sound should fade out soon. + expect_frame_soon( + Frame::from_mono(Decibels(-15.0).as_amplitude()).panned(Panning::CENTER), + &mut sound, + ); + assert_eq!( + sound.process_one(1.0, &MockInfoBuilder::new().build()), + Frame::from_mono(Decibels(-30.0).as_amplitude()).panned(Panning::CENTER) + ); + assert_eq!( + sound.process_one(1.0, &MockInfoBuilder::new().build()), + Frame::from_mono(Decibels(-45.0).as_amplitude()).panned(Panning::CENTER) + ); + + sound.on_start_processing(); + let position = handle.position(); + for _ in 0..10 { + assert_eq!( + sound.process_one(1.0, &MockInfoBuilder::new().build()), + Frame::from_mono(0.0).panned(Panning::CENTER) + ); + sound.on_start_processing(); + assert_eq!(handle.position(), position); + assert_eq!( + sound.playback_state_manager.playback_state(), + PlaybackState::Paused + ); + } + + handle.resume(Tween { + duration: Duration::from_secs(4), + ..Default::default() + }); + sound.on_start_processing(); + + // allow for a few samples of delay because of the resampling, but the + // sound should fade back in soon. + expect_frame_soon( + Frame::from_mono(Decibels(-45.0).as_amplitude()).panned(Panning::CENTER), + &mut sound, + ); + assert_eq!( + sound.playback_state_manager.playback_state(), + PlaybackState::Resuming + ); + assert_eq!( + sound.process_one(1.0, &MockInfoBuilder::new().build()), + Frame::from_mono(Decibels(-30.0).as_amplitude()).panned(Panning::CENTER) + ); + assert_eq!( + sound.playback_state_manager.playback_state(), + PlaybackState::Resuming + ); + assert_eq!( + sound.process_one(1.0, &MockInfoBuilder::new().build()), + Frame::from_mono(Decibels(-15.0).as_amplitude()).panned(Panning::CENTER) + ); + assert_eq!( + sound.playback_state_manager.playback_state(), + PlaybackState::Resuming + ); + + for _ in 0..3 { + assert_eq!( + sound.process_one(1.0, &MockInfoBuilder::new().build()), + Frame::from_mono(1.0).panned(Panning::CENTER) + ); + assert_eq!( + sound.playback_state_manager.playback_state(), + PlaybackState::Playing + ); + } + + let mut frames = vec![Frame::ZERO; 3]; + sound.process(&mut frames, 1.0, &MockInfoBuilder::new().build()); + assert_eq!( + frames, + vec![Frame::from_mono(1.0).panned(Panning::CENTER); 3] + ); +} + +/// Tests that a `StaticSound` stops and finishes after a fade-out. +#[test] +#[allow(clippy::float_cmp)] +fn stops_with_fade_out() { + let data = StaticSoundData { + sample_rate: 1, + frames: Arc::new([Frame::from_mono(1.0); 100]), + settings: StaticSoundSettings::new(), + slice: None, + }; + let (mut sound, mut handle) = data.split(); + + sound.process_one(1.0, &MockInfoBuilder::new().build()); + assert_eq!( + sound.playback_state_manager.playback_state(), + PlaybackState::Playing + ); + + handle.stop(Tween { + duration: Duration::from_secs(4), + ..Default::default() + }); + sound.on_start_processing(); + assert_eq!( + sound.playback_state_manager.playback_state(), + PlaybackState::Stopping + ); + + // allow for a few samples of delay because of the resampling, but the + // sound should fade out soon. + expect_frame_soon( + Frame::from_mono(Decibels(-15.0).as_amplitude()).panned(Panning::CENTER), + &mut sound, + ); + assert_eq!( + sound.process_one(1.0, &MockInfoBuilder::new().build()), + Frame::from_mono(Decibels(-30.0).as_amplitude()).panned(Panning::CENTER) + ); + assert_eq!( + sound.process_one(1.0, &MockInfoBuilder::new().build()), + Frame::from_mono(Decibels(-45.0).as_amplitude()).panned(Panning::CENTER) + ); + + sound.on_start_processing(); + let position = handle.position(); + for _ in 0..3 { + assert_eq!( + sound.process_one(1.0, &MockInfoBuilder::new().build()), + Frame::from_mono(0.0).panned(Panning::CENTER) + ); + sound.on_start_processing(); + assert_eq!(handle.position(), position); + assert_eq!( + sound.playback_state_manager.playback_state(), + PlaybackState::Stopped + ); + assert!(sound.finished()); + } +} + +/// Tests that a `StaticSound` will wait for its start clock time +/// when appropriate. +#[test] +#[allow(clippy::float_cmp)] +fn waits_for_start_time() { + let mut info_builder = MockInfoBuilder::new(); + let clock_id_1 = info_builder.add_clock(true, 0, 0.0); + let info = info_builder.build(); + + let data = StaticSoundData { + sample_rate: 1, + frames: (1..100).map(|i| Frame::from_mono(i as f32)).collect(), + settings: StaticSoundSettings::new().start_time(ClockTime { + clock: clock_id_1, + ticks: 2, + fraction: 0.0, + }), + slice: None, + }; + let (mut sound, _) = data.split(); + + // the sound should not be playing yet + for _ in 0..3 { + assert_eq!(sound.process_one(1.0, &info), Frame::from_mono(0.0)); + } + + let info = { + let mut builder = MockInfoBuilder::new(); + builder.add_clock(true, 1, 0.0); + builder.add_clock(true, 0, 0.0); + builder.build() + }; + + // the sound is set to start at tick 2, so it should not + // play yet + for _ in 0..3 { + assert_eq!(sound.process_one(1.0, &info), Frame::from_mono(0.0)); + } + + let info = { + let mut builder = MockInfoBuilder::new(); + builder.add_clock(true, 1, 0.0); + builder.add_clock(true, 2, 0.0); + builder.build() + }; + + // a different clock reached tick 2, so the sound should + // not play yet + for _ in 0..3 { + assert_eq!(sound.process_one(1.0, &info), Frame::from_mono(0.0)); + } + + let info = { + let mut builder = MockInfoBuilder::new(); + builder.add_clock(true, 2, 0.0); + builder.add_clock(true, 2, 0.0); + builder.build() + }; + + // the sound should start playing now + for i in 1..10 { + assert_eq!( + sound.process_one(1.0, &info), + Frame::from_mono(i as f32).panned(Panning::CENTER) + ); + } +} + +/// Tests that a `StaticSound` will stop (allowing it to be removed) +/// if it's waiting on a clock that no longer exists before it can +/// start. +/// +/// Without this behavior, a sound in this situation would be in limbo +/// forever with no way to free up its resources. +#[test] +fn stops_if_waiting_on_missing_clock() { + let mut info_builder = MockInfoBuilder::new(); + let clock_id = info_builder.add_clock(true, 0, 0.0); + let info = info_builder.build(); + + let data = StaticSoundData { + sample_rate: 1, + frames: (1..100).map(|i| Frame::from_mono(i as f32)).collect(), + settings: StaticSoundSettings::new().start_time(ClockTime { + clock: clock_id, + ticks: 2, + fraction: 0.0, + }), + slice: None, + }; + let (mut sound, handle) = data.split(); + + sound.process_one(1.0, &info); + sound.on_start_processing(); + assert_eq!(handle.state(), PlaybackState::Playing); + + // the clock is removed + let info = MockInfoBuilder::new().build(); + + sound.process_one(1.0, &info); + sound.on_start_processing(); + assert_eq!(handle.state(), PlaybackState::Stopped); + assert!(sound.finished()); +} + +/// Tests that a `StaticSound` that had its start time set to a clock time and already +/// started will not stop if the clock stops. +#[test] +fn continues_when_clock_stops() { + let mut info_builder = MockInfoBuilder::new(); + let clock_id = info_builder.add_clock(true, 0, 0.0); + let info = info_builder.build(); + + let data = StaticSoundData { + sample_rate: 1, + frames: (1..100).map(|_| Frame::from_mono(1.0)).collect(), + settings: StaticSoundSettings::new().start_time(ClockTime { + clock: clock_id, + ticks: 0, + fraction: 0.0, + }), + slice: None, + }; + let (mut sound, _) = data.split(); + + assert_eq!(sound.process_one(1.0, &info), Frame::from_mono(1.0),); + + let info = { + let mut builder = MockInfoBuilder::new(); + builder.add_clock(false, 0, 0.0); + builder.build() + }; + + assert_eq!(sound.process_one(1.0, &info), Frame::from_mono(1.0),); +} + +/// Tests that a `StaticSound` can be paused, resumed, and stopped immediately +/// even if it's waiting for its start time. +#[test] +fn immediate_playback_state_change_with_start_time() { + let mut info_builder = MockInfoBuilder::new(); + let clock_id = info_builder.add_clock(true, 0, 0.0); + let info = info_builder.build(); + + let data = StaticSoundData { + sample_rate: 1, + frames: (1..100).map(|_| Frame::from_mono(1.0)).collect(), + settings: StaticSoundSettings::new().start_time(ClockTime { + clock: clock_id, + ticks: 1, + fraction: 0.0, + }), + slice: None, + }; + let (mut sound, mut handle) = data.split(); + + handle.pause(Tween { + duration: Duration::ZERO, + ..Default::default() + }); + sound.on_start_processing(); + sound.process_one(1.0, &info); + sound.on_start_processing(); + assert_eq!(handle.state(), PlaybackState::Paused); + + handle.resume(Tween { + duration: Duration::ZERO, + ..Default::default() + }); + sound.on_start_processing(); + sound.process_one(1.0, &info); + sound.on_start_processing(); + assert_eq!(handle.state(), PlaybackState::Playing); + + handle.stop(Tween { + duration: Duration::ZERO, + ..Default::default() + }); + sound.on_start_processing(); + sound.process_one(1.0, &info); + sound.on_start_processing(); + assert_eq!(handle.state(), PlaybackState::Stopped); +} + +/// Tests that a `StaticSound` can be set to resume at a certain start time. +#[test] +fn resume_at() { + let mut info_builder = MockInfoBuilder::new(); + let clock_id = info_builder.add_clock(true, 0, 0.0); + let info = info_builder.build(); + + let data = StaticSoundData { + sample_rate: 1, + frames: (1..100).map(|_| Frame::from_mono(1.0)).collect(), + settings: StaticSoundSettings::new(), + slice: None, + }; + let (mut sound, mut handle) = data.split(); + + handle.pause(Tween { + duration: Duration::ZERO, + ..Default::default() + }); + sound.on_start_processing(); + sound.process_one(1.0, &info); + sound.on_start_processing(); + assert_eq!(handle.state(), PlaybackState::Paused); + + handle.resume_at( + StartTime::ClockTime(ClockTime { + clock: clock_id, + ticks: 1, + fraction: 0.0, + }), + Tween { + duration: Duration::from_secs(1), + ..Default::default() + }, + ); + sound.on_start_processing(); + sound.process_one(1.0, &info); + sound.on_start_processing(); + assert_eq!(handle.state(), PlaybackState::WaitingToResume); + + let info = { + let mut builder = MockInfoBuilder::new(); + builder.add_clock(true, 1, 0.0); + builder.build() + }; + sound.process_one(1.0, &info); + sound.on_start_processing(); + assert_eq!(handle.state(), PlaybackState::Resuming); + sound.process_one(1.0, &info); + sound.on_start_processing(); + assert_eq!(handle.state(), PlaybackState::Playing); +} + +/// Tests that a `StaticSound` can be played partially. +#[test] +#[allow(clippy::float_cmp)] +fn start_position() { + let data = StaticSoundData { + sample_rate: 1, + frames: (0..10).map(|i| Frame::from_mono(i as f32)).collect(), + settings: StaticSoundSettings::new().start_position(3.0), + slice: None, + }; + let (mut sound, handle) = data.split(); + + for i in 3..=6 { + assert_eq!(handle.position(), i as f64); + assert_eq!( + sound.process_one(1.0, &MockInfoBuilder::new().build()), + Frame::from_mono(i as f32).panned(Panning::CENTER) + ); + sound.on_start_processing(); + } +} + +/// Tests that starting a `StaticSound` past the end of the sound +/// will not cause a panic. +#[test] +#[allow(clippy::float_cmp)] +fn out_of_bounds_start_position() { + let data = StaticSoundData { + sample_rate: 1, + frames: (0..10).map(|i| Frame::from_mono(i as f32)).collect(), + settings: StaticSoundSettings::new().start_position(15.0), + slice: None, + }; + let (mut sound, _) = data.split(); + sound.process_one(1.0, &MockInfoBuilder::new().build()); +} + +/// Tests that a `StaticSound` properly obeys looping behavior when +/// playing forward. +#[test] +#[allow(clippy::float_cmp)] +fn loops_forward() { + let data = StaticSoundData { + sample_rate: 1, + frames: (0..10).map(|i| Frame::from_mono(i as f32)).collect(), + settings: StaticSoundSettings::new().loop_region(Some((3.0..6.0).into())), + slice: None, + }; + let (mut sound, _) = data.split(); + + for i in 0..6 { + assert_eq!( + sound.process_one(1.0, &MockInfoBuilder::new().build()), + Frame::from_mono(i as f32).panned(Panning::CENTER) + ); + } + + assert_eq!( + sound.process_one(2.0, &MockInfoBuilder::new().build()), + Frame::from_mono(3.0).panned(Panning::CENTER) + ); + assert_eq!( + sound.process_one(2.0, &MockInfoBuilder::new().build()), + Frame::from_mono(5.0).panned(Panning::CENTER) + ); + assert_eq!( + sound.process_one(2.0, &MockInfoBuilder::new().build()), + Frame::from_mono(4.0).panned(Panning::CENTER) + ); + assert_eq!( + sound.process_one(2.0, &MockInfoBuilder::new().build()), + Frame::from_mono(3.0).panned(Panning::CENTER) + ); +} + +/// Tests that the volume of a `StaticSound` can be adjusted. +#[test] +#[allow(clippy::float_cmp)] +fn volume() { + let data = StaticSoundData { + sample_rate: 1, + frames: Arc::new([Frame::from_mono(1.0); 10]), + settings: StaticSoundSettings::new().volume(-6.0), + slice: None, + }; + let (mut sound, _) = data.split(); + + assert_eq!( + sound.process_one(1.0, &MockInfoBuilder::new().build()), + Frame::from_mono(Decibels(-6.0).as_amplitude()).panned(Panning::CENTER) + ); +} + +/// Tests that the volume of a `StaticSound` can be changed +/// after the sound is started. +#[test] +fn set_volume() { + let data = StaticSoundData { + sample_rate: 1, + frames: Arc::new([Frame::from_mono(1.0); 10]), + settings: StaticSoundSettings::new(), + slice: None, + }; + let (mut sound, mut handle) = data.split(); + + assert_eq!( + sound.process_one(1.0, &MockInfoBuilder::new().build()), + Frame::from_mono(1.0).panned(Panning::CENTER) + ); + handle.set_volume( + -6.0, + Tween { + duration: Duration::ZERO, + ..Default::default() + }, + ); + sound.on_start_processing(); + expect_frame_soon( + Frame::from_mono(Decibels(-6.0).as_amplitude()).panned(Panning::CENTER), + &mut sound, + ); +} + +/// Tests that the panning of a `StaticSound` can be adjusted. +#[test] +#[allow(clippy::float_cmp)] +fn panning() { + let data = StaticSoundData { + sample_rate: 1, + frames: Arc::new([Frame::from_mono(1.0); 10]), + settings: StaticSoundSettings::new().panning(Panning::LEFT), + slice: None, + }; + let (mut sound, _) = data.split(); + + assert_eq!( + sound.process_one(1.0, &MockInfoBuilder::new().build()), + Frame::from_mono(1.0).panned(Panning::LEFT) + ); +} + +/// Tests that the panning of a `StaticSound` can be changed +/// after the sound is started. +#[test] +fn set_panning() { + let data = StaticSoundData { + sample_rate: 1, + frames: Arc::new([Frame::from_mono(1.0); 10]), + settings: StaticSoundSettings::new(), + slice: None, + }; + let (mut sound, mut handle) = data.split(); + + assert_eq!( + sound.process_one(1.0, &MockInfoBuilder::new().build()), + Frame::from_mono(1.0).panned(Panning::CENTER) + ); + handle.set_panning( + Panning::LEFT, + Tween { + duration: Duration::ZERO, + ..Default::default() + }, + ); + sound.on_start_processing(); + expect_frame_soon(Frame::from_mono(1.0).panned(Panning::LEFT), &mut sound); +} + +/// Tests that the playback rate of a `StaticSound` can be adjusted. +#[test] +#[allow(clippy::float_cmp)] +fn playback_rate() { + let data = StaticSoundData { + sample_rate: 1, + frames: (0..10).map(|i| Frame::from_mono(i as f32)).collect(), + settings: StaticSoundSettings::new().playback_rate(2.0), + slice: None, + }; + let (mut sound, _) = data.split(); + + assert_eq!( + sound.process_one(1.0, &MockInfoBuilder::new().build()), + Frame::from_mono(0.0).panned(Panning::CENTER) + ); + assert_eq!( + sound.process_one(1.0, &MockInfoBuilder::new().build()), + Frame::from_mono(2.0).panned(Panning::CENTER) + ); +} + +/// Tests that the playback rate of a `StaticSound` can be adjusted after +/// it's started. +#[test] +#[allow(clippy::float_cmp)] +fn set_playback_rate() { + let data = StaticSoundData { + sample_rate: 1, + frames: (0..100).map(|i| Frame::from_mono(i as f32)).collect(), + settings: StaticSoundSettings::new(), + slice: None, + }; + let (mut sound, mut handle) = data.split(); + + assert_eq!( + sound.process_one(1.0, &MockInfoBuilder::new().build()), + Frame::from_mono(0.0).panned(Panning::CENTER) + ); + assert_eq!( + sound.process_one(1.0, &MockInfoBuilder::new().build()), + Frame::from_mono(1.0).panned(Panning::CENTER) + ); + + handle.set_playback_rate( + 2.0, + Tween { + duration: Duration::ZERO, + ..Default::default() + }, + ); + sound.on_start_processing(); + + assert_eq!( + sound.process_one(1.0, &MockInfoBuilder::new().build()), + Frame::from_mono(2.0).panned(Panning::CENTER) + ); + assert_eq!( + sound.process_one(1.0, &MockInfoBuilder::new().build()), + Frame::from_mono(4.0).panned(Panning::CENTER) + ); +} + +/// Tests that a `StaticSound` outputs interpolated samples when +/// its playback position is between samples. +#[test] +fn interpolates_samples() { + let data = StaticSoundData { + sample_rate: 1, + frames: Arc::new([ + Frame::from_mono(0.0), + Frame::from_mono(1.0), + Frame::from_mono(1.0), + Frame::from_mono(1.0), + Frame::from_mono(1.0), + Frame::from_mono(1.0), + Frame::from_mono(-10.0), + ]), + settings: Default::default(), + slice: None, + }; + let (mut sound, _) = data.split(); + + assert_eq!( + sound.process_one(0.5, &MockInfoBuilder::new().build()), + Frame::from_mono(0.0).panned(Panning::CENTER) + ); + // at sample 0.5, the output should be somewhere between 0 and 1. + // i don't care what exactly, that's up the to the interpolation algorithm. + let frame = sound.process_one(5.0, &MockInfoBuilder::new().build()); + assert!(frame.left > 0.0 && frame.left < 1.0); + // at sample 5.5, the output should be between 1 and -10. + let frame = sound.process_one(1.0, &MockInfoBuilder::new().build()); + assert!(frame.left < 0.0 && frame.left > -10.0); +} + +/// Tests that a `StaticSound` outputs interpolated samples correctly +/// when looping. +#[test] +fn interpolates_samples_when_looping() { + let data = StaticSoundData { + sample_rate: 1, + frames: Arc::new([Frame::from_mono(10.0), Frame::from_mono(9.0)]), + settings: StaticSoundSettings::new().loop_region(Some((..).into())), + slice: None, + }; + let (mut sound, _) = data.split(); + sound.process_one(1.5, &MockInfoBuilder::new().build()); + // because we're looping back to the first sample, which is 10.0, + // the interpolated sample should be be tween 9.0 and 10.0 + let frame = sound.process_one(1.0, &MockInfoBuilder::new().build()); + assert!(frame.left > 9.0 && frame.left < 10.0); +} + +/// Tests that a `StaticSound` can seek to a position. +#[test] +fn seek_to() { + let data = StaticSoundData { + sample_rate: 1, + frames: (0..100).map(|i| Frame::from_mono(i as f32)).collect(), + settings: StaticSoundSettings::new(), + slice: None, + }; + let (mut sound, mut handle) = data.split(); + handle.seek_to(15.0); + sound.on_start_processing(); + expect_frame_soon(Frame::from_mono(15.0).panned(Panning::CENTER), &mut sound); +} + +/// Tests that a `StaticSound` can seek by an amount of time. +#[test] +fn seek_by() { + let data = StaticSoundData { + sample_rate: 1, + frames: (0..100).map(|i| Frame::from_mono(i as f32)).collect(), + settings: StaticSoundSettings::new().start_position(10.0), + slice: None, + }; + let (mut sound, mut handle) = data.split(); + handle.seek_by(5.0); + sound.on_start_processing(); + // we wouldn't actually expect the position to be 10.0 seconds right at + // this moment - the sound probably ran ahead a few samples to fill + // the resample buffer. so let's just say it should reach 20.0 soon. + expect_frame_soon(Frame::from_mono(20.0).panned(Panning::CENTER), &mut sound); +} + +/// Tests that a `StaticSound` can play in reverse. +#[test] +fn reverse() { + let data = StaticSoundData { + sample_rate: 1, + frames: (0..10).map(|i| Frame::from_mono(i as f32)).collect(), + settings: StaticSoundSettings::new().reverse(true), + slice: None, + }; + let (mut sound, _) = data.split(); + + for i in (4..=9).rev() { + assert_eq!( + sound.process_one(1.0, &MockInfoBuilder::new().build()), + Frame::from_mono(i as f32).panned(Panning::CENTER) + ); + } +} diff --git a/third_party/kira/src/sound/streaming.rs b/third_party/kira/src/sound/streaming.rs new file mode 100644 index 00000000..f76ee177 --- /dev/null +++ b/third_party/kira/src/sound/streaming.rs @@ -0,0 +1,113 @@ +/*! +Decodes data gradually from an audio file. + +To play a streaming sound, pass a [`StreamingSoundData`] to +[`AudioManager::play`](crate::AudioManager::play). + +```no_run +use kira::{ + AudioManager, AudioManagerSettings, DefaultBackend, + sound::streaming::{StreamingSoundData, StreamingSoundSettings}, +}; + +let mut manager = AudioManager::::new(AudioManagerSettings::default())?; +let sound_data = StreamingSoundData::from_file("sound.ogg")?; +manager.play(sound_data)?; +# Result::<(), Box>::Ok(()) +``` + +Streaming sounds use less memory than static sounds, but they use more +CPU, and they can have delays when starting or seeking. +*/ + +#![cfg_attr(docsrs, doc(cfg(not(wasm32))))] + +mod data; +mod decoder; +mod handle; +mod settings; +mod sound; + +pub use data::*; +pub use decoder::*; +pub use handle::*; +pub use settings::*; + +use crate::{ + Decibels, Panning, PlaybackRate, StartTime, Tween, + command::{CommandReader, CommandWriter, ValueChangeCommand, command_writer_and_reader}, +}; + +use super::Region; + +#[derive(Debug)] +pub(crate) struct CommandWriters { + set_volume: CommandWriter>, + set_playback_rate: CommandWriter>, + set_panning: CommandWriter>, + set_loop_region: CommandWriter>, + pause: CommandWriter, + resume: CommandWriter<(StartTime, Tween)>, + stop: CommandWriter, + seek_by: CommandWriter, + seek_to: CommandWriter, +} + +pub(crate) struct CommandReaders { + set_volume: CommandReader>, + set_playback_rate: CommandReader>, + set_panning: CommandReader>, + pause: CommandReader, + resume: CommandReader<(StartTime, Tween)>, + stop: CommandReader, +} + +#[derive(Debug)] +pub(crate) struct DecodeSchedulerCommandReaders { + set_loop_region: CommandReader>, + seek_by: CommandReader, + seek_to: CommandReader, +} + +#[must_use] +fn command_writers_and_readers() -> ( + CommandWriters, + CommandReaders, + DecodeSchedulerCommandReaders, +) { + let (set_volume_writer, set_volume_reader) = command_writer_and_reader(); + let (set_playback_rate_writer, set_playback_rate_reader) = command_writer_and_reader(); + let (set_panning_writer, set_panning_reader) = command_writer_and_reader(); + let (set_loop_region_writer, set_loop_region_reader) = command_writer_and_reader(); + let (pause_writer, pause_reader) = command_writer_and_reader(); + let (resume_writer, resume_reader) = command_writer_and_reader(); + let (stop_writer, stop_reader) = command_writer_and_reader(); + let (seek_by_writer, seek_by_reader) = command_writer_and_reader(); + let (seek_to_writer, seek_to_reader) = command_writer_and_reader(); + ( + CommandWriters { + set_volume: set_volume_writer, + set_playback_rate: set_playback_rate_writer, + set_panning: set_panning_writer, + set_loop_region: set_loop_region_writer, + pause: pause_writer, + resume: resume_writer, + stop: stop_writer, + seek_by: seek_by_writer, + seek_to: seek_to_writer, + }, + CommandReaders { + set_volume: set_volume_reader, + set_playback_rate: set_playback_rate_reader, + set_panning: set_panning_reader, + pause: pause_reader, + resume: resume_reader, + stop: stop_reader, + }, + DecodeSchedulerCommandReaders { + set_loop_region: set_loop_region_reader, + seek_by: seek_by_reader, + seek_to: seek_to_reader, + }, + ) +} diff --git a/third_party/kira/src/sound/streaming/data.rs b/third_party/kira/src/sound/streaming/data.rs new file mode 100644 index 00000000..803aed18 --- /dev/null +++ b/third_party/kira/src/sound/streaming/data.rs @@ -0,0 +1,411 @@ +#[cfg(test)] +mod test; + +use std::sync::Mutex; +use std::{sync::Arc, time::Duration}; + +use crate::sound::{EndPosition, IntoOptionalRegion, PlaybackPosition, Region, SoundData}; +use crate::{Decibels, Panning, PlaybackRate, StartTime}; +use crate::{Tween, Value}; +use rtrb::RingBuffer; + +use super::sound::Shared; +use super::{StreamingSoundHandle, StreamingSoundSettings, command_writers_and_readers}; + +use super::{ + decoder::Decoder, + sound::{StreamingSound, decode_scheduler::DecodeScheduler}, +}; + +const ERROR_BUFFER_CAPACITY: usize = 1; + +/// A streaming sound that is not playing yet. +pub struct StreamingSoundData { + pub(crate) decoder: Box>, + /// Settings for the streaming sound. + pub settings: StreamingSoundSettings, + /** + The portion of the sound this [`StreamingSoundData`] represents. + + Note that the [`StreamingSoundData`] holds the entire piece of audio + it was originally given regardless of the value of `slice`, but + [`StreamingSoundData::num_frames`] and [`StreamingSoundData::duration`] + will behave as if this [`StreamingSoundData`] only contained the specified + portion of audio. + */ + pub slice: Option<(usize, usize)>, +} + +impl StreamingSoundData { + /// Creates a [`StreamingSoundData`] for a [`Decoder`]. + #[must_use] + pub fn from_decoder(decoder: impl Decoder + 'static) -> Self { + Self { + decoder: Box::new(decoder), + settings: StreamingSoundSettings::default(), + slice: None, + } + } + + /** + Sets when the sound should start playing. + + # Examples + + Configuring a sound to start 4 ticks after a clock's current time: + + ```no_run + use kira::{ + AudioManager, AudioManagerSettings, DefaultBackend, + sound::streaming::{StreamingSoundData, StreamingSoundSettings}, + clock::ClockSpeed, + }; + + let mut manager = AudioManager::::new(AudioManagerSettings::default())?; + let clock_handle = manager.add_clock(ClockSpeed::TicksPerMinute(120.0))?; + let sound = StreamingSoundData::from_file("sound.ogg")? + .start_time(clock_handle.time() + 4); + # Result::<(), Box>::Ok(()) + ``` + */ + #[must_use = "This method consumes self and returns a modified StreamingSoundData, so the return value should be used"] + pub fn start_time(mut self, start_time: impl Into) -> Self { + self.settings.start_time = start_time.into(); + self + } + + /// Sets where in the sound playback should start. + #[must_use = "This method consumes self and returns a modified StreamingSoundData, so the return value should be used"] + pub fn start_position(mut self, start_position: impl Into) -> Self { + self.settings.start_position = start_position.into(); + self + } + + /** + Sets the portion of the sound that should be looped. + + # Examples + + Configure a sound to loop the portion from 3 seconds in to the end: + + ```no_run + # use kira::sound::streaming::StreamingSoundData; + let sound = StreamingSoundData::from_file("sound.ogg")?.loop_region(3.0..); + # Result::<(), Box>::Ok(()) + ``` + + Configure a sound to loop the portion from 2 to 4 seconds: + + ```no_run + # use kira::sound::streaming::StreamingSoundData; + let sound = StreamingSoundData::from_file("sound.ogg")?.loop_region(2.0..4.0); + # Result::<(), Box>::Ok(()) + ``` + */ + #[must_use = "This method consumes self and returns a modified StreamingSoundData, so the return value should be used"] + pub fn loop_region(mut self, loop_region: impl IntoOptionalRegion) -> Self { + self.settings.loop_region = loop_region.into_optional_region(); + self + } + + /** + Sets the volume of the sound. + + # Examples + + Set the volume to a fixed value: + + ```no_run + # use kira::sound::streaming::StreamingSoundData; + let sound = StreamingSoundData::from_file("sound.ogg")?.volume(-6.0); + # Result::<(), Box>::Ok(()) + ``` + + Link the volume to a modulator: + + ```no_run + use kira::{ + AudioManager, AudioManagerSettings, DefaultBackend, + modulator::tweener::TweenerBuilder, + sound::streaming::StreamingSoundData, + Value, Mapping, Easing, + Decibels, + }; + + let mut manager = AudioManager::::new(AudioManagerSettings::default())?; + let tweener = manager.add_modulator(TweenerBuilder { + initial_value: 0.5, + })?; + let sound = StreamingSoundData::from_file("sound.ogg")?.volume(Value::FromModulator { + id: tweener.id(), + mapping: Mapping { + input_range: (0.0, 1.0), + output_range: (Decibels::SILENCE, Decibels::IDENTITY), + easing: Easing::Linear, + }, + }); + # Result::<(), Box>::Ok(()) + ``` + */ + #[must_use = "This method consumes self and returns a modified StreamingSoundData, so the return value should be used"] + pub fn volume(mut self, volume: impl Into>) -> Self { + self.settings.volume = volume.into(); + self + } + + /** + Sets the playback rate of the sound. + + Changing the playback rate will change both the speed + and the pitch of the sound. + + # Examples + + Set the playback rate as a factor: + + ```no_run + # use kira::sound::streaming::StreamingSoundData; + let sound = StreamingSoundData::from_file("sound.ogg")?.playback_rate(0.5); + # Result::<(), Box>::Ok(()) + ``` + + Set the playback rate as a change in semitones: + + ```no_run + # use kira::sound::streaming::StreamingSoundData; + use kira::Semitones; + let sound = StreamingSoundData::from_file("sound.ogg")?.playback_rate(Semitones(-2.0)); + # Result::<(), Box>::Ok(()) + ``` + + Link the playback rate to a modulator: + + ```no_run + use kira::{ + AudioManager, AudioManagerSettings, DefaultBackend, + modulator::tweener::TweenerBuilder, + sound::streaming::StreamingSoundData, + Value, Easing, Mapping, + PlaybackRate, + }; + + let mut manager = AudioManager::::new(AudioManagerSettings::default())?; + let tweener = manager.add_modulator(TweenerBuilder { + initial_value: 0.5, + })?; + let sound = StreamingSoundData::from_file("sound.ogg")?.playback_rate(Value::FromModulator { + id: tweener.id(), + mapping: Mapping { + input_range: (0.0, 1.0), + output_range: (PlaybackRate(0.0), PlaybackRate(1.0)), + easing: Easing::Linear, + }, + }); + # Result::<(), Box>::Ok(()) + ``` + */ + #[must_use = "This method consumes self and returns a modified StreamingSoundData, so the return value should be used"] + pub fn playback_rate(mut self, playback_rate: impl Into>) -> Self { + self.settings.playback_rate = playback_rate.into(); + self + } + + /** + Sets the panning of the sound, where 0 is hard left + and 1 is hard right. + + # Examples + + Set the panning to a fixed value: + + ``` no_run + # use kira::sound::streaming::StreamingSoundData; + let sound = StreamingSoundData::from_file("sound.ogg")?.panning(-0.5); + # Result::<(), Box>::Ok(()) + ``` + + Link the panning to a modulator: + + ```no_run + use kira::{ + AudioManager, AudioManagerSettings, DefaultBackend, + modulator::tweener::TweenerBuilder, + sound::streaming::StreamingSoundData, + Value, Easing, Mapping, + Panning, + }; + + let mut manager = AudioManager::::new(AudioManagerSettings::default())?; + let tweener = manager.add_modulator(TweenerBuilder { + initial_value: -0.5, + })?; + let sound = StreamingSoundData::from_file("sound.ogg")?.panning(Value::FromModulator { + id: tweener.id(), + mapping: Mapping { + input_range: (-1.0, 1.0), + output_range: (Panning::LEFT, Panning::RIGHT), + easing: Easing::Linear, + }, + }); + # Result::<(), Box>::Ok(()) + ``` + */ + #[must_use = "This method consumes self and returns a modified StreamingSoundData, so the return value should be used"] + pub fn panning(mut self, panning: impl Into>) -> Self { + self.settings.panning = panning.into(); + self + } + + /// Sets the tween used to fade in the instance from silence. + #[must_use = "This method consumes self and returns a modified StreamingSoundData, so the return value should be used"] + pub fn fade_in_tween(mut self, fade_in_tween: impl Into>) -> Self { + self.settings.fade_in_tween = fade_in_tween.into(); + self + } + + /// Returns the `StreamingSoundData` with the specified settings. + #[must_use = "This method consumes self and returns a modified StreamingSoundData, so the return value should be used"] + pub fn with_settings(mut self, settings: StreamingSoundSettings) -> Self { + self.settings = settings; + self + } + + /// Returns the number of frames in the [`StreamingSoundData`]. + /// + /// If [`StreamingSoundData::slice`] is `Some`, this will be the number + /// of frames in the slice. + #[must_use] + pub fn num_frames(&self) -> usize { + if let Some((start, end)) = self.slice { + end - start + } else { + self.decoder.num_frames() + } + } + + /// Returns the duration of the audio. + /// + /// If [`StreamingSoundData::slice`] is `Some`, this will be the duration + /// of the slice. + #[must_use] + pub fn duration(&self) -> Duration { + Duration::from_secs_f64(self.num_frames() as f64 / self.decoder.sample_rate() as f64) + } + + /// Returns the total duration of the audio, regardless of its slice. + #[must_use] + pub fn unsliced_duration(&self) -> Duration { + Duration::from_secs_f64( + self.decoder.num_frames() as f64 / self.decoder.sample_rate() as f64, + ) + } + + /** + Sets the portion of the audio this [`StreamingSoundData`] represents. + */ + #[must_use = "This method consumes self and returns a modified StreamingSoundData, so the return value should be used"] + pub fn slice(mut self, region: impl IntoOptionalRegion) -> Self { + self.slice = region.into_optional_region().map(|Region { start, end }| { + let start = start.into_samples(self.decoder.sample_rate()); + let end = match end { + EndPosition::EndOfAudio => self.decoder.num_frames(), + EndPosition::Custom(end) => end.into_samples(self.decoder.sample_rate()), + }; + (start, end) + }); + self + } +} + +impl StreamingSoundData {} + +#[cfg(feature = "symphonia")] +impl StreamingSoundData { + /// Creates a [`StreamingSoundData`] for an audio file. + pub fn from_file( + path: impl AsRef, + ) -> Result, crate::sound::FromFileError> { + use std::fs::File; + + use super::symphonia::SymphoniaDecoder; + + Ok(Self::from_decoder(SymphoniaDecoder::new(Box::new( + File::open(path)?, + ))?)) + } + + /// Creates a [`StreamingSoundData`] for a cursor wrapping audio file data. + pub fn from_cursor + Send + Sync + 'static>( + cursor: std::io::Cursor, + ) -> Result, crate::sound::FromFileError> { + use super::symphonia::SymphoniaDecoder; + + Ok(Self::from_decoder(SymphoniaDecoder::new(Box::new(cursor))?)) + } + + /// Creates a [`StreamingSoundData`] for a type that implements Symphonia's + /// [`MediaSource`](symphonia::core::io::MediaSource) trait. + pub fn from_media_source( + media_source: impl symphonia::core::io::MediaSource + 'static, + ) -> Result, crate::sound::FromFileError> { + use super::symphonia::SymphoniaDecoder; + + Ok(Self::from_decoder(SymphoniaDecoder::new(Box::new( + media_source, + ))?)) + } +} + +impl StreamingSoundData { + pub(crate) fn split( + self, + ) -> Result< + ( + StreamingSound, + StreamingSoundHandle, + DecodeScheduler, + ), + Error, + > { + let (command_writers, command_readers, decode_scheduler_command_readers) = + command_writers_and_readers(); + let (error_producer, error_consumer) = RingBuffer::new(ERROR_BUFFER_CAPACITY); + let sample_rate = self.decoder.sample_rate(); + let shared = Arc::new(Shared::new()); + let (scheduler, frame_consumer) = DecodeScheduler::new( + self.decoder, + self.slice, + self.settings, + shared.clone(), + decode_scheduler_command_readers, + error_producer, + )?; + let sound = StreamingSound::new( + sample_rate, + self.settings, + shared.clone(), + frame_consumer, + command_readers, + &scheduler, + ); + let handle = StreamingSoundHandle { + shared, + command_writers, + error_consumer: Mutex::new(error_consumer), + }; + Ok((sound, handle, scheduler)) + } +} + +impl SoundData for StreamingSoundData { + type Error = Error; + + type Handle = StreamingSoundHandle; + + #[allow(clippy::type_complexity)] + fn into_sound(self) -> Result<(Box, Self::Handle), Self::Error> { + let (sound, handle, scheduler) = self.split()?; + scheduler.start(); + Ok((Box::new(sound), handle)) + } +} diff --git a/third_party/kira/src/sound/streaming/data/test.rs b/third_party/kira/src/sound/streaming/data/test.rs new file mode 100644 index 00000000..12c1f5fe --- /dev/null +++ b/third_party/kira/src/sound/streaming/data/test.rs @@ -0,0 +1,36 @@ +use std::time::Duration; + +use crate::{ + frame::Frame, + sound::streaming::{StreamingSoundData, mock::MockDecoder}, +}; + +#[test] +fn duration() { + let sound = StreamingSoundData { + decoder: Box::new(MockDecoder::new(vec![Frame::from_mono(0.5); 4])), + settings: Default::default(), + slice: None, + }; + assert_eq!(sound.duration(), Duration::from_secs(4)); +} + +#[test] +fn unsliced_duration() { + let sound = StreamingSoundData { + decoder: Box::new(MockDecoder::new(vec![Frame::from_mono(0.5); 4])), + settings: Default::default(), + slice: Some((2, 3)), + }; + assert_eq!(sound.unsliced_duration(), Duration::from_secs(4)); +} + +#[test] +fn sliced_duration() { + let sound = StreamingSoundData { + decoder: Box::new(MockDecoder::new(vec![Frame::from_mono(0.5); 4])), + settings: Default::default(), + slice: Some((2, 3)), + }; + assert_eq!(sound.duration(), Duration::from_secs(1)); +} diff --git a/third_party/kira/src/sound/streaming/decoder.rs b/third_party/kira/src/sound/streaming/decoder.rs new file mode 100644 index 00000000..71fa0286 --- /dev/null +++ b/third_party/kira/src/sound/streaming/decoder.rs @@ -0,0 +1,33 @@ +#[cfg(test)] +pub(crate) mod mock; +#[cfg(feature = "symphonia")] +pub(crate) mod symphonia; + +use crate::frame::Frame; + +/// Decodes chunks of audio. +pub trait Decoder: Send { + /// Errors that can occur when decoding audio. + type Error; + + /// Returns the sample rate of the audio (in Hz). + #[must_use] + fn sample_rate(&self) -> u32; + + /// Returns the total number of samples of audio. + #[must_use] + fn num_frames(&self) -> usize; + + /// Decodes the next chunk of audio. + fn decode(&mut self) -> Result, Self::Error>; + + /// Seeks to an audio sample. + /// + /// The `index` is the _requested_ sample to seek to. It's OK if the decoder + /// seeks to an earlier sample than the one requested. + /// + /// This should return the sample index that was _actually_ seeked to. + fn seek(&mut self, index: usize) -> Result; +} + +type SeekedToIndex = usize; diff --git a/third_party/kira/src/sound/streaming/decoder/mock.rs b/third_party/kira/src/sound/streaming/decoder/mock.rs new file mode 100644 index 00000000..c7d4fd27 --- /dev/null +++ b/third_party/kira/src/sound/streaming/decoder/mock.rs @@ -0,0 +1,61 @@ +use crate::frame::Frame; + +use super::Decoder; + +const MOCK_DECODER_SAMPLE_RATE: u32 = 1; +const MOCK_DECODER_PACKET_SIZE: usize = 3; + +pub(crate) struct MockDecoder { + frames: Vec, + current_frame_index: usize, +} + +impl MockDecoder { + #[must_use] + pub(crate) fn new(frames: Vec) -> Self { + Self { + frames, + current_frame_index: 0, + } + } +} + +impl Decoder for MockDecoder { + type Error = MockDecoderError; + + fn sample_rate(&self) -> u32 { + MOCK_DECODER_SAMPLE_RATE + } + + fn num_frames(&self) -> usize { + self.frames.len() + } + + fn decode(&mut self) -> Result, Self::Error> { + let mut frames = vec![]; + for _ in 0..MOCK_DECODER_PACKET_SIZE { + let frame = self.frames[self.current_frame_index]; + if frame.left.is_nan() || frame.right.is_nan() { + return Err(MockDecoderError); + } + frames.push(frame); + self.current_frame_index += 1; + if self.current_frame_index >= self.frames.len() { + break; + } + } + Ok(frames) + } + + fn seek(&mut self, index: usize) -> Result { + // seek to the beginning of the "packet" to simulate + // seeking behavior with real decoders + let index = + (index as f64 / MOCK_DECODER_PACKET_SIZE as f64) as usize * MOCK_DECODER_PACKET_SIZE; + self.current_frame_index = index; + Ok(index) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub(crate) struct MockDecoderError; diff --git a/third_party/kira/src/sound/streaming/decoder/symphonia.rs b/third_party/kira/src/sound/streaming/decoder/symphonia.rs new file mode 100644 index 00000000..9350f6f7 --- /dev/null +++ b/third_party/kira/src/sound/streaming/decoder/symphonia.rs @@ -0,0 +1,95 @@ +use std::convert::TryInto; + +use crate::{ + frame::Frame, + sound::{FromFileError, symphonia::load_frames_from_buffer_ref}, +}; +use symphonia::core::{ + codecs::Decoder, + formats::{FormatReader, SeekMode, SeekTo}, + io::{MediaSource, MediaSourceStream}, + probe::Hint, +}; + +pub(crate) struct SymphoniaDecoder { + format_reader: Box, + decoder: Box, + sample_rate: u32, + num_frames: usize, + track_id: u32, +} + +impl SymphoniaDecoder { + pub(crate) fn new(media_source: Box) -> Result { + let codecs = symphonia::default::get_codecs(); + let probe = symphonia::default::get_probe(); + let mss = MediaSourceStream::new(media_source, Default::default()); + let format_reader = probe + .format( + &Hint::default(), + mss, + &Default::default(), + &Default::default(), + )? + .format; + let default_track = format_reader + .default_track() + .ok_or(FromFileError::NoDefaultTrack)?; + let sample_rate = default_track + .codec_params + .sample_rate + .ok_or(FromFileError::UnknownSampleRate)?; + let num_frames = default_track + .codec_params + .n_frames + .ok_or(FromFileError::UnknownSampleRate)? + .try_into() + .expect("could not convert u64 into usize"); + let decoder = codecs.make(&default_track.codec_params, &Default::default())?; + let track_id = default_track.id; + Ok(Self { + format_reader, + decoder, + sample_rate, + num_frames, + track_id, + }) + } +} + +impl super::Decoder for SymphoniaDecoder { + type Error = FromFileError; + + fn sample_rate(&self) -> u32 { + self.sample_rate + } + + fn num_frames(&self) -> usize { + self.num_frames + } + + fn decode(&mut self) -> Result, Self::Error> { + let packet = loop { + let packet = self.format_reader.next_packet()?; + if self.track_id == packet.track_id() { + break packet; + } + }; + let buffer = self.decoder.decode(&packet)?; + load_frames_from_buffer_ref(&buffer) + } + + fn seek(&mut self, index: usize) -> Result { + let seeked_to = self.format_reader.seek( + SeekMode::Accurate, + SeekTo::TimeStamp { + ts: index.try_into().expect("could not convert usize into u64"), + track_id: self.track_id, + }, + )?; + Ok(seeked_to + .actual_ts + .try_into() + .expect("could not convert u64 into usize")) + } +} diff --git a/third_party/kira/src/sound/streaming/handle.rs b/third_party/kira/src/sound/streaming/handle.rs new file mode 100644 index 00000000..803f34a1 --- /dev/null +++ b/third_party/kira/src/sound/streaming/handle.rs @@ -0,0 +1,355 @@ +use std::{ + fmt::{Debug, Formatter}, + sync::{Arc, Mutex}, +}; + +use crate::{ + Decibels, Panning, PlaybackRate, StartTime, Tween, + command::handle_param_setters, + sound::{IntoOptionalRegion, PlaybackState}, +}; +use rtrb::Consumer; + +use super::{CommandWriters, sound::Shared}; + +/// Controls a streaming sound. +pub struct StreamingSoundHandle { + pub(super) shared: Arc, + pub(super) command_writers: CommandWriters, + pub(super) error_consumer: Mutex>, +} + +impl StreamingSoundHandle { + /// Returns the current playback state of the sound. + #[must_use] + pub fn state(&self) -> PlaybackState { + self.shared.state() + } + + /// Returns the current playback position of the sound (in seconds). + #[must_use] + pub fn position(&self) -> f64 { + self.shared.position() + } + + handle_param_setters! { + /** + Sets the volume of the sound. + + # Examples + + Set the volume of the sound immediately: + + ```no_run + # use kira::{ + # AudioManager, AudioManagerSettings, DefaultBackend, + # sound::streaming::{StreamingSoundData, StreamingSoundSettings}, + # }; + # let mut manager = AudioManager::::new(AudioManagerSettings::default())?; + # let mut sound = manager.play(StreamingSoundData::from_file("sound.ogg")?)?; + use kira::Tween; + + sound.set_volume(-6.0, Tween::default()); + # Result::<(), Box>::Ok(()) + ``` + + Smoothly transition the volume to a target volume: + + ```no_run + # use kira::{ + # AudioManager, AudioManagerSettings, DefaultBackend, + # sound::streaming::{StreamingSoundData, StreamingSoundSettings}, + # }; + # let mut manager = AudioManager::::new(AudioManagerSettings::default())?; + # let mut sound = manager.play(StreamingSoundData::from_file("sound.ogg")?)?; + use kira::Tween; + use std::time::Duration; + + sound.set_volume(-6.0, Tween { + duration: Duration::from_secs(3), + ..Default::default() + }); + # Result::<(), Box>::Ok(()) + ``` + + Link the volume to a modulator, smoothly transitioning from the current value: + + ```no_run + use kira::{ + AudioManager, AudioManagerSettings, DefaultBackend, + sound::streaming::{StreamingSoundData, StreamingSoundSettings}, + modulator::tweener::TweenerBuilder, + Value, Tween, Mapping, Easing, + Decibels, + }; + use std::time::Duration; + + let mut manager = AudioManager::::new(AudioManagerSettings::default())?; + let tweener = manager.add_modulator(TweenerBuilder { + initial_value: 0.5, + })?; + let mut sound = manager.play(StreamingSoundData::from_file("sound.ogg")?)?; + sound.set_volume(Value::FromModulator { + id: tweener.id(), + mapping: Mapping { + input_range: (0.0, 1.0), + output_range: (Decibels::SILENCE, Decibels::IDENTITY), + easing: Easing::Linear, + } + }, Tween { + duration: Duration::from_secs(3), + ..Default::default() + }); + # Result::<(), Box>::Ok(()) + ``` + */ + volume: Decibels, + + /** + Sets the playback rate of the sound. + + Changing the playback rate will change both the speed + and pitch of the sound. + + # Examples + + Set the playback rate of the sound immediately: + + ```no_run + # use kira::{ + # AudioManager, AudioManagerSettings, DefaultBackend, + # sound::streaming::{StreamingSoundData, StreamingSoundSettings}, + # }; + # let mut manager = AudioManager::::new(AudioManagerSettings::default())?; + # let mut sound = manager.play(StreamingSoundData::from_file("sound.ogg")?)?; + use kira::Tween; + + sound.set_playback_rate(0.5, Tween::default()); + # Result::<(), Box>::Ok(()) + ``` + + Smoothly transition the playback rate to a target value in semitones: + + ```no_run + # use kira::{ + # AudioManager, AudioManagerSettings, DefaultBackend, + # sound::streaming::{StreamingSoundData, StreamingSoundSettings}, + # }; + # let mut manager = AudioManager::::new(AudioManagerSettings::default())?; + # let mut sound = manager.play(StreamingSoundData::from_file("sound.ogg")?)?; + use kira::{Tween, Semitones}; + use std::time::Duration; + + sound.set_playback_rate(Semitones(-2.0), Tween { + duration: Duration::from_secs(3), + ..Default::default() + }); + # Result::<(), Box>::Ok(()) + ``` + + Link the playback rate to a modulator, smoothly transitioning from the current value: + + ```no_run + use kira::{ + AudioManager, AudioManagerSettings, DefaultBackend, + sound::streaming::{StreamingSoundData, StreamingSoundSettings}, + modulator::tweener::TweenerBuilder, + Value, Easing, Mapping, Tween, + PlaybackRate, + }; + use std::time::Duration; + + let mut manager = AudioManager::::new(AudioManagerSettings::default())?; + let tweener = manager.add_modulator(TweenerBuilder { + initial_value: 0.5, + })?; + let mut sound = manager.play(StreamingSoundData::from_file("sound.ogg")?)?; + sound.set_playback_rate(Value::FromModulator { + id: tweener.id(), + mapping: Mapping { + input_range: (0.0, 1.0), + output_range: (PlaybackRate(0.0), PlaybackRate(1.0)), + easing: Easing::Linear, + }, + }, Tween { + duration: Duration::from_secs(3), + ..Default::default() + }); + # Result::<(), Box>::Ok(()) + ``` + */ + playback_rate: PlaybackRate, + + /** + Sets the panning of the sound, where `-1.0` is hard left, + `0.0` is center, and `1.0` is hard right. + + # Examples + + Smoothly transition the panning to a target value: + + ```no_run + # use kira::{ + # AudioManager, AudioManagerSettings, DefaultBackend, + # sound::streaming::{StreamingSoundData, StreamingSoundSettings}, + # }; + # let mut manager = AudioManager::::new(AudioManagerSettings::default())?; + # let mut sound = manager.play(StreamingSoundData::from_file("sound.ogg")?)?; + use kira::Tween; + use std::time::Duration; + + sound.set_panning(-0.5, Tween { + duration: Duration::from_secs(3), + ..Default::default() + }); + # Result::<(), Box>::Ok(()) + ``` + + Link the panning to a modulator, smoothly transitioning from the current value: + + ```no_run + use kira::{ + AudioManager, AudioManagerSettings, DefaultBackend, + sound::streaming::{StreamingSoundData, StreamingSoundSettings}, + modulator::tweener::TweenerBuilder, + Value, Easing, Mapping, Tween, + Panning, + }; + use std::time::Duration; + + let mut manager = AudioManager::::new(AudioManagerSettings::default())?; + let tweener = manager.add_modulator(TweenerBuilder { + initial_value: -0.5, + })?; + let mut sound = manager.play(StreamingSoundData::from_file("sound.ogg")?)?; + sound.set_panning(Value::FromModulator { + id: tweener.id(), + mapping: Mapping { + input_range: (-1.0, 1.0), + output_range: (Panning::LEFT, Panning::RIGHT), + easing: Easing::Linear, + }, + }, Tween { + duration: Duration::from_secs(3), + ..Default::default() + }); + # Result::<(), Box>::Ok(()) + ``` + */ + panning: Panning, + } + + /** + Sets the portion of the sound that will play in a loop. + + # Examples + + Set the sound to loop the portion from 3 seconds in to the end: + + ```no_run + # use kira::{ + # AudioManager, AudioManagerSettings, DefaultBackend, + # sound::streaming::{StreamingSoundData, StreamingSoundSettings}, + # }; + # let mut manager = AudioManager::::new(AudioManagerSettings::default())?; + # let mut sound = manager.play(StreamingSoundData::from_file("sound.ogg")?)?; + sound.set_loop_region(3.0..); + # Result::<(), Box>::Ok(()) + ``` + + Set the sound to loop the portion from 2 to 4 seconds: + + ```no_run + # use kira::{ + # AudioManager, AudioManagerSettings, DefaultBackend, + # sound::streaming::{StreamingSoundData, StreamingSoundSettings}, + # }; + # let mut manager = AudioManager::::new(AudioManagerSettings::default())?; + # let mut sound = manager.play(StreamingSoundData::from_file("sound.ogg")?)?; + sound.set_loop_region(2.0..4.0); + # Result::<(), Box>::Ok(()) + ``` + + Set a sound that was previously looping to stop looping: + + ```no_run + # use kira::{ + # AudioManager, AudioManagerSettings, DefaultBackend, + # sound::streaming::{StreamingSoundData, StreamingSoundSettings}, + # }; + # let mut manager = AudioManager::::new(AudioManagerSettings::default())?; + # let mut sound = manager.play(StreamingSoundData::from_file("sound.ogg")?)?; + sound.set_loop_region(None); + # Result::<(), Box>::Ok(()) + ``` + */ + pub fn set_loop_region(&mut self, loop_region: impl IntoOptionalRegion) { + self.command_writers + .set_loop_region + .write(loop_region.into_optional_region()) + } + + /// Fades out the sound to silence with the given tween and then + /// pauses playback. + pub fn pause(&mut self, tween: Tween) { + self.command_writers.pause.write(tween) + } + + /// Resumes playback and fades in the sound from silence + /// with the given tween. + pub fn resume(&mut self, tween: Tween) { + self.resume_at(StartTime::Immediate, tween) + } + + /// Resumes playback at the given start time and fades in + /// the sound from silence with the given tween. + pub fn resume_at(&mut self, start_time: StartTime, tween: Tween) { + self.command_writers.resume.write((start_time, tween)) + } + + /// Fades out the sound to silence with the given tween and then + /// stops playback. + /// + /// Once the sound is stopped, it cannot be restarted. + pub fn stop(&mut self, tween: Tween) { + self.command_writers.stop.write(tween) + } + + /// Sets the playback position to the specified time in seconds. + pub fn seek_to(&mut self, position: f64) { + self.command_writers.seek_to.write(position) + } + + /// Moves the playback position by the specified amount of time in seconds. + pub fn seek_by(&mut self, amount: f64) { + self.command_writers.seek_by.write(amount) + } + + /// Returns an error that occurred while decoding audio, if any. + #[must_use] + pub fn pop_error(&mut self) -> Option { + self.error_consumer + .lock() + .expect("error consumer mutex poisoned") + .pop() + .ok() + } +} + +impl Debug for StreamingSoundHandle { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("StreamingSoundHandle") + .field("shared", &self.shared) + .field("command_writers", &self.command_writers) + .field("error_consumer", &ConsumerDebug) + .finish() + } +} + +struct ConsumerDebug; + +impl Debug for ConsumerDebug { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Consumer").finish() + } +} diff --git a/third_party/kira/src/sound/streaming/settings.rs b/third_party/kira/src/sound/streaming/settings.rs new file mode 100644 index 00000000..b672742e --- /dev/null +++ b/third_party/kira/src/sound/streaming/settings.rs @@ -0,0 +1,120 @@ +use crate::{ + Decibels, Panning, PlaybackRate, StartTime, Tween, Value, + sound::{IntoOptionalRegion, PlaybackPosition, Region}, +}; + +/// Settings for a streaming sound. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct StreamingSoundSettings { + /// When the sound should start playing. + pub start_time: StartTime, + /// Where in the sound playback should start. + pub start_position: PlaybackPosition, + /// The portion of the sound that should be looped. + pub loop_region: Option, + /// The volume of the sound. + pub volume: Value, + /// The playback rate of the sound. + /// + /// Changing the playback rate will change both the speed + /// and the pitch of the sound. + pub playback_rate: Value, + /// The panning of the sound, where 0 is hard left + /// and 1 is hard right. + pub panning: Value, + /// An optional fade-in from silence. + pub fade_in_tween: Option, +} + +impl StreamingSoundSettings { + /// Creates a new [`StreamingSoundSettings`] with the default settings. + #[must_use] + pub fn new() -> Self { + Self { + start_time: StartTime::Immediate, + start_position: PlaybackPosition::Seconds(0.0), + loop_region: None, + volume: Value::Fixed(Decibels::IDENTITY), + playback_rate: Value::Fixed(PlaybackRate(1.0)), + panning: Value::Fixed(Panning::CENTER), + fade_in_tween: None, + } + } + + /** Sets when the sound should start playing. */ + #[must_use = "This method consumes self and returns a modified StreamingSoundSettings, so the return value should be used"] + pub fn start_time(self, start_time: impl Into) -> Self { + Self { + start_time: start_time.into(), + ..self + } + } + + /// Sets where in the sound playback should start. + #[must_use = "This method consumes self and returns a modified StreamingSoundSettings, so the return value should be used"] + pub fn start_position(self, start_position: impl Into) -> Self { + Self { + start_position: start_position.into(), + ..self + } + } + + /** Sets the portion of the sound that should be looped. */ + #[must_use = "This method consumes self and returns a modified StreamingSoundSettings, so the return value should be used"] + pub fn loop_region(self, loop_region: impl IntoOptionalRegion) -> Self { + Self { + loop_region: loop_region.into_optional_region(), + ..self + } + } + + /** Sets the volume of the sound. */ + #[must_use = "This method consumes self and returns a modified StreamingSoundSettings, so the return value should be used"] + pub fn volume(self, volume: impl Into>) -> Self { + Self { + volume: volume.into(), + ..self + } + } + + /** + Sets the playback rate of the sound. + + Changing the playback rate will change both the speed + and the pitch of the sound. + */ + #[must_use = "This method consumes self and returns a modified StreamingSoundSettings, so the return value should be used"] + pub fn playback_rate(self, playback_rate: impl Into>) -> Self { + Self { + playback_rate: playback_rate.into(), + ..self + } + } + + /** + Sets the panning of the sound, where -1.0 is hard left + and 1.0 is hard right. + */ + #[must_use = "This method consumes self and returns a modified StreamingSoundSettings, so the return value should be used"] + pub fn panning(self, panning: impl Into>) -> Self { + Self { + panning: panning.into(), + ..self + } + } + + /// Sets the tween used to fade in the instance from silence. + #[must_use = "This method consumes self and returns a modified StreamingSoundSettings, so the return value should be used"] + pub fn fade_in_tween(self, fade_in_tween: impl Into>) -> Self { + Self { + fade_in_tween: fade_in_tween.into(), + ..self + } + } +} + +impl Default for StreamingSoundSettings { + fn default() -> Self { + Self::new() + } +} diff --git a/third_party/kira/src/sound/streaming/sound.rs b/third_party/kira/src/sound/streaming/sound.rs new file mode 100644 index 00000000..6f0423b2 --- /dev/null +++ b/third_party/kira/src/sound/streaming/sound.rs @@ -0,0 +1,283 @@ +pub(crate) mod decode_scheduler; + +#[cfg(test)] +mod test; + +use std::sync::{ + Arc, + atomic::{AtomicBool, AtomicU8, AtomicU64, Ordering}, +}; + +use crate::{ + Decibels, Panning, Parameter, PlaybackRate, StartTime, Tween, + command::read_commands_into_parameters, + frame::{Frame, interpolate_frame}, + info::Info, + playback_state_manager::PlaybackStateManager, + sound::{PlaybackState, Sound}, +}; +use rtrb::Consumer; + +use super::{CommandReaders, StreamingSoundSettings}; + +use self::decode_scheduler::DecodeScheduler; + +#[derive(Debug)] +pub(crate) struct Shared { + state: AtomicU8, + position: AtomicU64, + reached_end: AtomicBool, + encountered_error: AtomicBool, +} + +impl Shared { + #[must_use] + pub fn new() -> Self { + Self { + position: AtomicU64::new(0.0f64.to_bits()), + state: AtomicU8::new(PlaybackState::Playing as u8), + reached_end: AtomicBool::new(false), + encountered_error: AtomicBool::new(false), + } + } + + #[must_use] + pub fn state(&self) -> PlaybackState { + match self.state.load(Ordering::SeqCst) { + 0 => PlaybackState::Playing, + 1 => PlaybackState::Pausing, + 2 => PlaybackState::Paused, + 3 => PlaybackState::WaitingToResume, + 4 => PlaybackState::Resuming, + 5 => PlaybackState::Stopping, + 6 => PlaybackState::Stopped, + _ => panic!("Invalid playback state"), + } + } + + pub fn set_state(&self, state: PlaybackState) { + self.state.store(state as u8, Ordering::SeqCst); + } + + #[must_use] + pub fn position(&self) -> f64 { + f64::from_bits(self.position.load(Ordering::SeqCst)) + } + + #[must_use] + pub fn reached_end(&self) -> bool { + self.reached_end.load(Ordering::SeqCst) + } + + #[must_use] + pub fn encountered_error(&self) -> bool { + self.encountered_error.load(Ordering::SeqCst) + } +} + +pub(crate) struct StreamingSound { + command_readers: CommandReaders, + sample_rate: u32, + frame_consumer: Consumer, + start_time: StartTime, + playback_state_manager: PlaybackStateManager, + current_frame: usize, + fractional_position: f64, + volume: Parameter, + playback_rate: Parameter, + panning: Parameter, + shared: Arc, +} + +impl StreamingSound { + #[must_use] + pub(super) fn new( + sample_rate: u32, + settings: StreamingSoundSettings, + shared: Arc, + frame_consumer: Consumer, + command_readers: CommandReaders, + scheduler: &DecodeScheduler, + ) -> Self { + let current_frame = scheduler.current_frame(); + let start_position = current_frame as f64 / sample_rate as f64; + shared + .position + .store(start_position.to_bits(), Ordering::SeqCst); + Self { + command_readers, + sample_rate, + frame_consumer, + start_time: settings.start_time, + playback_state_manager: PlaybackStateManager::new(settings.fade_in_tween), + current_frame, + fractional_position: 0.0, + volume: Parameter::new(settings.volume, Decibels::IDENTITY), + playback_rate: Parameter::new(settings.playback_rate, PlaybackRate(1.0)), + panning: Parameter::new(settings.panning, Panning::CENTER), + shared, + } + } + + fn update_shared_playback_state(&mut self) { + self.shared + .set_state(self.playback_state_manager.playback_state()); + } + + fn update_current_frame(&mut self) { + let chunk = self + .frame_consumer + .read_chunk(self.frame_consumer.slots().min(4)) + .unwrap(); + let (a, b) = chunk.as_slices(); + let mut iter = a.iter().chain(b.iter()); + if let Some(TimestampedFrame { index, .. }) = iter.nth(1) { + self.current_frame = *index; + } + } + + #[must_use] + fn next_frames(&mut self) -> [Frame; 4] { + let mut frames = [Frame::ZERO; 4]; + let chunk = self + .frame_consumer + .read_chunk(self.frame_consumer.slots().min(4)) + .unwrap(); + let (a, b) = chunk.as_slices(); + let mut iter = a.iter().chain(b.iter()); + for frame in &mut frames { + *frame = iter + .next() + .copied() + .map(|TimestampedFrame { frame, .. }| frame) + .unwrap_or(Frame::ZERO); + } + frames + } + + #[must_use] + fn position(&self) -> f64 { + (self.current_frame as f64 + self.fractional_position) / self.sample_rate as f64 + } + + fn pause(&mut self, fade_out_tween: Tween) { + self.playback_state_manager.pause(fade_out_tween); + self.update_shared_playback_state(); + } + + fn resume(&mut self, start_time: StartTime, fade_in_tween: Tween) { + self.playback_state_manager + .resume(start_time, fade_in_tween); + self.update_shared_playback_state(); + } + + fn stop(&mut self, fade_out_tween: Tween) { + self.playback_state_manager.stop(fade_out_tween); + self.update_shared_playback_state(); + } + + fn read_commands(&mut self) { + read_commands_into_parameters!(self, volume, playback_rate, panning); + if let Some(tween) = self.command_readers.pause.read() { + self.pause(tween); + } + if let Some((start_time, tween)) = self.command_readers.resume.read() { + self.resume(start_time, tween); + } + if let Some(tween) = self.command_readers.stop.read() { + self.stop(tween); + } + } +} + +impl Sound for StreamingSound { + fn on_start_processing(&mut self) { + self.update_current_frame(); + self.shared + .position + .store(self.position().to_bits(), Ordering::SeqCst); + self.read_commands(); + } + + fn process(&mut self, out: &mut [Frame], dt: f64, info: &Info) { + if self.shared.encountered_error() { + self.playback_state_manager.mark_as_stopped(); + self.update_shared_playback_state(); + out.fill(Frame::ZERO); + return; + } + + // update parameters + self.volume.update(dt * out.len() as f64, info); + self.playback_rate.update(dt * out.len() as f64, info); + self.panning.update(dt * out.len() as f64, info); + let changed_playback_state = self + .playback_state_manager + .update(dt * out.len() as f64, info); + if changed_playback_state { + self.update_shared_playback_state(); + } + + let will_never_start = self.start_time.update(dt * out.len() as f64, info); + if will_never_start { + self.playback_state_manager.mark_as_stopped(); + self.update_shared_playback_state(); + } + if self.start_time != StartTime::Immediate { + out.fill(Frame::ZERO); + return; + } + + if !self.playback_state_manager.playback_state().is_advancing() { + out.fill(Frame::ZERO); + return; + } + // pause playback while waiting for audio data. the first frame + // in the ringbuffer is the previous frame, so we need to make + // sure there's at least 2 before we continue playing. + if self.frame_consumer.slots() < 2 && !self.shared.reached_end() { + out.fill(Frame::ZERO); + return; + } + + let num_frames = out.len(); + for (i, frame) in out.iter_mut().enumerate() { + let time_in_chunk = (i + 1) as f64 / num_frames as f64; + let volume = self.volume.interpolated_value(time_in_chunk).as_amplitude(); + let fade_volume = self + .playback_state_manager + .interpolated_fade_volume(time_in_chunk) + .as_amplitude(); + let panning = self.panning.interpolated_value(time_in_chunk); + let playback_rate = self.playback_rate.interpolated_value(time_in_chunk); + let next_frames = self.next_frames(); + let interpolated_out = interpolate_frame( + next_frames[0], + next_frames[1], + next_frames[2], + next_frames[3], + self.fractional_position as f32, + ); + self.fractional_position += self.sample_rate as f64 * playback_rate.0.max(0.0) * dt; + while self.fractional_position >= 1.0 { + self.fractional_position -= 1.0; + self.frame_consumer.pop().ok(); + } + if self.shared.reached_end() && self.frame_consumer.is_empty() { + self.playback_state_manager.mark_as_stopped(); + self.update_shared_playback_state(); + } + *frame = (interpolated_out * fade_volume * volume).panned(panning); + } + } + + fn finished(&self) -> bool { + self.playback_state_manager.playback_state() == PlaybackState::Stopped + } +} + +#[derive(Debug, Clone, Copy, PartialEq)] +pub(crate) struct TimestampedFrame { + frame: Frame, + index: usize, +} diff --git a/third_party/kira/src/sound/streaming/sound/decode_scheduler.rs b/third_party/kira/src/sound/streaming/sound/decode_scheduler.rs new file mode 100644 index 00000000..58dae449 --- /dev/null +++ b/third_party/kira/src/sound/streaming/sound/decode_scheduler.rs @@ -0,0 +1,216 @@ +use std::{ + sync::{Arc, atomic::Ordering}, + time::Duration, +}; + +use crate::{ + frame::Frame, + sound::{ + PlaybackState, + streaming::{DecodeSchedulerCommandReaders, StreamingSoundSettings, decoder::Decoder}, + transport::Transport, + }, +}; +use rtrb::{Consumer, Producer, RingBuffer}; + +use super::{Shared, TimestampedFrame}; + +const BUFFER_SIZE: usize = 16_384; +const DECODER_THREAD_SLEEP_DURATION: Duration = Duration::from_millis(1); + +pub(crate) enum NextStep { + Continue, + Wait, + End, +} + +pub(crate) struct DecodeScheduler { + decoder: Box>, + sample_rate: u32, + slice: Option<(usize, usize)>, + num_frames: usize, + transport: Transport, + decoder_current_frame_index: usize, + decoded_chunk: Option, + command_readers: DecodeSchedulerCommandReaders, + frame_producer: Producer, + error_producer: Producer, + shared: Arc, +} + +impl DecodeScheduler { + pub(crate) fn new( + mut decoder: Box>, + slice: Option<(usize, usize)>, + settings: StreamingSoundSettings, + shared: Arc, + command_readers: DecodeSchedulerCommandReaders, + error_producer: Producer, + ) -> Result<(Self, Consumer), Error> { + let (mut frame_producer, frame_consumer) = RingBuffer::new(BUFFER_SIZE); + // pre-seed the frame ringbuffer with a zero frame. this is the "previous" frame + // when the sound just started. + frame_producer + .push(TimestampedFrame { + frame: Frame::ZERO, + index: 0, + }) + .expect("The frame producer shouldn't be full because we just created it"); + let sample_rate = decoder.sample_rate(); + let num_frames = if let Some((start, end)) = slice { + end - start + } else { + decoder.num_frames() + }; + let start_position = settings.start_position.into_samples(sample_rate); + let decoder_current_frame_index = decoder.seek(start_position)?; + let scheduler = Self { + decoder, + sample_rate, + slice, + num_frames, + transport: Transport::new( + start_position, + settings.loop_region, + false, + sample_rate, + num_frames, + ), + decoder_current_frame_index, + decoded_chunk: None, + command_readers, + frame_producer, + error_producer, + shared, + }; + Ok((scheduler, frame_consumer)) + } + + #[must_use] + pub fn current_frame(&self) -> usize { + self.transport.position + } + + pub fn start(mut self) { + std::thread::spawn(move || { + loop { + match self.run() { + Ok(result) => match result { + NextStep::Continue => {} + NextStep::Wait => std::thread::sleep(DECODER_THREAD_SLEEP_DURATION), + NextStep::End => break, + }, + Err(error) => { + self.error_producer.push(error).ok(); + self.shared.encountered_error.store(true, Ordering::SeqCst); + } + } + } + }); + } + + pub fn run(&mut self) -> Result { + // if the sound was manually stopped, end the thread + if self.shared.state() == PlaybackState::Stopped { + return Ok(NextStep::End); + } + // if the frame ringbuffer is full, sleep for a bit + if self.frame_producer.is_full() { + return Ok(NextStep::Wait); + } + // check for commands + if let Some(loop_region) = self.command_readers.set_loop_region.read() { + self.transport + .set_loop_region(loop_region, self.sample_rate, self.num_frames); + } + if let Some(amount) = self.command_readers.seek_by.read() { + self.seek_by(amount)?; + } + if let Some(position) = self.command_readers.seek_to.read() { + self.seek_to(position)?; + } + let frame = self.frame_at_index(self.transport.position)?; + self.frame_producer + .push(TimestampedFrame { + frame, + index: self.transport.position, + }) + .expect("could not push frame to frame producer"); + self.transport.increment_position(self.num_frames); + if !self.transport.playing { + self.shared.reached_end.store(true, Ordering::SeqCst); + return Ok(NextStep::End); + } + Ok(NextStep::Continue) + } + + fn frame_at_index(&mut self, index: usize) -> Result { + let start = self.slice.map(|(start, _)| start).unwrap_or(0); + let end = self.slice.map(|(_, end)| end).unwrap_or(self.num_frames); + if index >= end - start { + return Ok(Frame::ZERO); + } + let index = start + index; + // if the requested frame is already loaded, return it + if let Some(chunk) = &self.decoded_chunk { + if let Some(frame) = chunk.frame_at_index(index) { + return Ok(frame); + } + } + /* + otherwise, seek to the requested index and decode chunks sequentially + until we get the frame we want. just because we seek to an index does + not mean the next decoded chunk will have the frame we want (or any frame + at all, for that matter), so we may need to decode multiple chunks to + get the frame we care about. + */ + if index < self.decoder_current_frame_index { + self.decoder_current_frame_index = self.decoder.seek(index)?; + } + loop { + let decoded_chunk = DecodedChunk { + start_index: self.decoder_current_frame_index, + frames: self.decoder.decode()?, + }; + self.decoder_current_frame_index += decoded_chunk.frames.len(); + self.decoded_chunk = Some(decoded_chunk); + if let Some(chunk) = &self.decoded_chunk { + if let Some(frame) = chunk.frame_at_index(index) { + return Ok(frame); + } + } + } + } + + fn seek_to(&mut self, position: f64) -> Result<(), Error> { + let index = (position * self.sample_rate as f64).round() as usize; + self.seek_to_index(index)?; + Ok(()) + } + + fn seek_by(&mut self, amount: f64) -> Result<(), Error> { + let position = self.shared.position() + amount; + self.seek_to(position)?; + Ok(()) + } + + fn seek_to_index(&mut self, index: usize) -> Result<(), Error> { + self.transport.seek_to(index, self.num_frames); + self.decoder_current_frame_index = self.decoder.seek(index)?; + Ok(()) + } +} + +struct DecodedChunk { + pub start_index: usize, + pub frames: Vec, +} + +impl DecodedChunk { + fn frame_at_index(&self, index: usize) -> Option { + if index < self.start_index { + return None; + } + self.frames.get(index - self.start_index).copied() + } +} diff --git a/third_party/kira/src/sound/streaming/sound/test.rs b/third_party/kira/src/sound/streaming/sound/test.rs new file mode 100644 index 00000000..81293fc8 --- /dev/null +++ b/third_party/kira/src/sound/streaming/sound/test.rs @@ -0,0 +1,878 @@ +use std::time::Duration; + +use crate::{ + Decibels, Panning, StartTime, Tween, + clock::ClockTime, + frame::Frame, + info::MockInfoBuilder, + sound::{ + PlaybackState, Sound, + streaming::{StreamingSoundData, StreamingSoundSettings, decoder::mock::MockDecoder}, + }, + test_helpers::expect_frame_soon, +}; + +use super::decode_scheduler::NextStep; + +/// Tests that a `StreamingSound` will play all of its samples before finishing. +#[test] +fn plays_all_samples() { + let data = StreamingSoundData { + decoder: Box::new(MockDecoder::new(vec![ + Frame::from_mono(1.0), + Frame::from_mono(2.0), + Frame::from_mono(3.0), + ])), + settings: StreamingSoundSettings::new(), + slice: None, + }; + let (mut sound, _, mut scheduler) = data.split().unwrap(); + while matches!(scheduler.run().unwrap(), NextStep::Continue) {} + + assert!(!sound.finished()); + + for i in 1..=3 { + assert_eq!( + sound.process_one(1.0, &MockInfoBuilder::new().build()), + Frame::from_mono(i as f32).panned(Panning::CENTER) + ); + assert!(!sound.finished()); + } + + // give some time for the resample buffer to empty. in the meantime we should + // get silent output. + for _ in 0..10 { + assert_eq!( + sound.process_one(1.0, &MockInfoBuilder::new().build()), + Frame::from_mono(0.0).panned(Panning::CENTER) + ); + } + + // the sound should be finished and stopped by now + assert!(sound.finished()); + assert_eq!( + sound.playback_state_manager.playback_state(), + PlaybackState::Stopped + ); +} + +/// Tests that a `StreamingSound` will pause playback while waiting +/// for samples from the decoder. +#[test] +#[allow(clippy::float_cmp)] +fn waits_for_samples() { + let data = StreamingSoundData { + decoder: Box::new(MockDecoder::new( + (1..=10).map(|i| Frame::from_mono(i as f32)).collect(), + )), + settings: StreamingSoundSettings::default(), + slice: None, + }; + let (mut sound, handle, mut scheduler) = data.split().unwrap(); + + for _ in 0..3 { + assert_eq!( + sound.process_one(1.0, &MockInfoBuilder::new().build()), + Frame::ZERO.panned(Panning::CENTER) + ); + sound.on_start_processing(); + assert_eq!(handle.position(), 0.0); + } + + for _ in 0..3 { + scheduler.run().unwrap(); + } + + for i in 1..=3 { + assert_eq!(handle.position(), (i - 1) as f64); + assert_eq!( + sound.process_one(1.0, &MockInfoBuilder::new().build()), + Frame::from_mono(i as f32).panned(Panning::CENTER) + ); + sound.on_start_processing(); + } + + for _ in 0..3 { + assert_eq!( + sound.process_one(1.0, &MockInfoBuilder::new().build()), + Frame::ZERO.panned(Panning::CENTER) + ); + sound.on_start_processing(); + assert_eq!(handle.position(), 2.0); + } +} + +/// Tests that a `StreamingSound` correctly reports its playback state +/// to be queried by StreamingSoundHandle::state. +#[test] +fn reports_playback_state() { + let data = StreamingSoundData { + decoder: Box::new(MockDecoder::new(vec![Frame::from_mono(0.0); 10])), + settings: StreamingSoundSettings::new(), + slice: None, + }; + let (mut sound, handle, mut scheduler) = data.split().unwrap(); + while matches!(scheduler.run().unwrap(), NextStep::Continue) {} + + for _ in 0..20 { + assert_eq!( + handle.state(), + sound.playback_state_manager.playback_state() + ); + sound.process_one(1.0, &MockInfoBuilder::new().build()); + } +} + +/// Tests that a `StreamingSound` correctly reports its playback state +/// to be queried by StreamingSoundHandle::state. +#[test] +#[allow(clippy::float_cmp)] +fn reports_playback_position() { + let data = StreamingSoundData { + decoder: Box::new(MockDecoder::new(vec![Frame::from_mono(0.0); 10])), + settings: StreamingSoundSettings::new(), + slice: None, + }; + let (mut sound, handle, mut scheduler) = data.split().unwrap(); + while matches!(scheduler.run().unwrap(), NextStep::Continue) {} + + for i in 0..20 { + assert_eq!(handle.position(), i.clamp(0, 9) as f64); + sound.process_one(1.0, &MockInfoBuilder::new().build()); + sound.on_start_processing(); + } +} + +/// Tests that a `StreamingSound` fades out fully before pausing +/// and fades back in when resuming. +#[test] +#[allow(clippy::float_cmp)] +fn pauses_and_resumes_with_fades() { + let data = StreamingSoundData { + decoder: Box::new(MockDecoder::new(vec![Frame::from_mono(1.0); 100])), + settings: StreamingSoundSettings::new(), + slice: None, + }; + let (mut sound, mut handle, mut scheduler) = data.split().unwrap(); + while matches!(scheduler.run().unwrap(), NextStep::Continue) {} + + sound.process_one(1.0, &MockInfoBuilder::new().build()); + assert_eq!( + sound.playback_state_manager.playback_state(), + PlaybackState::Playing + ); + + handle.pause(Tween { + duration: Duration::from_secs(4), + ..Default::default() + }); + sound.on_start_processing(); + assert_eq!( + sound.playback_state_manager.playback_state(), + PlaybackState::Pausing + ); + + // allow for a few samples of delay because of the resampling, but the + // sound should fade out soon. + expect_frame_soon( + Frame::from_mono(Decibels(-15.0).as_amplitude()).panned(Panning::CENTER), + &mut sound, + ); + assert_eq!( + sound.process_one(1.0, &MockInfoBuilder::new().build()), + Frame::from_mono(Decibels(-30.0).as_amplitude()).panned(Panning::CENTER) + ); + assert_eq!( + sound.process_one(1.0, &MockInfoBuilder::new().build()), + Frame::from_mono(Decibels(-45.0).as_amplitude()).panned(Panning::CENTER) + ); + sound.process_one(1.0, &MockInfoBuilder::new().build()); + + sound.on_start_processing(); + let position = handle.position(); + for _ in 0..10 { + assert_eq!( + sound.process_one(1.0, &MockInfoBuilder::new().build()), + Frame::from_mono(0.0).panned(Panning::CENTER) + ); + sound.on_start_processing(); + assert_eq!(handle.position(), position); + assert_eq!( + sound.playback_state_manager.playback_state(), + PlaybackState::Paused + ); + } + + handle.resume(Tween { + duration: Duration::from_secs(4), + ..Default::default() + }); + sound.on_start_processing(); + + // allow for a few samples of delay because of the resampling, but the + // sound should fade back in soon. + expect_frame_soon( + Frame::from_mono(Decibels(-45.0).as_amplitude()).panned(Panning::CENTER), + &mut sound, + ); + assert_eq!( + sound.playback_state_manager.playback_state(), + PlaybackState::Resuming + ); + assert_eq!( + sound.process_one(1.0, &MockInfoBuilder::new().build()), + Frame::from_mono(Decibels(-30.0).as_amplitude()).panned(Panning::CENTER) + ); + assert_eq!( + sound.playback_state_manager.playback_state(), + PlaybackState::Resuming + ); + assert_eq!( + sound.process_one(1.0, &MockInfoBuilder::new().build()), + Frame::from_mono(Decibels(-15.0).as_amplitude()).panned(Panning::CENTER) + ); + assert_eq!( + sound.playback_state_manager.playback_state(), + PlaybackState::Resuming + ); + + for _ in 0..3 { + assert_eq!( + sound.process_one(1.0, &MockInfoBuilder::new().build()), + Frame::from_mono(1.0).panned(Panning::CENTER) + ); + assert_eq!( + sound.playback_state_manager.playback_state(), + PlaybackState::Playing + ); + } + + let mut frames = vec![Frame::ZERO; 3]; + sound.process(&mut frames, 1.0, &MockInfoBuilder::new().build()); + assert_eq!( + frames, + vec![Frame::from_mono(1.0).panned(Panning::CENTER); 3] + ); +} + +/// Tests that a `StreamingSound` stops and finishes after a fade-out. +#[test] +#[allow(clippy::float_cmp)] +fn stops_with_fade_out() { + let data = StreamingSoundData { + decoder: Box::new(MockDecoder::new(vec![Frame::from_mono(1.0); 100])), + settings: StreamingSoundSettings::new(), + slice: None, + }; + let (mut sound, mut handle, mut scheduler) = data.split().unwrap(); + while matches!(scheduler.run().unwrap(), NextStep::Continue) {} + + sound.process_one(1.0, &MockInfoBuilder::new().build()); + assert_eq!( + sound.playback_state_manager.playback_state(), + PlaybackState::Playing + ); + + handle.stop(Tween { + duration: Duration::from_secs(4), + ..Default::default() + }); + sound.on_start_processing(); + assert_eq!( + sound.playback_state_manager.playback_state(), + PlaybackState::Stopping + ); + + // allow for a few samples of delay because of the resampling, but the + // sound should fade out soon. + expect_frame_soon( + Frame::from_mono(Decibels(-15.0).as_amplitude()).panned(Panning::CENTER), + &mut sound, + ); + assert_eq!( + sound.process_one(1.0, &MockInfoBuilder::new().build()), + Frame::from_mono(Decibels(-30.0).as_amplitude()).panned(Panning::CENTER) + ); + assert_eq!( + sound.process_one(1.0, &MockInfoBuilder::new().build()), + Frame::from_mono(Decibels(-45.0).as_amplitude()).panned(Panning::CENTER) + ); + sound.process_one(1.0, &MockInfoBuilder::new().build()); + + sound.on_start_processing(); + let position = handle.position(); + for _ in 0..3 { + assert_eq!( + sound.process_one(1.0, &MockInfoBuilder::new().build()), + Frame::from_mono(0.0).panned(Panning::CENTER) + ); + sound.on_start_processing(); + assert_eq!(handle.position(), position); + assert_eq!( + sound.playback_state_manager.playback_state(), + PlaybackState::Stopped + ); + assert!(sound.finished()); + } +} + +/// Tests that a `StreamingSound` will wait for its start clock time +/// when appropriate. +#[test] +#[allow(clippy::float_cmp)] +fn waits_for_start_time() { + let mut info_builder = MockInfoBuilder::new(); + let clock_id_1 = info_builder.add_clock(true, 0, 0.0); + let info = info_builder.build(); + + let data = StreamingSoundData { + decoder: Box::new(MockDecoder::new( + (1..100).map(|i| Frame::from_mono(i as f32)).collect(), + )), + settings: StreamingSoundSettings::new().start_time(ClockTime { + clock: clock_id_1, + ticks: 2, + fraction: 0.0, + }), + slice: None, + }; + let (mut sound, _, mut scheduler) = data.split().unwrap(); + while matches!(scheduler.run().unwrap(), NextStep::Continue) {} + + // the sound should not be playing yet + for _ in 0..3 { + assert_eq!(sound.process_one(1.0, &info), Frame::from_mono(0.0)); + } + + let info = { + let mut builder = MockInfoBuilder::new(); + builder.add_clock(true, 1, 0.0); + builder.add_clock(true, 0, 0.0); + builder.build() + }; + + // the sound is set to start at tick 2, so it should not + // play yet + for _ in 0..3 { + assert_eq!(sound.process_one(1.0, &info), Frame::from_mono(0.0)); + } + + let info = { + let mut builder = MockInfoBuilder::new(); + builder.add_clock(true, 1, 0.0); + builder.add_clock(true, 2, 0.0); + builder.build() + }; + + // a different clock reached tick 2, so the sound should + // not play yet + for _ in 0..3 { + assert_eq!(sound.process_one(1.0, &info), Frame::from_mono(0.0)); + } + + let info = { + let mut builder = MockInfoBuilder::new(); + builder.add_clock(true, 2, 0.0); + builder.add_clock(true, 2, 0.0); + builder.build() + }; + + // the sound should start playing now + for i in 1..10 { + assert_eq!( + sound.process_one(1.0, &info), + Frame::from_mono(i as f32).panned(Panning::CENTER) + ); + } +} + +/// Tests that a `StreamingSound` will stop (allowing it to be removed) +/// if it's waiting on a clock that no longer exists before it can +/// start. +/// +/// Without this behavior, a sound in this situation would be in limbo +/// forever with no way to free up its resources. +#[test] +fn stops_if_waiting_on_missing_clock() { + let mut info_builder = MockInfoBuilder::new(); + let clock_id = info_builder.add_clock(true, 0, 0.0); + let info = info_builder.build(); + + let data = StreamingSoundData { + decoder: Box::new(MockDecoder::new( + (1..100).map(|i| Frame::from_mono(i as f32)).collect(), + )), + settings: StreamingSoundSettings::new().start_time(ClockTime { + clock: clock_id, + ticks: 2, + fraction: 0.0, + }), + slice: None, + }; + let (mut sound, handle, mut scheduler) = data.split().unwrap(); + while matches!(scheduler.run().unwrap(), NextStep::Continue) {} + + sound.process_one(1.0, &info); + sound.on_start_processing(); + assert_eq!(handle.state(), PlaybackState::Playing); + + // the clock is removed + let info = MockInfoBuilder::new().build(); + + sound.process_one(1.0, &info); + sound.on_start_processing(); + assert_eq!(handle.state(), PlaybackState::Stopped); + assert!(sound.finished()); +} + +/// Tests that a `StreamingSound` that had its start time set to a clock time and already +/// started will not stop if the clock stops. +#[test] +fn continues_when_clock_stops() { + let mut info_builder = MockInfoBuilder::new(); + let clock_id = info_builder.add_clock(true, 0, 0.0); + let info = info_builder.build(); + + let data = StreamingSoundData { + decoder: Box::new(MockDecoder::new( + (1..100).map(|_| Frame::from_mono(1.0)).collect(), + )), + settings: StreamingSoundSettings::new().start_time(ClockTime { + clock: clock_id, + ticks: 0, + fraction: 0.0, + }), + slice: None, + }; + let (mut sound, _, mut scheduler) = data.split().unwrap(); + while matches!(scheduler.run().unwrap(), NextStep::Continue) {} + + assert_eq!(sound.process_one(1.0, &info), Frame::from_mono(1.0),); + + let info = { + let mut builder = MockInfoBuilder::new(); + builder.add_clock(false, 0, 0.0); + builder.build() + }; + + assert_eq!(sound.process_one(1.0, &info), Frame::from_mono(1.0),); +} + +/// Tests that a `StreamingSound` can be paused, resumed, and stopped immediately +/// even if it's waiting for its start time. +#[test] +fn immediate_playback_state_change_with_start_time() { + let mut info_builder = MockInfoBuilder::new(); + let clock_id = info_builder.add_clock(true, 0, 0.0); + let info = info_builder.build(); + + let data = StreamingSoundData { + decoder: Box::new(MockDecoder::new( + (1..100).map(|i| Frame::from_mono(i as f32)).collect(), + )), + settings: StreamingSoundSettings::new().start_time(ClockTime { + clock: clock_id, + ticks: 2, + fraction: 0.0, + }), + slice: None, + }; + let (mut sound, mut handle, mut scheduler) = data.split().unwrap(); + while matches!(scheduler.run().unwrap(), NextStep::Continue) {} + + handle.pause(Tween { + duration: Duration::ZERO, + ..Default::default() + }); + sound.on_start_processing(); + sound.process_one(1.0, &info); + sound.on_start_processing(); + assert_eq!(handle.state(), PlaybackState::Paused); + + handle.resume(Tween { + duration: Duration::ZERO, + ..Default::default() + }); + sound.on_start_processing(); + sound.process_one(1.0, &info); + sound.on_start_processing(); + assert_eq!(handle.state(), PlaybackState::Playing); + + handle.stop(Tween { + duration: Duration::ZERO, + ..Default::default() + }); + sound.on_start_processing(); + sound.process_one(1.0, &info); + sound.on_start_processing(); + assert_eq!(handle.state(), PlaybackState::Stopped); +} + +/// Tests that a `StreamingSound` can be set to resume at a certain start time. +#[test] +fn resume_at() { + let mut info_builder = MockInfoBuilder::new(); + let clock_id = info_builder.add_clock(true, 0, 0.0); + let info = info_builder.build(); + + let data = StreamingSoundData { + decoder: Box::new(MockDecoder::new( + (1..100).map(|i| Frame::from_mono(i as f32)).collect(), + )), + settings: StreamingSoundSettings::new(), + slice: None, + }; + let (mut sound, mut handle, mut scheduler) = data.split().unwrap(); + while matches!(scheduler.run().unwrap(), NextStep::Continue) {} + + handle.pause(Tween { + duration: Duration::ZERO, + ..Default::default() + }); + sound.on_start_processing(); + sound.process_one(1.0, &info); + sound.on_start_processing(); + assert_eq!(handle.state(), PlaybackState::Paused); + + handle.resume_at( + StartTime::ClockTime(ClockTime { + clock: clock_id, + ticks: 1, + fraction: 0.0, + }), + Tween { + duration: Duration::from_secs(1), + ..Default::default() + }, + ); + sound.on_start_processing(); + sound.process_one(1.0, &info); + sound.on_start_processing(); + assert_eq!(handle.state(), PlaybackState::WaitingToResume); + + let info = { + let mut builder = MockInfoBuilder::new(); + builder.add_clock(true, 1, 0.0); + builder.build() + }; + sound.process_one(1.0, &info); + sound.on_start_processing(); + assert_eq!(handle.state(), PlaybackState::Resuming); + sound.process_one(1.0, &info); + sound.on_start_processing(); + assert_eq!(handle.state(), PlaybackState::Playing); +} + +/// Tests that a `StreamingSound` can be started partway through the sound. +#[test] +#[allow(clippy::float_cmp)] +fn start_position() { + let data = StreamingSoundData { + decoder: Box::new(MockDecoder::new( + (0..10).map(|i| Frame::from_mono(i as f32)).collect(), + )), + settings: StreamingSoundSettings::new().start_position(3.0), + slice: None, + }; + let (mut sound, handle, mut scheduler) = data.split().unwrap(); + while matches!(scheduler.run().unwrap(), NextStep::Continue) {} + + for i in 3..=6 { + assert_eq!(handle.position(), i as f64); + assert_eq!( + sound.process_one(1.0, &MockInfoBuilder::new().build()), + Frame::from_mono(i as f32).panned(Panning::CENTER) + ); + sound.on_start_processing(); + } +} + +/// Tests that a `StreamingSound` properly obeys looping behavior when +/// playing forward. +#[test] +#[allow(clippy::float_cmp)] +fn loops_forward() { + let data = StreamingSoundData { + decoder: Box::new(MockDecoder::new( + (0..10).map(|i| Frame::from_mono(i as f32)).collect(), + )), + settings: StreamingSoundSettings::new().loop_region(Some((3.0..6.0).into())), + slice: None, + }; + let (mut sound, _, mut scheduler) = data.split().unwrap(); + while matches!(scheduler.run().unwrap(), NextStep::Continue) {} + + for i in 0..6 { + assert_eq!( + sound.process_one(1.0, &MockInfoBuilder::new().build()), + Frame::from_mono(i as f32).panned(Panning::CENTER) + ); + } + + assert_eq!( + sound.process_one(2.0, &MockInfoBuilder::new().build()), + Frame::from_mono(3.0).panned(Panning::CENTER) + ); + assert_eq!( + sound.process_one(2.0, &MockInfoBuilder::new().build()), + Frame::from_mono(5.0).panned(Panning::CENTER) + ); + assert_eq!( + sound.process_one(2.0, &MockInfoBuilder::new().build()), + Frame::from_mono(4.0).panned(Panning::CENTER) + ); + assert_eq!( + sound.process_one(2.0, &MockInfoBuilder::new().build()), + Frame::from_mono(3.0).panned(Panning::CENTER) + ); +} + +/// Tests that the volume of a `StreamingSound` can be adjusted. +#[test] +#[allow(clippy::float_cmp)] +fn volume() { + let data = StreamingSoundData { + decoder: Box::new(MockDecoder::new(vec![Frame::from_mono(1.0); 10])), + settings: StreamingSoundSettings::new().volume(-6.0), + slice: None, + }; + let (mut sound, _, mut scheduler) = data.split().unwrap(); + while matches!(scheduler.run().unwrap(), NextStep::Continue) {} + + assert_eq!( + sound.process_one(1.0, &MockInfoBuilder::new().build()), + Frame::from_mono(Decibels(-6.0).as_amplitude()).panned(Panning::CENTER) + ); +} + +/// Tests that the volume of a `StreamingSound` can be changed +/// after the sound is started. +#[test] +fn set_volume() { + let data = StreamingSoundData { + decoder: Box::new(MockDecoder::new(vec![Frame::from_mono(1.0); 100])), + settings: StreamingSoundSettings::new(), + slice: None, + }; + let (mut sound, mut handle, mut scheduler) = data.split().unwrap(); + while matches!(scheduler.run().unwrap(), NextStep::Continue) {} + + assert_eq!( + sound.process_one(1.0, &MockInfoBuilder::new().build()), + Frame::from_mono(1.0).panned(Panning::CENTER) + ); + handle.set_volume( + -6.0, + Tween { + duration: Duration::ZERO, + ..Default::default() + }, + ); + sound.on_start_processing(); + expect_frame_soon( + Frame::from_mono(Decibels(-6.0).as_amplitude()).panned(Panning::CENTER), + &mut sound, + ); +} + +/// Tests that the panning of a `StreamingSound` can be adjusted. +#[test] +#[allow(clippy::float_cmp)] +fn panning() { + let data = StreamingSoundData { + decoder: Box::new(MockDecoder::new(vec![Frame::from_mono(1.0); 10])), + settings: StreamingSoundSettings::new().panning(Panning::LEFT), + slice: None, + }; + let (mut sound, _, mut scheduler) = data.split().unwrap(); + while matches!(scheduler.run().unwrap(), NextStep::Continue) {} + + assert_eq!( + sound.process_one(1.0, &MockInfoBuilder::new().build()), + Frame::from_mono(1.0).panned(Panning::LEFT) + ); +} + +/// Tests that the panning of a `StreamingSound` can be changed +/// after the sound is started. +#[test] +fn set_panning() { + let data = StreamingSoundData { + decoder: Box::new(MockDecoder::new(vec![Frame::from_mono(1.0); 100])), + settings: StreamingSoundSettings::new(), + slice: None, + }; + let (mut sound, mut handle, mut scheduler) = data.split().unwrap(); + while matches!(scheduler.run().unwrap(), NextStep::Continue) {} + + assert_eq!( + sound.process_one(1.0, &MockInfoBuilder::new().build()), + Frame::from_mono(1.0).panned(Panning::CENTER) + ); + handle.set_panning( + Panning::LEFT, + Tween { + duration: Duration::ZERO, + ..Default::default() + }, + ); + sound.on_start_processing(); + expect_frame_soon(Frame::from_mono(1.0).panned(Panning::LEFT), &mut sound); +} + +/// Tests that the playback rate of a `StreamingSound` can be adjusted. +#[test] +#[allow(clippy::float_cmp)] +fn playback_rate() { + let data = StreamingSoundData { + decoder: Box::new(MockDecoder::new( + (0..10).map(|i| Frame::from_mono(i as f32)).collect(), + )), + settings: StreamingSoundSettings::new().playback_rate(2.0), + slice: None, + }; + let (mut sound, _, mut scheduler) = data.split().unwrap(); + while matches!(scheduler.run().unwrap(), NextStep::Continue) {} + + assert_eq!( + sound.process_one(1.0, &MockInfoBuilder::new().build()), + Frame::from_mono(0.0).panned(Panning::CENTER) + ); + assert_eq!( + sound.process_one(1.0, &MockInfoBuilder::new().build()), + Frame::from_mono(2.0).panned(Panning::CENTER) + ); +} + +/// Tests that the playback rate of a `StreamingSound` can be adjusted after +/// it's started. +#[test] +#[allow(clippy::float_cmp)] +fn set_playback_rate() { + let data = StreamingSoundData { + decoder: Box::new(MockDecoder::new( + (0..100).map(|i| Frame::from_mono(i as f32)).collect(), + )), + settings: StreamingSoundSettings::new(), + slice: None, + }; + let (mut sound, mut handle, mut scheduler) = data.split().unwrap(); + while matches!(scheduler.run().unwrap(), NextStep::Continue) {} + + assert_eq!( + sound.process_one(1.0, &MockInfoBuilder::new().build()), + Frame::from_mono(0.0).panned(Panning::CENTER) + ); + assert_eq!( + sound.process_one(1.0, &MockInfoBuilder::new().build()), + Frame::from_mono(1.0).panned(Panning::CENTER) + ); + + handle.set_playback_rate( + 2.0, + Tween { + duration: Duration::ZERO, + ..Default::default() + }, + ); + sound.on_start_processing(); + + assert_eq!( + sound.process_one(1.0, &MockInfoBuilder::new().build()), + Frame::from_mono(2.0).panned(Panning::CENTER) + ); + assert_eq!( + sound.process_one(1.0, &MockInfoBuilder::new().build()), + Frame::from_mono(4.0).panned(Panning::CENTER) + ); +} + +/// Tests that a `StreamingSound` outputs interpolated samples when +/// its playback position is between samples. +#[test] +fn interpolates_samples() { + let data = StreamingSoundData { + decoder: Box::new(MockDecoder::new(vec![ + Frame::from_mono(0.0), + Frame::from_mono(1.0), + Frame::from_mono(1.0), + Frame::from_mono(1.0), + Frame::from_mono(1.0), + Frame::from_mono(1.0), + Frame::from_mono(-10.0), + ])), + settings: Default::default(), + slice: None, + }; + let (mut sound, _, mut scheduler) = data.split().unwrap(); + while matches!(scheduler.run().unwrap(), NextStep::Continue) {} + + assert_eq!( + sound.process_one(0.5, &MockInfoBuilder::new().build()), + Frame::from_mono(0.0).panned(Panning::CENTER) + ); + // at sample 0.5, the output should be somewhere between 0 and 1. + // i don't care what exactly, that's up the to the interpolation algorithm. + let frame = sound.process_one(5.0, &MockInfoBuilder::new().build()); + assert!(frame.left > 0.0 && frame.left < 1.0); + // at sample 5.5, the output should be between 1 and -10. + let frame = sound.process_one(1.0, &MockInfoBuilder::new().build()); + assert!(frame.left < 0.0 && frame.left > -10.0); +} + +/// Tests that a `StreamingSound` outputs interpolated samples correctly +/// when looping. +#[test] +fn interpolates_samples_when_looping() { + let data = StreamingSoundData { + decoder: Box::new(MockDecoder::new(vec![ + Frame::from_mono(10.0), + Frame::from_mono(9.0), + ])), + settings: StreamingSoundSettings::new().loop_region(Some((..).into())), + slice: None, + }; + let (mut sound, _, mut scheduler) = data.split().unwrap(); + while matches!(scheduler.run().unwrap(), NextStep::Continue) {} + + sound.process_one(1.5, &MockInfoBuilder::new().build()); + // because we're looping back to the first sample, which is 10.0, + // the interpolated sample should be be tween 9.0 and 10.0 + let frame = sound.process_one(1.0, &MockInfoBuilder::new().build()); + assert!(frame.left > 9.0 && frame.left < 10.0); +} + +/// Tests that a `StreamingSound` can seek to a position. +#[test] +fn seek_to() { + let data = StreamingSoundData { + decoder: Box::new(MockDecoder::new( + (0..100).map(|i| Frame::from_mono(i as f32)).collect(), + )), + settings: StreamingSoundSettings::new(), + slice: None, + }; + let (mut sound, mut handle, mut scheduler) = data.split().unwrap(); + + handle.seek_to(15.0); + sound.on_start_processing(); + while matches!(scheduler.run().unwrap(), NextStep::Continue) {} + expect_frame_soon(Frame::from_mono(15.0).panned(Panning::CENTER), &mut sound); +} + +/// Tests that a `StreamingSound` can seek by an amount of time. +#[test] +fn seek_by() { + let data = StreamingSoundData { + decoder: Box::new(MockDecoder::new( + (0..100).map(|i| Frame::from_mono(i as f32)).collect(), + )), + settings: StreamingSoundSettings::new().start_position(10.0), + slice: None, + }; + let (mut sound, mut handle, mut scheduler) = data.split().unwrap(); + handle.seek_by(5.0); + sound.on_start_processing(); + while matches!(scheduler.run().unwrap(), NextStep::Continue) {} + expect_frame_soon(Frame::from_mono(20.0).panned(Panning::CENTER), &mut sound); +} diff --git a/third_party/kira/src/sound/symphonia.rs b/third_party/kira/src/sound/symphonia.rs new file mode 100644 index 00000000..f642a6b0 --- /dev/null +++ b/third_party/kira/src/sound/symphonia.rs @@ -0,0 +1,46 @@ +use symphonia::core::{ + audio::{AudioBuffer, AudioBufferRef, Signal}, + conv::{FromSample, IntoSample}, + sample::Sample, +}; + +use crate::frame::Frame; + +use super::FromFileError; + +pub fn load_frames_from_buffer_ref(buffer: &AudioBufferRef) -> Result, FromFileError> { + match buffer { + AudioBufferRef::U8(buffer) => load_frames_from_buffer(buffer), + AudioBufferRef::U16(buffer) => load_frames_from_buffer(buffer), + AudioBufferRef::U24(buffer) => load_frames_from_buffer(buffer), + AudioBufferRef::U32(buffer) => load_frames_from_buffer(buffer), + AudioBufferRef::S8(buffer) => load_frames_from_buffer(buffer), + AudioBufferRef::S16(buffer) => load_frames_from_buffer(buffer), + AudioBufferRef::S24(buffer) => load_frames_from_buffer(buffer), + AudioBufferRef::S32(buffer) => load_frames_from_buffer(buffer), + AudioBufferRef::F32(buffer) => load_frames_from_buffer(buffer), + AudioBufferRef::F64(buffer) => load_frames_from_buffer(buffer), + } +} + +pub fn load_frames_from_buffer( + buffer: &AudioBuffer, +) -> Result, FromFileError> +where + f32: FromSample, +{ + match buffer.spec().channels.count() { + 1 => Ok(buffer + .chan(0) + .iter() + .map(|sample| Frame::from_mono((*sample).into_sample())) + .collect()), + 2 => Ok(buffer + .chan(0) + .iter() + .zip(buffer.chan(1).iter()) + .map(|(left, right)| Frame::new((*left).into_sample(), (*right).into_sample())) + .collect()), + _ => Err(FromFileError::UnsupportedChannelConfiguration), + } +} diff --git a/third_party/kira/src/sound/transport.rs b/third_party/kira/src/sound/transport.rs new file mode 100644 index 00000000..f349b78e --- /dev/null +++ b/third_party/kira/src/sound/transport.rs @@ -0,0 +1,106 @@ +use super::{EndPosition, Region}; + +#[cfg(test)] +mod test; + +pub struct Transport { + pub position: usize, + /// The start and end frames of the sound that should be looped. The upper bound + /// is *exclusive*. + pub loop_region: Option<(usize, usize)>, + pub playing: bool, +} + +impl Transport { + #[must_use] + pub fn new( + start_position: usize, + loop_region: Option, + reverse: bool, + sample_rate: u32, + num_frames: usize, + ) -> Self { + let loop_region = loop_region.map(|loop_region| { + let loop_start = loop_region.start.into_samples(sample_rate); + let loop_end = match loop_region.end { + EndPosition::EndOfAudio => num_frames, + EndPosition::Custom(end_position) => end_position.into_samples(sample_rate), + }; + (loop_start, loop_end) + }); + Self { + position: if reverse { + num_frames - 1 - start_position + } else { + start_position + }, + loop_region, + playing: true, + } + } + + pub fn set_loop_region( + &mut self, + loop_region: Option, + sample_rate: u32, + num_frames: usize, + ) { + self.loop_region = loop_region.map(|loop_region| { + let loop_start = loop_region.start.into_samples(sample_rate); + let loop_end = match loop_region.end { + EndPosition::EndOfAudio => num_frames, + EndPosition::Custom(end_position) => end_position.into_samples(sample_rate), + }; + (loop_start, loop_end) + }); + } + + pub fn increment_position(&mut self, num_frames: usize) { + if !self.playing { + return; + } + self.position += 1; + if let Some((loop_start, loop_end)) = self.loop_region { + while self.position >= loop_end { + self.position -= loop_end - loop_start; + } + } + if self.position >= num_frames { + self.playing = false; + } + } + + pub fn decrement_position(&mut self) { + if !self.playing { + return; + } + if let Some((loop_start, loop_end)) = self.loop_region { + while self.position <= loop_start { + self.position += loop_end - loop_start; + } + } + if self.position == 0 { + self.playing = false; + } else { + self.position -= 1; + } + } + + pub fn seek_to(&mut self, mut position: usize, num_frames: usize) { + if let Some((loop_start, loop_end)) = self.loop_region { + if position > self.position { + while position >= loop_end { + position -= loop_end - loop_start; + } + } else { + while position < loop_start { + position += loop_end - loop_start; + } + } + } + self.position = position; + if self.position >= num_frames { + self.playing = false; + } + } +} diff --git a/third_party/kira/src/sound/transport/test.rs b/third_party/kira/src/sound/transport/test.rs new file mode 100644 index 00000000..b5edbf6a --- /dev/null +++ b/third_party/kira/src/sound/transport/test.rs @@ -0,0 +1,107 @@ +use super::Transport; + +#[test] +fn stops_at_end() { + let mut transport = Transport { + position: 2, + loop_region: None, + playing: true, + }; + for i in 2..4 { + assert_eq!(transport.position, i); + assert!(transport.playing); + transport.increment_position(4); + } + assert_eq!(transport.position, 4); + assert!(!transport.playing); +} + +#[test] +fn stops_at_start_when_playing_backwards() { + let mut transport = Transport { + position: 2, + loop_region: None, + playing: true, + }; + for i in (0..=2).rev() { + assert_eq!(transport.position, i); + assert!(transport.playing); + transport.decrement_position(); + } + assert_eq!(transport.position, 0); + assert!(!transport.playing); +} + +#[test] +fn loops() { + let mut transport = Transport { + position: 0, + loop_region: Some((2, 5)), + playing: true, + }; + for i in 0..5 { + assert_eq!(transport.position, i); + assert!(transport.playing); + transport.increment_position(10); + } + for i in 2..5 { + assert_eq!(transport.position, i); + assert!(transport.playing); + transport.increment_position(10); + } +} + +#[test] +fn loops_when_playing_backward() { + let mut transport = Transport { + position: 0, + loop_region: Some((2, 5)), + playing: true, + }; + transport.position = 10; + for i in (2..=10).rev() { + assert_eq!(transport.position, i); + assert!(transport.playing); + transport.decrement_position(); + } + for i in (2..5).rev() { + assert_eq!(transport.position, i); + assert!(transport.playing); + transport.decrement_position(); + } +} + +#[test] +fn loop_wrapping() { + let mut transport = Transport { + position: 0, + loop_region: Some((2, 5)), + playing: true, + }; + transport.position = 6; + transport.increment_position(10); + assert_eq!(transport.position, 4); + transport.position = 1; + transport.decrement_position(); + assert_eq!(transport.position, 3); +} + +#[test] +fn seek_loop_wrapping() { + let mut transport = Transport { + position: 0, + loop_region: Some((2, 5)), + playing: true, + }; + transport.seek_to(7, 10); + assert_eq!(transport.position, 4); + transport.seek_to(0, 10); + assert_eq!(transport.position, 3); +} + +#[test] +fn seek_out_of_bounds() { + let mut transport = Transport::new(5, None, false, 1, 10); + transport.seek_to(10, 10); + assert!(!transport.playing); +} diff --git a/third_party/kira/src/start_time.rs b/third_party/kira/src/start_time.rs new file mode 100644 index 00000000..faffe95f --- /dev/null +++ b/third_party/kira/src/start_time.rs @@ -0,0 +1,55 @@ +use std::time::Duration; + +use crate::{ + clock::ClockTime, + info::{Info, WhenToStart}, +}; + +/// Describes when an action should occur. +#[derive(Debug, Clone, Copy, PartialEq, Default)] +pub enum StartTime { + /// The action should occur immediately. + #[default] + Immediate, + /// The action should occur a certain amount of time from now. + Delayed(Duration), + /// The action should occur when a clock reaches a + /// specific time. + ClockTime(ClockTime), +} + +impl StartTime { + pub(crate) fn update(&mut self, dt: f64, info: &Info) -> WillNeverStart { + match self { + StartTime::Immediate => {} + StartTime::Delayed(time_remaining) => { + *time_remaining = time_remaining.saturating_sub(Duration::from_secs_f64(dt)); + if time_remaining.is_zero() { + *self = StartTime::Immediate; + } + } + StartTime::ClockTime(clock_time) => match info.when_to_start(*clock_time) { + WhenToStart::Now => { + *self = StartTime::Immediate; + } + WhenToStart::Later => {} + WhenToStart::Never => return true, + }, + } + false + } +} + +pub(crate) type WillNeverStart = bool; + +impl From for StartTime { + fn from(v: Duration) -> Self { + Self::Delayed(v) + } +} + +impl From for StartTime { + fn from(v: ClockTime) -> Self { + Self::ClockTime(v) + } +} diff --git a/third_party/kira/src/test_helpers.rs b/third_party/kira/src/test_helpers.rs new file mode 100644 index 00000000..f19a7032 --- /dev/null +++ b/third_party/kira/src/test_helpers.rs @@ -0,0 +1,17 @@ +use crate::{Frame, info::MockInfoBuilder, sound::Sound}; + +pub fn expect_frame_soon(expected_frame: Frame, sound: &mut dyn Sound) { + const NUM_SAMPLES_TO_WAIT: usize = 10; + let mut collected_samples = vec![]; + for _ in 0..NUM_SAMPLES_TO_WAIT { + let frame = sound.process_one(1.0, &MockInfoBuilder::new().build()); + if frame == expected_frame { + return; + } + collected_samples.push(frame); + } + panic!( + "Sound did not output frame with value {:?} within {} samples. Recent samples: {:#?}", + expected_frame, NUM_SAMPLES_TO_WAIT, collected_samples + ); +} diff --git a/third_party/kira/src/track.rs b/third_party/kira/src/track.rs new file mode 100644 index 00000000..92c7a2b3 --- /dev/null +++ b/third_party/kira/src/track.rs @@ -0,0 +1,364 @@ +/*! +Organizes and applies effects to audio. + +Kira has an internal mixer which works like a real-life mixing console. Sounds +can be played on "tracks", which are individual streams of audio that can +optionally have effects that modify the audio. + +Tracks can also be spatialized, which gives them a position in a 3D space +relative to a [listener](crate::listener). The distance from the listener +can be used to drive settings on effects on that track. + +## Creating and using tracks + +The mixer has a "main" track by default, and you can add any number of +sub-tracks. To add a sub-track, use +[`AudioManager::add_sub_track`](crate::AudioManager::add_sub_track). + +```no_run +# use std::error::Error; +use kira::{ + AudioManager, AudioManagerSettings, DefaultBackend, + track::TrackBuilder, +}; + +let mut manager = AudioManager::::new(AudioManagerSettings::default())?; +let mut track = manager.add_sub_track(TrackBuilder::default())?; +# Result::<(), Box>::Ok(()) +``` + +To play a sound on the track, use [`TrackHandle::play`]. + +```no_run +# use std::error::Error; +# use kira::{ +# AudioManager, AudioManagerSettings, backend::DefaultBackend, +# track::TrackBuilder, +# }; +use kira::sound::static_sound::StaticSoundData; + +# let mut manager = AudioManager::::new(AudioManagerSettings::default())?; +# let mut track = manager.add_sub_track(TrackBuilder::default())?; +track.play(StaticSoundData::from_file("sound.ogg")?)?; +# Result::<(), Box>::Ok(()) +``` + +Tracks can themselves have sub-tracks. You can create them using [`TrackHandle::add_sub_track`]. + +You can set the volume of a track using [`TrackHandle::set_volume`]. The volume +setting will affect all sounds being played on the track as well as all child tracks. + +You can pause all sounds (and child tracks) of a track using [`TrackHandle::pause`] +and resume them using [`TrackHandle::resume`] or [`TrackHandle::resume_at`]. + +## Effects + +You can add effects to the track when creating it using +[`TrackBuilder::with_effect`]. All sounds that are played on that track will have +the effects applied sequentially. + +In this example, we'll use the [filter](crate::effect::filter) effect, which in the +low pass mode will remove high frequencies from sounds, making them sound muffled. + +```no_run +# use std::error::Error; +use kira::{ + AudioManager, AudioManagerSettings, DefaultBackend, + sound::static_sound::{StaticSoundData, StaticSoundSettings}, + track::TrackBuilder, + effect::filter::FilterBuilder, +}; + +let mut manager = AudioManager::::new(AudioManagerSettings::default())?; +let mut track = manager.add_sub_track( + TrackBuilder::new() + .with_effect(FilterBuilder::new().cutoff(1000.0)) +)?; +track.play(StaticSoundData::from_file("sound.ogg")?)?; +# Result::<(), Box>::Ok(()) +``` + +[`TrackBuilder::add_effect`] returns a handle that can be used to modify the effect +after the track has been created. + +```no_run +# use std::error::Error; +# use kira::{ +# AudioManager, AudioManagerSettings, DefaultBackend, +# sound::static_sound::{StaticSoundData, StaticSoundSettings}, +# track::TrackBuilder, +# effect::filter::FilterBuilder, +# Tween, +# }; +# let mut manager = AudioManager::::new(AudioManagerSettings::default())?; +let mut filter; +let track = manager.add_sub_track({ + let mut builder = TrackBuilder::new(); + filter = builder.add_effect(FilterBuilder::new().cutoff(1000.0)); + builder +})?; +filter.set_cutoff(4000.0, Tween::default()); +# Result::<(), Box>::Ok(()) +``` + +## Send tracks + +Sometimes, you may want to share effects across multiple tracks. For example, let's say +we have a game with a player and an enemy that both make sounds, so we have a "player" +track and an "enemy" track. We want sounds for both of these tracks to have reverb +applied. We could add a separate reverb effect to both the player and enemy track, +but there's a couple reasons this isn't an optimal solution: + +- Since the player and enemy are in the same space, they should be using the same + reverb settings. But if we have two reverb effects, we're duplicating the settings. +- Having more effects takes more CPU time, and it's wasteful in this case because + both reverb effects are doing the same thing. + +This is where send tracks come in handy. Send tracks are non-hierarchical mixer tracks +which you can't play sounds on directly - instead, regular mixer tracks can have their +output sent to the input of one or more send tracks. The output of the send tracks +is then sent to the main mixer track. + +In the following example, we'll set up mixer tracks to have the following flow of audio: + +```text +┌──────┐ ┌──────────┐ +│ ├─────────► │ +│Player├───────┐ │Main track│ +│ │ ┌─────┼─► │ +└──────┘ │ │ └─────▲────┘ +┌──────┐ │ ┌───▼──┐ │ +│ ├─┘ │ │ │ +│Enemy ├───►Reverb├────┘ +│ │ │(send)│ +└──────┘ └──────┘ +``` + +To start, we'll create the reverb send with +[`AudioManager::add_send_track`](crate::AudioManager::add_send_track): + +```no_run +# use std::error::Error; +use kira::{ + effect::reverb::ReverbBuilder, + AudioManager, AudioManagerSettings, DefaultBackend, + track::SendTrackBuilder, +}; + +let mut manager = AudioManager::::new(AudioManagerSettings::default())?; +let reverb_send = manager + .add_send_track(SendTrackBuilder::new().with_effect(ReverbBuilder::new().mix(1.0)))?; +# Result::<(), Box>::Ok(()) +``` + +Note that we set the mix of the reverb to `1.0`, meaning only the reverberations will be output, +not the dry signal. This is important because the player and enemy tracks will already +be outputting the dry signal. If the reverb effect was also outputting the dry signal, the +overall volume of the sound would be louder than we want. + +Next, we'll create the player and enemy tracks and route them to the reverb send using +[`TrackBuilder::with_send`]: + +```no_run +# use std::error::Error; +use kira::track::TrackBuilder; +# use kira::{ +# effect::reverb::ReverbBuilder, +# AudioManager, AudioManagerSettings, DefaultBackend, +# track::SendTrackBuilder, +# }; + +# let mut manager = AudioManager::::new(AudioManagerSettings::default())?; +# let reverb_send = manager +# .add_send_track(SendTrackBuilder::new().with_effect(ReverbBuilder::new().mix(1.0)))?; +let player_track = manager.add_sub_track(TrackBuilder::new().with_send(&reverb_send, -6.0))?; +let enemy_track = manager.add_sub_track(TrackBuilder::new().with_send(&reverb_send, -12.0))?; +# Result::<(), Box>::Ok(()) +``` + +We can use the second argument of `with_send` to change the volume of the track before sending +it to the send track. This allows the player and enemy to have different amounts of reverb +without having to instantiate two separate effects. + +## Spatial tracks + +Oftentimes, it’s useful to give sounds a location in a 3D (or 2D) space and play back those sounds +from the perspective of a character’s ears located somewhere else in that space. For example, as a +player character gets closer to a waterfall, you may want the sound of the waterfall to get louder. + +We can use **spatial tracks** as the sound source and [listener](crate::listener)s for the +character's ears. + +First, let's create a listener using +[`AudioManager::add_listener`](crate::AudioManager::add_listener): + +```no_run +# use std::error::Error; +use kira::{AudioManager, AudioManagerSettings, DefaultBackend}; + +let mut manager = AudioManager::::new(AudioManagerSettings::default())?; +let listener = manager.add_listener(glam::Vec3::ZERO, glam::Quat::IDENTITY)?; +# Result::<(), Box>::Ok(()) +``` + +This example uses `glam`, but you can use any math library that has interoperability +with `mint`. + +Next, we'll create a spatial track that's linked to the listener using +[`AudioManager::add_spatial_sub_track`](crate::AudioManager::add_spatial_sub_track): + +```no_run +# use std::error::Error; +# use kira::{ +# AudioManager, AudioManagerSettings, DefaultBackend, +# }; +use kira::track::SpatialTrackBuilder; + +# let mut manager = AudioManager::::new(AudioManagerSettings::default())?; +# let listener = manager.add_listener(glam::Vec3::ZERO, glam::Quat::IDENTITY)?; +let spatial_track = manager.add_spatial_sub_track( + &listener, + glam::vec3(0.0, 0.0, 10.0), // track position + SpatialTrackBuilder::new(), +)?; +# Result::<(), Box>::Ok(()) +``` + +Now any sounds played on the spatial track will automatically have the following behaviors: + +- Attenuation: sounds will get quieter the farther away they are from the listener +- Spatialization: sounds will be panned left or right depending on their direction from + the listener + +We can customize or disable these behaviors using the methods on [`SpatialTrackBuilder`]. + +We can also map any effect setting that uses `Value` to the distance between the spatial +track and the listener using `Value::FromListenerDistance`. One common use for this is to +change the amount of reverb a sound has based on distance: + +```no_run +# use std::error::Error; +use kira::{ + effect::reverb::ReverbBuilder, + Easing, Mapping, Value, + Mix, +}; +# use kira::{ +# AudioManager, AudioManagerSettings, DefaultBackend, +# track::SpatialTrackBuilder, +# }; + +# let mut manager = AudioManager::::new(AudioManagerSettings::default())?; +# let listener = manager.add_listener(glam::Vec3::ZERO, glam::Quat::IDENTITY)?; +manager.add_spatial_sub_track( + &listener, + glam::vec3(0.0, 0.0, 10.0), + SpatialTrackBuilder::new().with_effect(ReverbBuilder::new().mix( + Value::FromListenerDistance( + Mapping { + input_range: (0.0, 100.0), + output_range: (Mix::DRY, Mix::WET), + easing: Easing::Linear, + }, + ), + )), +)?; +# Result::<(), Box>::Ok(()) +``` +*/ + +mod main; +mod send; +mod sub; + +pub use main::*; +pub use send::*; +pub use sub::*; + +use std::sync::atomic::{AtomicBool, AtomicU8, Ordering}; + +use crate::sound::PlaybackState; + +#[derive(Debug)] +pub(crate) struct TrackShared { + state: AtomicU8, + removed: AtomicBool, +} + +impl TrackShared { + pub fn new() -> Self { + Self { + state: AtomicU8::new(TrackPlaybackState::Playing as u8), + removed: AtomicBool::new(false), + } + } + + pub fn state(&self) -> TrackPlaybackState { + match self.state.load(Ordering::SeqCst) { + 0 => TrackPlaybackState::Playing, + 1 => TrackPlaybackState::Pausing, + 2 => TrackPlaybackState::Paused, + 3 => TrackPlaybackState::WaitingToResume, + 4 => TrackPlaybackState::Resuming, + _ => panic!("Invalid playback state"), + } + } + + pub fn set_state(&self, playback_state: PlaybackState) { + self.state.store(playback_state as u8, Ordering::SeqCst); + } + + #[must_use] + pub fn is_marked_for_removal(&self) -> bool { + self.removed.load(Ordering::SeqCst) + } + + pub fn mark_for_removal(&self) { + self.removed.store(true, Ordering::SeqCst); + } +} + +/// The playback state of a mixer sub-track. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub enum TrackPlaybackState { + /// The track is playing normally. + Playing, + /// The track is fading out, and when the fade-out + /// is finished, playback will pause. + Pausing, + /// Playback is paused. + Paused, + /// The track is paused, but is schedule to resume in the future. + WaitingToResume, + /// The track is fading back in after being previously paused. + Resuming, +} + +impl TrackPlaybackState { + /// Whether the track is outputting audio given + /// its current playback state. + pub fn is_advancing(self) -> bool { + match self { + TrackPlaybackState::Playing => true, + TrackPlaybackState::Pausing => true, + TrackPlaybackState::Paused => false, + TrackPlaybackState::WaitingToResume => false, + TrackPlaybackState::Resuming => true, + } + } +} + +impl From for TrackPlaybackState { + fn from(value: PlaybackState) -> Self { + match value { + PlaybackState::Playing => Self::Playing, + PlaybackState::Pausing => Self::Pausing, + PlaybackState::Paused => Self::Paused, + PlaybackState::WaitingToResume => Self::WaitingToResume, + PlaybackState::Resuming => Self::Resuming, + PlaybackState::Stopping => unreachable!(), + PlaybackState::Stopped => unreachable!(), + } + } +} diff --git a/third_party/kira/src/track/main.rs b/third_party/kira/src/track/main.rs new file mode 100644 index 00000000..de0bc170 --- /dev/null +++ b/third_party/kira/src/track/main.rs @@ -0,0 +1,69 @@ +mod builder; +mod handle; + +pub use builder::*; +pub use handle::*; + +use crate::{ + Decibels, Frame, Parameter, + backend::resources::ResourceStorage, + command::{CommandReader, ValueChangeCommand}, + effect::Effect, + info::Info, + sound::Sound, +}; + +pub(crate) struct MainTrack { + volume: Parameter, + set_volume_command_reader: CommandReader>, + sounds: ResourceStorage>, + effects: Vec>, + temp_buffer: Vec, + internal_buffer_size: usize, +} + +impl MainTrack { + pub fn init_effects(&mut self, sample_rate: u32) { + for effect in &mut self.effects { + effect.init(sample_rate, self.internal_buffer_size); + } + } + + pub fn on_change_sample_rate(&mut self, sample_rate: u32) { + for effect in &mut self.effects { + effect.on_change_sample_rate(sample_rate); + } + } + + pub fn on_start_processing(&mut self) { + self.volume + .read_command(&mut self.set_volume_command_reader); + self.sounds.remove_and_add(|sound| sound.finished()); + for (_, sound) in &mut self.sounds { + sound.on_start_processing(); + } + for effect in &mut self.effects { + effect.on_start_processing(); + } + } + + pub fn process(&mut self, out: &mut [Frame], dt: f64, info: &Info) { + self.volume.update(dt * out.len() as f64, info); + for (_, sound) in &mut self.sounds { + sound.process(&mut self.temp_buffer[..out.len()], dt, info); + for (summed_out, sound_out) in out.iter_mut().zip(self.temp_buffer.iter().copied()) { + *summed_out += sound_out; + } + self.temp_buffer.fill(Frame::ZERO); + } + for effect in &mut self.effects { + effect.process(out, dt, info); + } + let num_frames = out.len(); + for (i, frame) in out.iter_mut().enumerate() { + let time_in_chunk = (i + 1) as f64 / num_frames as f64; + let volume = self.volume.interpolated_value(time_in_chunk).as_amplitude(); + *frame *= volume; + } + } +} diff --git a/third_party/kira/src/track/main/builder.rs b/third_party/kira/src/track/main/builder.rs new file mode 100644 index 00000000..9b063f08 --- /dev/null +++ b/third_party/kira/src/track/main/builder.rs @@ -0,0 +1,168 @@ +use crate::{ + Decibels, Frame, Parameter, Value, backend::resources::ResourceStorage, + command::command_writer_and_reader, effect::EffectBuilder, +}; + +use super::{Effect, MainTrack, MainTrackHandle}; + +/// Configures the main mixer track. +pub struct MainTrackBuilder { + /// The volume of the track. + pub(crate) volume: Value, + /// The effects that should be applied to the input audio + /// for this track. + pub(crate) effects: Vec>, + /// The maximum number of sounds that can be played simultaneously on this track. + pub(crate) sound_capacity: usize, +} + +impl MainTrackBuilder { + /// Creates a new [`MainTrackBuilder`] with the default settings. + #[must_use] + pub fn new() -> Self { + Self { + volume: Value::Fixed(Decibels::IDENTITY), + effects: vec![], + sound_capacity: 128, + } + } + + /// Sets the volume of the main mixer track. + #[must_use = "This method consumes self and returns a modified MainTrackBuilder, so the return value should be used"] + pub fn volume(self, volume: impl Into>) -> Self { + Self { + volume: volume.into(), + ..self + } + } + + /// Sets the maximum number of sounds that can be played simultaneously on this track. + #[must_use = "This method consumes self and returns a modified MainTrackBuilder, so the return value should be used"] + pub fn sound_capacity(self, capacity: usize) -> Self { + Self { + sound_capacity: capacity, + ..self + } + } + + /** + Adds an effect to the track. + + # Examples + + ``` + use kira::{track::MainTrackBuilder, effect::delay::DelayBuilder}; + + let mut builder = MainTrackBuilder::new(); + let delay_handle = builder.add_effect(DelayBuilder::new()); + ``` + */ + pub fn add_effect(&mut self, builder: B) -> B::Handle { + let (effect, handle) = builder.build(); + self.effects.push(effect); + handle + } + + /** + Adds an effect to the track and returns the [`MainTrackBuilder`]. + + If you need to modify the effect later, use [`add_effect`](Self::add_effect), + which returns the effect handle. + + # Examples + + ``` + use kira::{ + track::MainTrackBuilder, + effect::{filter::FilterBuilder, reverb::ReverbBuilder}, + }; + + let mut builder = MainTrackBuilder::new() + .with_effect(FilterBuilder::new()) + .with_effect(ReverbBuilder::new()); + ``` + */ + #[must_use = "This method consumes self and returns a modified MainTrackBuilder, so the return value should be used"] + pub fn with_effect(mut self, builder: B) -> Self { + self.add_effect(builder); + self + } + + /** Adds an already built effect into this track. + + `Box` values are created when calling `build` on an effect builder, which gives you + an effect handle, as well as this boxed effect, which is the actual audio effect. + + This is a lower-level method than [`Self::add_effect`], and you should probably use it rather + than this method, unless you have a reason to. + + # Examples + + ``` + use kira::track::MainTrackBuilder; + use kira::effect::{EffectBuilder, delay::DelayBuilder}; + + let mut builder = MainTrackBuilder::new(); + let delay_builder = DelayBuilder::new(); + let (effect, delay_handle) = delay_builder.build(); + let delay_handle = builder.add_built_effect(effect); + ``` + */ + pub fn add_built_effect(&mut self, effect: Box) { + self.effects.push(effect); + } + + /** Add an already-built effect and return the [`MainTrackBuilder`]. + + `Box` values are created when calling `build` on an effect builder, which gives you + an effect handle, as well as this boxed effect, which is the actual audio effect. + + This is a lower-level method than [`Self::with_effect`], and you should probably use it rather + than this method, unless you have a reason to. + + # Examples + + ``` + use kira::{ + track::MainTrackBuilder, + effect::{filter::FilterBuilder, reverb::ReverbBuilder, EffectBuilder}, + }; + + let (filter_effect, filter_handle) = FilterBuilder::new().build(); + let (reverb_effect, reverb_handle) = ReverbBuilder::new().build(); + let mut builder = MainTrackBuilder::new() + .with_built_effect(filter_effect) + .with_built_effect(reverb_effect); + ``` + */ + #[must_use = "This method consumes self and returns a modified MainTrackBuilder, so the return value should be used"] + pub fn with_built_effect(mut self, effect: Box) -> Self { + self.add_built_effect(effect); + self + } + + #[must_use] + pub(crate) fn build(self, internal_buffer_size: usize) -> (MainTrack, MainTrackHandle) { + let (set_volume_command_writer, set_volume_command_reader) = command_writer_and_reader(); + let (sounds, sound_controller) = ResourceStorage::new(self.sound_capacity); + let track = MainTrack { + volume: Parameter::new(self.volume, Decibels::IDENTITY), + set_volume_command_reader, + sounds, + effects: self.effects, + temp_buffer: vec![Frame::ZERO; internal_buffer_size], + internal_buffer_size, + }; + let handle = MainTrackHandle { + set_volume_command_writer, + sound_controller, + }; + (track, handle) + } +} + +impl Default for MainTrackBuilder { + fn default() -> Self { + Self::new() + } +} diff --git a/third_party/kira/src/track/main/handle.rs b/third_party/kira/src/track/main/handle.rs new file mode 100644 index 00000000..4185f05e --- /dev/null +++ b/third_party/kira/src/track/main/handle.rs @@ -0,0 +1,49 @@ +use crate::{ + Decibels, PlaySoundError, Tween, Value, + backend::resources::ResourceController, + command::{CommandWriter, ValueChangeCommand}, + sound::{Sound, SoundData}, +}; + +/// Controls the main mixer track. +#[derive(Debug)] +pub struct MainTrackHandle { + pub(crate) set_volume_command_writer: CommandWriter>, + pub(crate) sound_controller: ResourceController>, +} + +impl MainTrackHandle { + /// Plays a sound. + pub fn play( + &mut self, + sound_data: D, + ) -> Result> { + let (sound, handle) = sound_data + .into_sound() + .map_err(PlaySoundError::IntoSoundError)?; + self.sound_controller + .insert(sound) + .map_err(|_| PlaySoundError::SoundLimitReached)?; + Ok(handle) + } + + /// Sets the (post-effects) volume of the mixer track. + pub fn set_volume(&mut self, volume: impl Into>, tween: Tween) { + self.set_volume_command_writer.write(ValueChangeCommand { + target: volume.into(), + tween, + }) + } + + /// Returns the maximum number of sounds that can play simultaneously on this track. + #[must_use] + pub fn sound_capacity(&self) -> usize { + self.sound_controller.capacity() + } + + /// Returns the number of sounds currently playing on this track. + #[must_use] + pub fn num_sounds(&self) -> usize { + self.sound_controller.len() + } +} diff --git a/third_party/kira/src/track/send.rs b/third_party/kira/src/track/send.rs new file mode 100644 index 00000000..68e2ce1f --- /dev/null +++ b/third_party/kira/src/track/send.rs @@ -0,0 +1,98 @@ +mod builder; +mod handle; + +use std::sync::Arc; + +use atomic_arena::Key; +pub use builder::*; +pub use handle::*; + +use crate::{ + Decibels, Frame, Parameter, + command::{CommandReader, ValueChangeCommand}, + effect::Effect, + info::Info, +}; + +use super::TrackShared; + +/// A unique identifier for a mixer send track. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct SendTrackId(pub(crate) Key); + +impl From<&SendTrackHandle> for SendTrackId { + fn from(handle: &SendTrackHandle) -> Self { + handle.id() + } +} + +pub(crate) struct SendTrack { + shared: Arc, + volume: Parameter, + set_volume_command_reader: CommandReader>, + effects: Vec>, + input: Vec, + internal_buffer_size: usize, +} + +impl SendTrack { + pub fn init_effects(&mut self, sample_rate: u32) { + for effect in &mut self.effects { + effect.init(sample_rate, self.internal_buffer_size); + } + } + + pub fn on_change_sample_rate(&mut self, sample_rate: u32) { + for effect in &mut self.effects { + effect.on_change_sample_rate(sample_rate); + } + } + + #[must_use] + pub fn shared(&self) -> Arc { + self.shared.clone() + } + + pub fn add_input(&mut self, input: &[Frame], volume: Decibels) { + for (input, added) in self.input.iter_mut().zip(input.iter().copied()) { + *input += added * volume.as_amplitude(); + } + } + + pub fn on_start_processing(&mut self) { + self.volume + .read_command(&mut self.set_volume_command_reader); + for effect in &mut self.effects { + effect.on_start_processing(); + } + } + + pub fn process(&mut self, out: &mut [Frame], dt: f64, info: &Info) { + self.volume.update(dt * out.len() as f64, info); + for (out_frame, input_frame) in out.iter_mut().zip(self.input.iter().copied()) { + *out_frame += input_frame; + } + self.input.fill(Frame::ZERO); + for effect in &mut self.effects { + effect.process(out, dt, info); + } + let num_frames = out.len(); + for (i, frame) in out.iter_mut().enumerate() { + let time_in_chunk = (i + 1) as f64 / num_frames as f64; + let volume = self.volume.interpolated_value(time_in_chunk).as_amplitude(); + *frame *= volume; + } + } +} + +pub(crate) struct SendTrackRoute { + pub(crate) volume: Parameter, + pub(crate) set_volume_command_reader: CommandReader>, +} + +impl SendTrackRoute { + pub fn read_commands(&mut self) { + self.volume + .read_command(&mut self.set_volume_command_reader); + } +} diff --git a/third_party/kira/src/track/send/builder.rs b/third_party/kira/src/track/send/builder.rs new file mode 100644 index 00000000..bc61bc89 --- /dev/null +++ b/third_party/kira/src/track/send/builder.rs @@ -0,0 +1,200 @@ +use std::sync::Arc; + +use crate::{ + Decibels, Parameter, Value, command::command_writer_and_reader, effect::EffectBuilder, + frame::Frame, +}; + +use super::{Effect, SendTrack, SendTrackHandle, SendTrackId, TrackShared}; + +/// Configures a mixer track. +pub struct SendTrackBuilder { + /// The volume of the send track. + pub(crate) volume: Value, + /// The effects that should be applied to the input audio + /// for this track. + pub(crate) effects: Vec>, +} + +impl SendTrackBuilder { + /// Creates a new [`SendTrackBuilder`] with the default settings. + #[must_use] + pub fn new() -> Self { + Self { + volume: Value::Fixed(Decibels::IDENTITY), + effects: vec![], + } + } + + /** + Sets the volume of the send track. + + # Examples + + Set the volume to a fixed decibel value: + + ``` + # use kira::track::SendTrackBuilder; + let builder = SendTrackBuilder::new().volume(-6.0); + ``` + + Link the volume to a modulator: + + ```no_run + use kira::{ + AudioManager, AudioManagerSettings, DefaultBackend, + modulator::tweener::TweenerBuilder, + track::SendTrackBuilder, + Easing, Value, Mapping, + Decibels, + }; + + let mut manager = AudioManager::::new(AudioManagerSettings::default())?; + let tweener = manager.add_modulator(TweenerBuilder { + initial_value: 0.5, + })?; + let builder = SendTrackBuilder::new().volume(Value::FromModulator { + id: tweener.id(), + mapping: Mapping { + input_range: (0.0, 1.0), + output_range: (Decibels::SILENCE, Decibels::IDENTITY), + easing: Easing::Linear, + }, + }); + # Result::<(), Box>::Ok(()) + ``` + */ + #[must_use = "This method consumes self and returns a modified SendTrackBuilder, so the return value should be used"] + pub fn volume(self, volume: impl Into>) -> Self { + Self { + volume: volume.into(), + ..self + } + } + + /** + Adds an effect to the send track. + + # Examples + + ``` + use kira::{track::SendTrackBuilder, effect::delay::DelayBuilder}; + + let mut builder = SendTrackBuilder::new(); + let delay_handle = builder.add_effect(DelayBuilder::new()); + ``` + */ + pub fn add_effect(&mut self, builder: B) -> B::Handle { + let (effect, handle) = builder.build(); + self.effects.push(effect); + handle + } + + /** + Adds an effect to the send track and returns the [`SendTrackBuilder`]. + + If you need to modify the effect later, use [`add_effect`](Self::add_effect), + which returns the effect handle. + + # Examples + + ``` + use kira::{ + track::SendTrackBuilder, + effect::{filter::FilterBuilder, reverb::ReverbBuilder}, + }; + + let mut builder = SendTrackBuilder::new() + .with_effect(FilterBuilder::new()) + .with_effect(ReverbBuilder::new()); + ``` + */ + #[must_use = "This method consumes self and returns a modified SendTrackBuilder, so the return value should be used"] + pub fn with_effect(mut self, builder: B) -> Self { + self.add_effect(builder); + self + } + + /** Adds an already built effect into this track. + + `Box` values are created when calling `build` on an effect builder, which gives you + an effect handle, as well as this boxed effect, which is the actual audio effect. + + This is a lower-level method than [`Self::add_effect`], and you should probably use it rather + than this method, unless you have a reason to. + + # Examples + + ``` + use kira::track::SendTrackBuilder; + use kira::effect::{EffectBuilder, delay::DelayBuilder}; + + let mut builder = SendTrackBuilder::new(); + let delay_builder = DelayBuilder::new(); + let (effect, delay_handle) = delay_builder.build(); + let delay_handle = builder.add_built_effect(effect); + ``` + */ + pub fn add_built_effect(&mut self, effect: Box) { + self.effects.push(effect); + } + + /** Add an already-built effect and return the [`SendTrackBuilder`]. + + `Box` values are created when calling `build` on an effect builder, which gives you + an effect handle, as well as this boxed effect, which is the actual audio effect. + + This is a lower-level method than [`Self::with_effect`], and you should probably use it rather + than this method, unless you have a reason to. + + # Examples + + ``` + use kira::{ + track::SendTrackBuilder, + effect::{filter::FilterBuilder, reverb::ReverbBuilder, EffectBuilder}, + }; + + let (filter_effect, filter_handle) = FilterBuilder::new().build(); + let (reverb_effect, reverb_handle) = ReverbBuilder::new().build(); + let mut builder = SendTrackBuilder::new() + .with_built_effect(filter_effect) + .with_built_effect(reverb_effect); + ``` + */ + #[must_use = "This method consumes self and returns a modified SendTrackBuilder, so the return value should be used"] + pub fn with_built_effect(mut self, effect: Box) -> Self { + self.add_built_effect(effect); + self + } + + #[must_use] + pub(crate) fn build( + self, + id: SendTrackId, + internal_buffer_size: usize, + ) -> (SendTrack, SendTrackHandle) { + let (set_volume_command_writer, set_volume_command_reader) = command_writer_and_reader(); + let shared = Arc::new(TrackShared::new()); + let track = SendTrack { + shared: shared.clone(), + volume: Parameter::new(self.volume, Decibels::IDENTITY), + set_volume_command_reader, + effects: self.effects, + input: vec![Frame::ZERO; internal_buffer_size], + internal_buffer_size, + }; + let handle = SendTrackHandle { + id, + shared, + set_volume_command_writer, + }; + (track, handle) + } +} + +impl Default for SendTrackBuilder { + fn default() -> Self { + Self::new() + } +} diff --git a/third_party/kira/src/track/send/handle.rs b/third_party/kira/src/track/send/handle.rs new file mode 100644 index 00000000..e731326f --- /dev/null +++ b/third_party/kira/src/track/send/handle.rs @@ -0,0 +1,40 @@ +use std::sync::Arc; + +use crate::{ + Decibels, Tween, Value, + command::{CommandWriter, ValueChangeCommand}, +}; + +use super::{SendTrackId, TrackShared}; + +/// Controls a mixer track. +/// +/// When a [`SendTrackHandle`] is dropped, the corresponding mixer +/// track will be removed. +#[derive(Debug)] +pub struct SendTrackHandle { + pub(crate) id: SendTrackId, + pub(crate) shared: Arc, + pub(crate) set_volume_command_writer: CommandWriter>, +} + +impl SendTrackHandle { + /// Returns a unique identifier for this send track. + pub fn id(&self) -> SendTrackId { + self.id + } + + /// Sets the (post-effects) volume of the send track. + pub fn set_volume(&mut self, volume: impl Into>, tween: Tween) { + self.set_volume_command_writer.write(ValueChangeCommand { + target: volume.into(), + tween, + }) + } +} + +impl Drop for SendTrackHandle { + fn drop(&mut self) { + self.shared.mark_for_removal(); + } +} diff --git a/third_party/kira/src/track/sub.rs b/third_party/kira/src/track/sub.rs new file mode 100644 index 00000000..d76dc9a2 --- /dev/null +++ b/third_party/kira/src/track/sub.rs @@ -0,0 +1,491 @@ +mod builder; +mod handle; +mod spatial_builder; +mod spatial_handle; + +pub use builder::*; +pub use handle::*; +pub use spatial_builder::*; +pub use spatial_handle::*; + +use std::{error::Error, f32::consts::FRAC_PI_8, fmt::Display, sync::Arc}; + +use glam::{Quat, Vec3}; + +use crate::{ + Decibels, Easing, Frame, Parameter, StartTime, Tween, Tweenable, + backend::resources::{ + ResourceStorage, clocks::Clocks, listeners::Listeners, modulators::Modulators, + }, + command::ValueChangeCommand, + command_writers_and_readers, + effect::Effect, + info::{Info, SpatialTrackInfo}, + listener::ListenerId, + playback_state_manager::PlaybackStateManager, + sound::Sound, +}; + +use super::{SendTrack, SendTrackId, SendTrackRoute, TrackShared}; + +/// An error that's returned when trying to change the volume of a track route +/// that did not exist originally. +#[derive(Debug)] +pub struct NonexistentRoute; + +impl Display for NonexistentRoute { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("Cannot change the volume of a track route that did not exist originally") + } +} + +impl Error for NonexistentRoute {} + +pub(crate) struct Track { + shared: Arc, + command_readers: CommandReaders, + volume: Parameter, + sounds: ResourceStorage>, + sub_tracks: ResourceStorage, + effects: Vec>, + sends: Vec<(SendTrackId, SendTrackRoute)>, + persist_until_sounds_finish: bool, + spatial_data: Option, + playback_state_manager: PlaybackStateManager, + temp_buffer: Vec, + internal_buffer_size: usize, +} + +impl Track { + fn update_shared_playback_state(&mut self) { + self.shared + .set_state(self.playback_state_manager.playback_state()); + } + + fn pause(&mut self, fade_out_tween: Tween) { + self.playback_state_manager.pause(fade_out_tween); + self.update_shared_playback_state(); + } + + fn resume(&mut self, start_time: StartTime, fade_in_tween: Tween) { + self.playback_state_manager + .resume(start_time, fade_in_tween); + self.update_shared_playback_state(); + } + + pub fn init_effects(&mut self, sample_rate: u32) { + for effect in &mut self.effects { + effect.init(sample_rate, self.internal_buffer_size); + } + for (_, sub_track) in &mut self.sub_tracks { + sub_track.init_effects(sample_rate); + } + } + + pub fn on_change_sample_rate(&mut self, sample_rate: u32) { + for effect in &mut self.effects { + effect.on_change_sample_rate(sample_rate); + } + for (_, sub_track) in &mut self.sub_tracks { + sub_track.on_change_sample_rate(sample_rate); + } + } + + #[must_use] + pub fn shared(&self) -> Arc { + self.shared.clone() + } + + pub fn should_be_removed(&self) -> bool { + if self + .sub_tracks + .iter() + .any(|(_, sub_track)| !sub_track.should_be_removed()) + { + return false; + } + if self.persist_until_sounds_finish { + self.shared().is_marked_for_removal() && self.sounds.is_empty() + } else { + self.shared().is_marked_for_removal() + } + } + + pub fn on_start_processing(&mut self) { + self.read_commands(); + self.sounds.remove_and_add(|sound| sound.finished()); + for (_, sound) in &mut self.sounds { + sound.on_start_processing(); + } + self.sub_tracks + .remove_and_add(|sub_track| sub_track.should_be_removed()); + for (_, sub_track) in &mut self.sub_tracks { + sub_track.on_start_processing(); + } + for effect in &mut self.effects { + effect.on_start_processing(); + } + } + + #[allow(clippy::too_many_arguments)] + pub fn process( + &mut self, + out: &mut [Frame], + dt: f64, + clocks: &Clocks, + modulators: &Modulators, + listeners: &Listeners, + parent_spatial_track_info: Option, + send_tracks: &mut ResourceStorage, + ) { + // get info + let spatial_track_info = self + .spatial_data + .as_ref() + .map(|spatial_data| SpatialTrackInfo { + position: spatial_data.position.value(), + listener_id: spatial_data.listener_id, + }) + .or(parent_spatial_track_info); + let info = Info::new( + &clocks.0.resources, + &modulators.0.resources, + &listeners.0.resources, + spatial_track_info, + ); + + // update volume parameters + self.volume.update(dt * out.len() as f64, &info); + for (_, route) in &mut self.sends { + route.volume.update(dt * out.len() as f64, &info); + } + + // update playback state + let changed_playback_state = self + .playback_state_manager + .update(dt * out.len() as f64, &info); + if changed_playback_state { + self.update_shared_playback_state(); + } + if !self.playback_state_manager.playback_state().is_advancing() { + out.fill(Frame::ZERO); + return; + } + + let num_frames = out.len(); + + // process sub tracks + for (_, sub_track) in &mut self.sub_tracks { + sub_track.process( + &mut self.temp_buffer[..out.len()], + dt, + clocks, + modulators, + listeners, + spatial_track_info, + send_tracks, + ); + for (summed_out, track_out) in out.iter_mut().zip(self.temp_buffer.iter().copied()) { + *summed_out += track_out; + } + self.temp_buffer.fill(Frame::ZERO); + } + + // process sounds + for (_, sound) in &mut self.sounds { + sound.process(&mut self.temp_buffer[..out.len()], dt, &info); + for (summed_out, sound_out) in out.iter_mut().zip(self.temp_buffer.iter().copied()) { + *summed_out += sound_out; + } + self.temp_buffer.fill(Frame::ZERO); + } + + // apply effects + for effect in &mut self.effects { + effect.process(out, dt, &info); + } + + // apply spatialization, then the volume fade. + // + // benilla: the spatial gains and the volume are evaluated at the CHUNK's two ends and + // interpolated linearly across it, not recomputed per frame. Upstream spatialized every + // frame — two vector normalizes, a length, an attenuation curve and a dB→amplitude + // `powf` per frame per spatial track, and two more `powf`s for the volume tween — which + // made a dozen voices cost ~1 ms of every 60 Hz frame on the audio thread. A chunk is + // the output device's buffer (~10 ms); the pan and attenuation of a moving source change + // negligibly across it, and the mixer this backend stands in for (FMOD 3) updates them + // per mixer update, not per sample. See `third_party/kira/BENILLA.md`. + if let Some(spatial_data) = &mut self.spatial_data { + spatial_data.position.update(dt * out.len() as f64, &info); + spatial_data + .spatialization_strength + .update(dt * out.len() as f64, &info); + if let Some(listener_info) = info.listener_info() { + let gains_at = |t: f64| { + spatial_data.spatial_gains( + listener_info.interpolated_position(t as f32).into(), + listener_info.interpolated_orientation(t as f32).into(), + t, + ) + }; + let g0 = gains_at(0.0); + let g1 = gains_at(1.0); + let inv = 1.0 / num_frames as f32; + for (i, frame) in out.iter_mut().enumerate() { + let t = i as f32 * inv; + let g = g0.lerp(&g1, t); + *frame = g.apply(*frame); + } + } else { + out.fill(Frame::ZERO); + } + } + + { + let amplitude_at = |t: f64| { + self.volume.interpolated_value(t).as_amplitude() + * self + .playback_state_manager + .interpolated_fade_volume(t) + .as_amplitude() + }; + let a0 = amplitude_at(0.0); + let a1 = amplitude_at(1.0); + if a0 == a1 { + if a0 != 1.0 { + for frame in out.iter_mut() { + *frame *= a0; + } + } + } else { + let inv = 1.0 / num_frames as f32; + for (i, frame) in out.iter_mut().enumerate() { + let t = (i + 1) as f32 * inv; + *frame *= a0 + (a1 - a0) * t; + } + } + } + + // output to send tracks + for (send_track_id, SendTrackRoute { volume, .. }) in &self.sends { + let Some(send_track) = send_tracks.get_mut(send_track_id.0) else { + continue; + }; + send_track.add_input(out, volume.value()); + } + } + + fn read_commands(&mut self) { + self.volume + .read_command(&mut self.command_readers.set_volume); + for (_, route) in &mut self.sends { + route.read_commands(); + } + if let Some(SpatialData { + position, + spatialization_strength, + .. + }) = &mut self.spatial_data + { + position.read_command(&mut self.command_readers.set_position); + spatialization_strength + .read_command(&mut self.command_readers.set_spatialization_strength); + } + if let Some(tween) = self.command_readers.pause.read() { + self.pause(tween); + } + if let Some((start_time, tween)) = self.command_readers.resume.read() { + self.resume(start_time, tween); + } + } +} + +struct SpatialData { + listener_id: ListenerId, + position: Parameter, + /// The distances from a listener at which the track is loudest and quietest. + distances: SpatialTrackDistances, + /// How the track's volume will change with distance. + /// + /// If `None`, the track will output at a constant volume. + attenuation_function: Option, + /// How much the track's output should be panned left or right depending on its + /// direction from the listener. + /// + /// This value should be between `0.0` and `1.0`. `0.0` disables spatialization + /// entirely. + spatialization_strength: Parameter, +} + +impl SpatialData { + /// benilla: the gains [`Self::spatialize`] applies, as a value — so a chunk can evaluate + /// them twice and lerp instead of recomputing them per frame. + fn spatial_gains( + &self, + listener_position: Vec3, + listener_orientation: Quat, + time_in_chunk: f64, + ) -> SpatialGains { + let position = self.position.interpolated_value(time_in_chunk); + let spatialization_strength = self + .spatialization_strength + .interpolated_value(time_in_chunk) + .clamp(0.0, 1.0); + let min_ear_amplitude = 1.0 - spatialization_strength; + let attenuation = match self.attenuation_function { + Some(attenuation_function) => { + let distance = (listener_position - position).length(); + let relative_distance = self.distances.relative_distance(distance); + let relative_volume = + attenuation_function.apply((1.0 - relative_distance).into()) as f32; + Tweenable::interpolate( + Decibels::SILENCE, + Decibels::IDENTITY, + relative_volume.into(), + ) + .as_amplitude() + } + None => 1.0, + }; + let (left, right) = if spatialization_strength != 0.0 { + let (left_ear_position, right_ear_position) = + listener_ear_positions(listener_position, listener_orientation); + let (left_ear_direction, right_ear_direction) = + listener_ear_directions(listener_orientation); + let emitter_direction_relative_to_left_ear = + (position - left_ear_position).normalize_or_zero(); + let emitter_direction_relative_to_right_ear = + (position - right_ear_position).normalize_or_zero(); + let left_ear_volume = + (left_ear_direction.dot(emitter_direction_relative_to_left_ear) + 1.0) / 2.0; + let right_ear_volume = + (right_ear_direction.dot(emitter_direction_relative_to_right_ear) + 1.0) / 2.0; + ( + min_ear_amplitude + (1.0 - min_ear_amplitude) * left_ear_volume, + min_ear_amplitude + (1.0 - min_ear_amplitude) * right_ear_volume, + ) + } else { + (1.0, 1.0) + }; + SpatialGains { + attenuation, + left, + right, + mono: spatialization_strength != 0.0, + } + } + + #[allow(dead_code)] + fn spatialize( + &self, + input: Frame, + listener_position: Vec3, + listener_orientation: Quat, + time_in_chunk: f64, + ) -> Frame { + let position = self.position.interpolated_value(time_in_chunk); + let spatialization_strength = self + .spatialization_strength + .interpolated_value(time_in_chunk) + .clamp(0.0, 1.0); + let min_ear_amplitude = 1.0 - spatialization_strength; + + let mut output = input; + // attenuate volume + if let Some(attenuation_function) = self.attenuation_function { + let distance = (listener_position - position).length(); + let relative_distance = self.distances.relative_distance(distance); + let relative_volume = + attenuation_function.apply((1.0 - relative_distance).into()) as f32; + let amplitude = Tweenable::interpolate( + Decibels::SILENCE, + Decibels::IDENTITY, + relative_volume.into(), + ) + .as_amplitude(); + output *= amplitude; + } + // apply spatialization + if spatialization_strength != 0.0 { + output = output.as_mono(); + let (left_ear_position, right_ear_position) = + listener_ear_positions(listener_position, listener_orientation); + let (left_ear_direction, right_ear_direction) = + listener_ear_directions(listener_orientation); + let emitter_direction_relative_to_left_ear = + (position - left_ear_position).normalize_or_zero(); + let emitter_direction_relative_to_right_ear = + (position - right_ear_position).normalize_or_zero(); + let left_ear_volume = + (left_ear_direction.dot(emitter_direction_relative_to_left_ear) + 1.0) / 2.0; + let right_ear_volume = + (right_ear_direction.dot(emitter_direction_relative_to_right_ear) + 1.0) / 2.0; + output.left *= min_ear_amplitude + (1.0 - min_ear_amplitude) * left_ear_volume; + output.right *= min_ear_amplitude + (1.0 - min_ear_amplitude) * right_ear_volume; + } + output + } +} + +/// benilla: what [`SpatialData::spatialize`] does to a frame, factored out of the per-frame +/// loop — attenuation, then (for a spatialized track) mono-fold and per-ear gain. +#[derive(Clone, Copy)] +struct SpatialGains { + attenuation: f32, + left: f32, + right: f32, + mono: bool, +} + +impl SpatialGains { + fn lerp(&self, other: &Self, t: f32) -> Self { + Self { + attenuation: self.attenuation + (other.attenuation - self.attenuation) * t, + left: self.left + (other.left - self.left) * t, + right: self.right + (other.right - self.right) * t, + // The fold is a state, not a gain: a chunk whose strength crosses zero folds for + // the whole chunk, exactly as upstream would at every frame past the crossing. + mono: self.mono || other.mono, + } + } + + fn apply(&self, input: Frame) -> Frame { + let mut output = input * self.attenuation; + if self.mono { + output = output.as_mono(); + output.left *= self.left; + output.right *= self.right; + } + output + } +} + +#[must_use] +fn listener_ear_positions(listener_position: Vec3, listener_orientation: Quat) -> (Vec3, Vec3) { + const EAR_DISTANCE: f32 = 0.1; + let position = listener_position; + let orientation = listener_orientation; + let left = position + orientation * (Vec3::NEG_X * EAR_DISTANCE); + let right = position + orientation * (Vec3::X * EAR_DISTANCE); + (left, right) +} + +#[must_use] +fn listener_ear_directions(listener_orientation: Quat) -> (Vec3, Vec3) { + const EAR_ANGLE_FROM_HEAD: f32 = FRAC_PI_8; + let left_ear_direction_relative_to_head = + Quat::from_rotation_y(-EAR_ANGLE_FROM_HEAD) * Vec3::NEG_X; + let right_ear_direction_relative_to_head = Quat::from_rotation_y(EAR_ANGLE_FROM_HEAD) * Vec3::X; + let orientation = listener_orientation; + let left = orientation * left_ear_direction_relative_to_head; + let right = orientation * right_ear_direction_relative_to_head; + (left, right) +} + +command_writers_and_readers! { + set_volume: ValueChangeCommand, + set_position: ValueChangeCommand, + set_spatialization_strength: ValueChangeCommand, + pause: Tween, + resume: (StartTime, Tween), +} diff --git a/third_party/kira/src/track/sub/builder.rs b/third_party/kira/src/track/sub/builder.rs new file mode 100644 index 00000000..8ffbf090 --- /dev/null +++ b/third_party/kira/src/track/sub/builder.rs @@ -0,0 +1,282 @@ +use std::{collections::HashMap, sync::Arc}; + +use crate::{ + Decibels, Frame, Parameter, Value, + backend::{RendererShared, resources::ResourceStorage}, + command::command_writer_and_reader, + effect::EffectBuilder, + playback_state_manager::PlaybackStateManager, +}; + +use super::{ + Effect, SendTrackId, SendTrackRoute, Track, TrackHandle, TrackShared, + command_writers_and_readers, +}; + +/// Configures a mixer track. +pub struct TrackBuilder { + /// The volume of the track. + pub(crate) volume: Value, + /// The effects that should be applied to the input audio + /// for this track. + pub(crate) effects: Vec>, + /// The number of child tracks that can be added to this track. + pub(crate) sub_track_capacity: usize, + /// The maximum number of sounds that can be played simultaneously on this track. + pub(crate) sound_capacity: usize, + pub(crate) sends: HashMap>, + pub(crate) persist_until_sounds_finish: bool, +} + +impl TrackBuilder { + /// Creates a new [`TrackBuilder`] with the default settings. + #[must_use] + pub fn new() -> Self { + Self { + volume: Value::Fixed(Decibels::IDENTITY), + effects: vec![], + sub_track_capacity: 128, + sound_capacity: 128, + sends: HashMap::new(), + persist_until_sounds_finish: false, + } + } + + /** + Sets the volume of the track. + + # Examples + + Set the volume to a fixed decibel value: + + ``` + # use kira::track::TrackBuilder; + let builder = TrackBuilder::new().volume(-6.0); + ``` + + Link the volume to a modulator: + + ```no_run + use kira::{ + AudioManager, AudioManagerSettings, DefaultBackend, + modulator::tweener::TweenerBuilder, + track::TrackBuilder, + Easing, Value, Mapping, + Decibels, + }; + + let mut manager = AudioManager::::new(AudioManagerSettings::default())?; + let tweener = manager.add_modulator(TweenerBuilder { + initial_value: 0.5, + })?; + let builder = TrackBuilder::new().volume(Value::FromModulator { + id: tweener.id(), + mapping: Mapping { + input_range: (0.0, 1.0), + output_range: (Decibels::SILENCE, Decibels::IDENTITY), + easing: Easing::Linear, + }, + }); + # Result::<(), Box>::Ok(()) + ``` + */ + #[must_use = "This method consumes self and returns a modified TrackBuilder, so the return value should be used"] + pub fn volume(self, volume: impl Into>) -> Self { + Self { + volume: volume.into(), + ..self + } + } + + /// Sets the maximum number of sub-tracks this track can have. + #[must_use = "This method consumes self and returns a modified TrackBuilder, so the return value should be used"] + pub fn sub_track_capacity(self, capacity: usize) -> Self { + Self { + sub_track_capacity: capacity, + ..self + } + } + + /// Sets the maximum number of sounds that can be played simultaneously on this track. + #[must_use = "This method consumes self and returns a modified TrackBuilder, so the return value should be used"] + pub fn sound_capacity(self, capacity: usize) -> Self { + Self { + sound_capacity: capacity, + ..self + } + } + + /// Routes this track to the given send track with the given volume. + pub fn with_send( + mut self, + track: impl Into, + volume: impl Into>, + ) -> Self { + self.sends.insert(track.into(), volume.into()); + self + } + + /** + Adds an effect to the track. + + # Examples + + ``` + use kira::{track::TrackBuilder, effect::delay::DelayBuilder}; + + let mut builder = TrackBuilder::new(); + let delay_handle = builder.add_effect(DelayBuilder::new()); + ``` + */ + pub fn add_effect(&mut self, builder: B) -> B::Handle { + let (effect, handle) = builder.build(); + self.effects.push(effect); + handle + } + + /** + Adds an effect to the track and returns the [`TrackBuilder`]. + + If you need to modify the effect later, use [`add_effect`](Self::add_effect), + which returns the effect handle. + + # Examples + + ``` + use kira::{ + track::TrackBuilder, + effect::{filter::FilterBuilder, reverb::ReverbBuilder}, + }; + + let mut builder = TrackBuilder::new() + .with_effect(FilterBuilder::new()) + .with_effect(ReverbBuilder::new()); + ``` + */ + #[must_use = "This method consumes self and returns a modified TrackBuilder, so the return value should be used"] + pub fn with_effect(mut self, builder: B) -> Self { + self.add_effect(builder); + self + } + + /** Adds an already built effect into this track. + + `Box` values are created when calling `build` on an effect builder, which gives you + an effect handle, as well as this boxed effect, which is the actual audio effect. + + This is a lower-level method than [`Self::add_effect`], and you should probably use it rather + than this method, unless you have a reason to. + + # Examples + + ``` + use kira::track::TrackBuilder; + use kira::effect::{EffectBuilder, delay::DelayBuilder}; + + let mut builder = TrackBuilder::new(); + let delay_builder = DelayBuilder::new(); + let (effect, delay_handle) = delay_builder.build(); + let delay_handle = builder.add_built_effect(effect); + ``` + */ + pub fn add_built_effect(&mut self, effect: Box) { + self.effects.push(effect); + } + + /** Add an already-built effect and return the [`TrackBuilder`]. + + `Box` values are created when calling `build` on an effect builder, which gives you + an effect handle, as well as this boxed effect, which is the actual audio effect. + + This is a lower-level method than [`Self::with_effect`], and you should probably use it rather + than this method, unless you have a reason to. + + # Examples + + ``` + use kira::{ + track::TrackBuilder, + effect::{filter::FilterBuilder, reverb::ReverbBuilder, EffectBuilder}, + }; + + let (filter_effect, filter_handle) = FilterBuilder::new().build(); + let (reverb_effect, reverb_handle) = ReverbBuilder::new().build(); + let mut builder = TrackBuilder::new() + .with_built_effect(filter_effect) + .with_built_effect(reverb_effect); + ``` + */ + #[must_use = "This method consumes self and returns a modified TrackBuilder, so the return value should be used"] + pub fn with_built_effect(mut self, effect: Box) -> Self { + self.add_built_effect(effect); + self + } + + /// Sets whether the track should stay alive while sounds are playing on it. + /// + /// By default, as soon as a track's handle is dropped, the track is unloaded. + /// If this is set to `true`, the track will wait until all sounds on the track + /// are finished before unloading. + pub fn persist_until_sounds_finish(self, persist: bool) -> Self { + Self { + persist_until_sounds_finish: persist, + ..self + } + } + + #[must_use] + pub(crate) fn build( + self, + renderer_shared: Arc, + internal_buffer_size: usize, + ) -> (Track, TrackHandle) { + let (command_writers, command_readers) = command_writers_and_readers(); + let shared = Arc::new(TrackShared::new()); + let (sounds, sound_controller) = ResourceStorage::new(self.sound_capacity); + let (sub_tracks, sub_track_controller) = ResourceStorage::new(self.sub_track_capacity); + let mut sends = vec![]; + let mut send_volume_command_writers = HashMap::new(); + for (send_track_id, volume) in self.sends { + let (set_volume_command_writer, set_volume_command_reader) = + command_writer_and_reader(); + sends.push(( + send_track_id, + SendTrackRoute { + volume: Parameter::new(volume, Decibels::IDENTITY), + set_volume_command_reader, + }, + )); + send_volume_command_writers.insert(send_track_id, set_volume_command_writer); + } + let track = Track { + shared: shared.clone(), + command_readers, + volume: Parameter::new(self.volume, Decibels::IDENTITY), + sounds, + sub_tracks, + effects: self.effects, + sends, + persist_until_sounds_finish: self.persist_until_sounds_finish, + spatial_data: None, + playback_state_manager: PlaybackStateManager::new(None), + temp_buffer: vec![Frame::ZERO; internal_buffer_size], + internal_buffer_size, + }; + let handle = TrackHandle { + renderer_shared, + shared, + command_writers, + sound_controller, + sub_track_controller, + send_volume_command_writers, + internal_buffer_size, + }; + (track, handle) + } +} + +impl Default for TrackBuilder { + fn default() -> Self { + Self::new() + } +} diff --git a/third_party/kira/src/track/sub/handle.rs b/third_party/kira/src/track/sub/handle.rs new file mode 100644 index 00000000..b30afd6f --- /dev/null +++ b/third_party/kira/src/track/sub/handle.rs @@ -0,0 +1,165 @@ +use std::{ + collections::HashMap, + sync::{Arc, atomic::Ordering}, +}; + +use crate::{ + Decibels, PlaySoundError, ResourceLimitReached, StartTime, Tween, Value, + backend::{RendererShared, resources::ResourceController}, + command::{CommandWriter, ValueChangeCommand}, + listener::ListenerId, + sound::{Sound, SoundData}, + track::TrackPlaybackState, +}; + +use super::{ + CommandWriters, NonexistentRoute, SendTrackId, SpatialTrackBuilder, SpatialTrackHandle, Track, + TrackBuilder, TrackShared, +}; + +/// Controls a mixer track. +/// +/// When a [`TrackHandle`] is dropped, the corresponding mixer +/// track will be removed. +#[derive(Debug)] +pub struct TrackHandle { + pub(crate) renderer_shared: Arc, + pub(crate) shared: Arc, + pub(crate) command_writers: CommandWriters, + pub(crate) sound_controller: ResourceController>, + pub(crate) sub_track_controller: ResourceController, + pub(crate) send_volume_command_writers: + HashMap>>, + pub(crate) internal_buffer_size: usize, +} + +impl TrackHandle { + /// Returns the current playback state of the track. + #[must_use] + pub fn state(&self) -> TrackPlaybackState { + self.shared.state() + } + + /// Plays a sound. + pub fn play( + &mut self, + sound_data: D, + ) -> Result> { + let (sound, handle) = sound_data + .into_sound() + .map_err(PlaySoundError::IntoSoundError)?; + self.sound_controller + .insert(sound) + .map_err(|_| PlaySoundError::SoundLimitReached)?; + Ok(handle) + } + + /// Adds a child track to this track. + pub fn add_sub_track( + &mut self, + builder: TrackBuilder, + ) -> Result { + let (mut track, handle) = + builder.build(self.renderer_shared.clone(), self.internal_buffer_size); + track.init_effects(self.renderer_shared.sample_rate.load(Ordering::SeqCst)); + self.sub_track_controller.insert(track)?; + Ok(handle) + } + + /// Adds a spatial child track to this track. + pub fn add_spatial_sub_track( + &mut self, + listener: impl Into, + position: impl Into>>, + builder: SpatialTrackBuilder, + ) -> Result { + let (mut track, handle) = builder.build( + self.renderer_shared.clone(), + self.internal_buffer_size, + listener.into(), + position.into().to_(), + ); + track.init_effects(self.renderer_shared.sample_rate.load(Ordering::SeqCst)); + self.sub_track_controller.insert(track)?; + Ok(handle) + } + + /// Sets the (post-effects) volume of the mixer track. + pub fn set_volume(&mut self, volume: impl Into>, tween: Tween) { + self.command_writers.set_volume.write(ValueChangeCommand { + target: volume.into(), + tween, + }) + } + + /// Sets the volume of this track's route to a send track. + /// + /// This can only be used to change the volume of existing routes, + /// not to add new routes. + pub fn set_send( + &mut self, + to: impl Into, + volume: impl Into>, + tween: Tween, + ) -> Result<(), NonexistentRoute> { + let to = to.into(); + self.send_volume_command_writers + .get_mut(&to) + .ok_or(NonexistentRoute)? + .write(ValueChangeCommand { + target: volume.into(), + tween, + }); + Ok(()) + } + + /// Fades out the track to silence with the given tween and then + /// pauses playback, pausing all sounds and emitters playing on this + /// track. + pub fn pause(&mut self, tween: Tween) { + self.command_writers.pause.write(tween) + } + + /// Resumes playback and fades in the sound from silence + /// with the given tween, resuming all sounds and emitters + /// playing on this track. + pub fn resume(&mut self, tween: Tween) { + self.resume_at(StartTime::Immediate, tween) + } + + /// Resumes playback at the given start time and fades in + /// the sound from silence with the given tween. + pub fn resume_at(&mut self, start_time: StartTime, tween: Tween) { + self.command_writers.resume.write((start_time, tween)) + } + + /// Returns the maximum number of sounds that can play simultaneously on this track. + #[must_use] + pub fn sound_capacity(&self) -> usize { + self.sound_controller.capacity() + } + + /// Returns the number of sounds currently playing on this track. + #[must_use] + pub fn num_sounds(&self) -> usize { + self.sound_controller.len() + } + + /// Returns the maximum number of child tracks this track can have. + #[must_use] + pub fn sub_track_capacity(&self) -> usize { + self.sub_track_controller.capacity() + } + + /// Returns the number of child tracks this track has. + #[must_use] + pub fn num_sub_tracks(&self) -> usize { + self.sub_track_controller.len() + } +} + +impl Drop for TrackHandle { + fn drop(&mut self) { + self.shared.mark_for_removal(); + } +} diff --git a/third_party/kira/src/track/sub/spatial_builder.rs b/third_party/kira/src/track/sub/spatial_builder.rs new file mode 100644 index 00000000..124e1a5a --- /dev/null +++ b/third_party/kira/src/track/sub/spatial_builder.rs @@ -0,0 +1,395 @@ +use std::{collections::HashMap, ops::RangeInclusive, sync::Arc}; + +use glam::Vec3; + +use crate::{ + Decibels, Frame, Parameter, Value, + backend::{RendererShared, resources::ResourceStorage}, + command::command_writer_and_reader, + effect::EffectBuilder, + listener::ListenerId, + playback_state_manager::PlaybackStateManager, + tween::Easing, +}; + +use super::{ + Effect, SendTrackId, SendTrackRoute, SpatialData, SpatialTrackHandle, Track, TrackShared, + command_writers_and_readers, +}; + +/// Configures a spatial mixer track. +pub struct SpatialTrackBuilder { + /// The volume of the track. + pub(crate) volume: Value, + /// The effects that should be applied to the input audio + /// for this track. + pub(crate) effects: Vec>, + /// The number of child tracks that can be added to this track. + pub(crate) sub_track_capacity: usize, + /// The maximum number of sounds that can be played simultaneously on this track. + pub(crate) sound_capacity: usize, + pub(crate) sends: HashMap>, + pub(crate) persist_until_sounds_finish: bool, + /// The distances from a listener at which the track is loudest and quietest. + pub(crate) distances: SpatialTrackDistances, + /// How the track's volume will change with distance. + /// + /// If `None`, the track will output at a constant volume. + pub(crate) attenuation_function: Option, + /// How much the track's output should be panned left or right depending on its + /// direction from the listener. + /// + /// This value should be between `0.0` and `1.0`. `0.0` disables spatialization + /// entirely. + pub(crate) spatialization_strength: Value, +} + +impl SpatialTrackBuilder { + /// Creates a new [`SpatialTrackBuilder`] with the default settings. + #[must_use] + pub fn new() -> Self { + Self { + volume: Value::Fixed(Decibels::IDENTITY), + effects: vec![], + sub_track_capacity: 128, + sound_capacity: 128, + sends: HashMap::new(), + persist_until_sounds_finish: false, + distances: SpatialTrackDistances::default(), + attenuation_function: Some(Easing::Linear), + spatialization_strength: Value::Fixed(0.75), + } + } + + /** + Sets the volume of the track. + + # Examples + + Set the volume to a fixed decibel value: + + ``` + # use kira::track::SpatialTrackBuilder; + let builder = SpatialTrackBuilder::new().volume(-6.0); + ``` + + Link the volume to a modulator: + + ```no_run + use kira::{ + AudioManager, AudioManagerSettings, DefaultBackend, + modulator::tweener::TweenerBuilder, + track::SpatialTrackBuilder, + Easing, Value, Mapping, + Decibels, + }; + + let mut manager = AudioManager::::new(AudioManagerSettings::default())?; + let tweener = manager.add_modulator(TweenerBuilder { + initial_value: 0.5, + })?; + let builder = SpatialTrackBuilder::new().volume(Value::FromModulator { + id: tweener.id(), + mapping: Mapping { + input_range: (0.0, 1.0), + output_range: (Decibels::SILENCE, Decibels::IDENTITY), + easing: Easing::Linear, + }, + }); + # Result::<(), Box>::Ok(()) + ``` + */ + #[must_use = "This method consumes self and returns a modified SpatialTrackBuilder, so the return value should be used"] + pub fn volume(self, volume: impl Into>) -> Self { + Self { + volume: volume.into(), + ..self + } + } + + /// Sets the maximum number of sub-tracks this track can have. + #[must_use = "This method consumes self and returns a modified SpatialTrackBuilder, so the return value should be used"] + pub fn sub_track_capacity(self, capacity: usize) -> Self { + Self { + sub_track_capacity: capacity, + ..self + } + } + + /// Sets the maximum number of sounds that can be played simultaneously on this track. + #[must_use = "This method consumes self and returns a modified SpatialTrackBuilder, so the return value should be used"] + pub fn sound_capacity(self, capacity: usize) -> Self { + Self { + sound_capacity: capacity, + ..self + } + } + + /// Routes this track to the given send track with the given volume. + pub fn with_send( + mut self, + track: impl Into, + volume: impl Into>, + ) -> Self { + self.sends.insert(track.into(), volume.into()); + self + } + + /** + Adds an effect to the track. + + # Examples + + ``` + use kira::{track::SpatialTrackBuilder, effect::delay::DelayBuilder}; + + let mut builder = SpatialTrackBuilder::new(); + let delay_handle = builder.add_effect(DelayBuilder::new()); + ``` + */ + pub fn add_effect(&mut self, builder: B) -> B::Handle { + let (effect, handle) = builder.build(); + self.effects.push(effect); + handle + } + + /** + Adds an effect to the track and returns the [`SpatialTrackBuilder`]. + + If you need to modify the effect later, use [`add_effect`](Self::add_effect), + which returns the effect handle. + + # Examples + + ``` + use kira::{ + track::SpatialTrackBuilder, + effect::{filter::FilterBuilder, reverb::ReverbBuilder}, + }; + + let mut builder = SpatialTrackBuilder::new() + .with_effect(FilterBuilder::new()) + .with_effect(ReverbBuilder::new()); + ``` + */ + #[must_use = "This method consumes self and returns a modified SpatialTrackBuilder, so the return value should be used"] + pub fn with_effect(mut self, builder: B) -> Self { + self.add_effect(builder); + self + } + + /** Adds an already built effect into this track. + + `Box` values are created when calling `build` on an effect builder, which gives you + an effect handle, as well as this boxed effect, which is the actual audio effect. + + This is a lower-level method than [`Self::add_effect`], and you should probably use it rather + than this method, unless you have a reason to. + + # Examples + + ``` + use kira::track::SpatialTrackBuilder; + use kira::effect::{EffectBuilder, delay::DelayBuilder}; + + let mut builder = SpatialTrackBuilder::new(); + let delay_builder = DelayBuilder::new(); + let (effect, delay_handle) = delay_builder.build(); + let delay_handle = builder.add_built_effect(effect); + ``` + */ + pub fn add_built_effect(&mut self, effect: Box) { + self.effects.push(effect); + } + + /** Add an already-built effect and return the [`SpatialTrackBuilder`]. + + `Box` values are created when calling `build` on an effect builder, which gives you + an effect handle, as well as this boxed effect, which is the actual audio effect. + + This is a lower-level method than [`Self::with_effect`], and you should probably use it rather + than this method, unless you have a reason to. + + # Examples + + ``` + use kira::{ + track::SpatialTrackBuilder, + effect::{filter::FilterBuilder, reverb::ReverbBuilder, EffectBuilder}, + }; + + let (filter_effect, filter_handle) = FilterBuilder::new().build(); + let (reverb_effect, reverb_handle) = ReverbBuilder::new().build(); + let mut builder = SpatialTrackBuilder::new() + .with_built_effect(filter_effect) + .with_built_effect(reverb_effect); + ``` + */ + #[must_use = "This method consumes self and returns a modified SpatialTrackBuilder, so the return value should be used"] + pub fn with_built_effect(mut self, effect: Box) -> Self { + self.add_built_effect(effect); + self + } + + /// Sets whether the track should stay alive while sounds are playing on it. + /// + /// By default, as soon as a track's handle is dropped, the track is unloaded. + /// If this is set to `true`, the track will wait until all sounds on the track + /// are finished before unloading. + pub fn persist_until_sounds_finish(self, persist: bool) -> Self { + Self { + persist_until_sounds_finish: persist, + ..self + } + } + + /// Sets the distances from a listener at which the emitter is loudest and quietest. + #[must_use = "This method consumes self and returns a modified SpatialTrackBuilder, so the return value should be used"] + pub fn distances(self, distances: impl Into) -> Self { + Self { + distances: distances.into(), + ..self + } + } + + /// Sets how the emitter's volume will change with distance. + /// + /// If `None`, the emitter will output at a constant volume. + #[must_use = "This method consumes self and returns a modified SpatialTrackBuilder, so the return value should be used"] + pub fn attenuation_function(self, attenuation_function: impl Into>) -> Self { + Self { + attenuation_function: attenuation_function.into(), + ..self + } + } + + /// Sets how much the track's output should be panned left or right depending on its + /// direction from the listener. + /// + /// This value should be between `0.0` and `1.0`. `0.0` disables spatialization + /// entirely. + #[must_use = "This method consumes self and returns a modified SpatialTrackBuilder, so the return value should be used"] + pub fn spatialization_strength(self, spatialization_strength: impl Into>) -> Self { + Self { + spatialization_strength: spatialization_strength.into(), + ..self + } + } + + #[must_use] + pub(crate) fn build( + self, + renderer_shared: Arc, + internal_buffer_size: usize, + listener_id: ListenerId, + position: Value, + ) -> (Track, SpatialTrackHandle) { + let (command_writers, command_readers) = command_writers_and_readers(); + let shared = Arc::new(TrackShared::new()); + let (sounds, sound_controller) = ResourceStorage::new(self.sound_capacity); + let (sub_tracks, sub_track_controller) = ResourceStorage::new(self.sub_track_capacity); + let mut sends = vec![]; + let mut send_volume_command_writers = HashMap::new(); + for (send_track_id, volume) in self.sends { + let (set_volume_command_writer, set_volume_command_reader) = + command_writer_and_reader(); + sends.push(( + send_track_id, + SendTrackRoute { + volume: Parameter::new(volume, Decibels::IDENTITY), + set_volume_command_reader, + }, + )); + send_volume_command_writers.insert(send_track_id, set_volume_command_writer); + } + let track = Track { + shared: shared.clone(), + command_readers, + volume: Parameter::new(self.volume, Decibels::IDENTITY), + sounds, + sub_tracks, + effects: self.effects, + sends, + persist_until_sounds_finish: self.persist_until_sounds_finish, + spatial_data: Some(SpatialData { + listener_id, + position: Parameter::new(position, Vec3::ZERO), + distances: self.distances, + attenuation_function: self.attenuation_function, + spatialization_strength: Parameter::new(self.spatialization_strength, 0.75), + }), + playback_state_manager: PlaybackStateManager::new(None), + temp_buffer: vec![Frame::ZERO; internal_buffer_size], + internal_buffer_size, + }; + let handle = SpatialTrackHandle { + renderer_shared, + shared, + command_writers, + sound_controller, + sub_track_controller, + send_volume_command_writers, + internal_buffer_size, + }; + (track, handle) + } +} + +impl Default for SpatialTrackBuilder { + fn default() -> Self { + Self::new() + } +} + +/// The distances from a listener at which an emitter is loudest and quietest. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct SpatialTrackDistances { + /// The distance from a listener at which an emitter outputs at full volume. + pub min_distance: f32, + /// The distance from a listener at which an emitter becomes inaudible. + pub max_distance: f32, +} + +impl SpatialTrackDistances { + #[must_use] + pub(crate) fn relative_distance(&self, distance: f32) -> f32 { + let distance = distance.clamp(self.min_distance, self.max_distance); + (distance - self.min_distance) / (self.max_distance - self.min_distance) + } +} + +impl Default for SpatialTrackDistances { + fn default() -> Self { + Self { + min_distance: 1.0, + max_distance: 100.0, + } + } +} + +impl From<(f32, f32)> for SpatialTrackDistances { + fn from((min_distance, max_distance): (f32, f32)) -> Self { + Self { + min_distance, + max_distance, + } + } +} + +impl From<[f32; 2]> for SpatialTrackDistances { + fn from([min_distance, max_distance]: [f32; 2]) -> Self { + Self { + min_distance, + max_distance, + } + } +} + +impl From> for SpatialTrackDistances { + fn from(range: RangeInclusive) -> Self { + Self { + min_distance: *range.start(), + max_distance: *range.end(), + } + } +} diff --git a/third_party/kira/src/track/sub/spatial_handle.rs b/third_party/kira/src/track/sub/spatial_handle.rs new file mode 100644 index 00000000..96c5c8b2 --- /dev/null +++ b/third_party/kira/src/track/sub/spatial_handle.rs @@ -0,0 +1,192 @@ +use std::{ + collections::HashMap, + sync::{Arc, atomic::Ordering}, +}; + +use crate::{ + Decibels, PlaySoundError, ResourceLimitReached, StartTime, Tween, Value, + backend::{RendererShared, resources::ResourceController}, + command::{CommandWriter, ValueChangeCommand}, + listener::ListenerId, + sound::{Sound, SoundData}, + track::TrackPlaybackState, +}; + +use super::{ + CommandWriters, NonexistentRoute, SendTrackId, SpatialTrackBuilder, Track, TrackBuilder, + TrackHandle, TrackShared, +}; + +/// Controls a mixer track. +/// +/// When a [`SpatialTrackHandle`] is dropped, the corresponding mixer +/// track will be removed. +#[derive(Debug)] +pub struct SpatialTrackHandle { + pub(crate) renderer_shared: Arc, + pub(crate) shared: Arc, + pub(crate) command_writers: CommandWriters, + pub(crate) sound_controller: ResourceController>, + pub(crate) sub_track_controller: ResourceController, + pub(crate) send_volume_command_writers: + HashMap>>, + pub(crate) internal_buffer_size: usize, +} + +impl SpatialTrackHandle { + /// Returns the current playback state of the track. + #[must_use] + pub fn state(&self) -> TrackPlaybackState { + self.shared.state() + } + + /// Plays a sound. + pub fn play( + &mut self, + sound_data: D, + ) -> Result> { + let (sound, handle) = sound_data + .into_sound() + .map_err(PlaySoundError::IntoSoundError)?; + self.sound_controller + .insert(sound) + .map_err(|_| PlaySoundError::SoundLimitReached)?; + Ok(handle) + } + + /// Adds a child track to this track. + pub fn add_sub_track( + &mut self, + builder: TrackBuilder, + ) -> Result { + let (mut track, handle) = + builder.build(self.renderer_shared.clone(), self.internal_buffer_size); + track.init_effects(self.renderer_shared.sample_rate.load(Ordering::SeqCst)); + self.sub_track_controller.insert(track)?; + Ok(handle) + } + + /// Adds a spatial child track to this track. + pub fn add_spatial_sub_track( + &mut self, + listener: impl Into, + position: impl Into>>, + builder: SpatialTrackBuilder, + ) -> Result { + let (mut track, handle) = builder.build( + self.renderer_shared.clone(), + self.internal_buffer_size, + listener.into(), + position.into().to_(), + ); + track.init_effects(self.renderer_shared.sample_rate.load(Ordering::SeqCst)); + self.sub_track_controller.insert(track)?; + Ok(handle) + } + + /// Sets the (post-effects) volume of the mixer track. + pub fn set_volume(&mut self, volume: impl Into>, tween: Tween) { + self.command_writers.set_volume.write(ValueChangeCommand { + target: volume.into(), + tween, + }) + } + + /// Sets the position that audio is produced from. + pub fn set_position(&mut self, position: impl Into>>, tween: Tween) { + let position: Value> = position.into(); + self.command_writers.set_position.write(ValueChangeCommand { + target: position.to_(), + tween, + }) + } + + /// Sets how much the track's output should be panned left or right depending on its + /// direction from the listener. + /// + /// This value should be between `0.0` and `1.0`. `0.0` disables spatialization + /// entirely. + pub fn set_spatialization_strength( + &mut self, + spatialization_strength: impl Into>, + tween: Tween, + ) { + self.command_writers + .set_spatialization_strength + .write(ValueChangeCommand { + target: spatialization_strength.into(), + tween, + }) + } + + /// Sets the volume of this track's route to a send track. + /// + /// This can only be used to change the volume of existing routes, + /// not to add new routes. + pub fn set_send( + &mut self, + to: impl Into, + volume: impl Into>, + tween: Tween, + ) -> Result<(), NonexistentRoute> { + let to = to.into(); + self.send_volume_command_writers + .get_mut(&to) + .ok_or(NonexistentRoute)? + .write(ValueChangeCommand { + target: volume.into(), + tween, + }); + Ok(()) + } + + /// Fades out the track to silence with the given tween and then + /// pauses playback, pausing all sounds and emitters playing on this + /// track. + pub fn pause(&mut self, tween: Tween) { + self.command_writers.pause.write(tween) + } + + /// Resumes playback and fades in the sound from silence + /// with the given tween, resuming all sounds and emitters + /// playing on this track. + pub fn resume(&mut self, tween: Tween) { + self.resume_at(StartTime::Immediate, tween) + } + + /// Resumes playback at the given start time and fades in + /// the sound from silence with the given tween. + pub fn resume_at(&mut self, start_time: StartTime, tween: Tween) { + self.command_writers.resume.write((start_time, tween)) + } + + /// Returns the maximum number of sounds that can play simultaneously on this track. + #[must_use] + pub fn sound_capacity(&self) -> usize { + self.sound_controller.capacity() + } + + /// Returns the number of sounds currently playing on this track. + #[must_use] + pub fn num_sounds(&self) -> usize { + self.sound_controller.len() + } + + /// Returns the maximum number of child tracks this track can have. + #[must_use] + pub fn sub_track_capacity(&self) -> usize { + self.sub_track_controller.capacity() + } + + /// Returns the number of child tracks this track has. + #[must_use] + pub fn num_sub_tracks(&self) -> usize { + self.sub_track_controller.len() + } +} + +impl Drop for SpatialTrackHandle { + fn drop(&mut self) { + self.shared.mark_for_removal(); + } +} diff --git a/third_party/kira/src/tween.rs b/third_party/kira/src/tween.rs new file mode 100644 index 00000000..eaf910d4 --- /dev/null +++ b/third_party/kira/src/tween.rs @@ -0,0 +1,112 @@ +//! Smooth interpolation between values. + +mod tweenable; + +pub use tweenable::*; + +use std::time::Duration; + +use crate::start_time::StartTime; + +/// Curves the motion of a [`Tween`]. +#[derive(Debug, Clone, Copy, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub enum Easing { + /// Maintains a constant speed for the duration of the [`Tween`]. + Linear, + /// Causes the [`Tween`] to start slow and speed up. A higher + /// value causes the [`Tween`] to speed up more dramatically. + InPowi(i32), + /// Causes the [`Tween`] to start fast and slow down. A higher + /// value causes the [`Tween`] to slow down more dramatically. + OutPowi(i32), + /// Causes the [`Tween`] to start slow, speed up, and then slow + /// back down. A higher values causes the [`Tween`] to have more + /// dramatic speed changes. + InOutPowi(i32), + /// Causes the [`Tween`] to start slow and speed up. A higher + /// value causes the [`Tween`] to speed up more dramatically. + /// + /// This is similar to [`InPowi`](Easing::InPowi), but allows + /// for float intensity values at the cost of being more + /// CPU intensive. + InPowf(f64), + /// Causes the [`Tween`] to start fast and slow down. A higher + /// value causes the [`Tween`] to slow down more dramatically. + /// + /// This is similar to [`OutPowi`](Easing::InPowi), but allows + /// for float intensity values at the cost of being more + /// CPU intensive. + OutPowf(f64), + /// Causes the [`Tween`] to start slow, speed up, and then slow + /// back down. A higher values causes the [`Tween`] to have more + /// dramatic speed changes. + /// + /// This is similar to [`InOutPowi`](Easing::InPowi), but allows + /// for float intensity values at the cost of being more + /// CPU intensive. + InOutPowf(f64), +} + +impl Easing { + pub(crate) fn apply(&self, mut x: f64) -> f64 { + match self { + Easing::Linear => x, + Easing::InPowi(power) => x.powi(*power), + Easing::OutPowi(power) => 1.0 - Self::InPowi(*power).apply(1.0 - x), + Easing::InOutPowi(power) => { + x *= 2.0; + if x < 1.0 { + 0.5 * Self::InPowi(*power).apply(x) + } else { + x = 2.0 - x; + 0.5 * (1.0 - Self::InPowi(*power).apply(x)) + 0.5 + } + } + Easing::InPowf(power) => x.powf(*power), + Easing::OutPowf(power) => 1.0 - Self::InPowf(*power).apply(1.0 - x), + Easing::InOutPowf(power) => { + x *= 2.0; + if x < 1.0 { + 0.5 * Self::InPowf(*power).apply(x) + } else { + x = 2.0 - x; + 0.5 * (1.0 - Self::InPowf(*power).apply(x)) + 0.5 + } + } + } + } +} + +impl Default for Easing { + fn default() -> Self { + Self::Linear + } +} + +/// Describes a smooth transition between values. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Tween { + /// When the motion starts. + pub start_time: StartTime, + /// The duration of the motion. + pub duration: Duration, + /// The curve of the motion. + pub easing: Easing, +} + +impl Tween { + pub(super) fn value(&self, time: f64) -> f64 { + self.easing.apply(time / self.duration.as_secs_f64()) + } +} + +impl Default for Tween { + fn default() -> Self { + Self { + start_time: StartTime::default(), + duration: Duration::from_millis(10), + easing: Easing::Linear, + } + } +} diff --git a/third_party/kira/src/tween/tweenable.rs b/third_party/kira/src/tween/tweenable.rs new file mode 100644 index 00000000..f4ce25b1 --- /dev/null +++ b/third_party/kira/src/tween/tweenable.rs @@ -0,0 +1,46 @@ +use std::time::Duration; + +use glam::{Quat, Vec3}; + +/// A trait for types that can be smoothly interpolated. +pub trait Tweenable: Copy { + /// Returns an linearly interpolated value between `a` and `b`. + /// + /// An amount of `0.0` should yield `a`, an amount of `1.0` should + /// yield `b`, and an amount of `0.5` should yield a value halfway + /// between `a` and `b`. + #[must_use] + fn interpolate(a: Self, b: Self, amount: f64) -> Self; +} + +impl Tweenable for f32 { + fn interpolate(a: Self, b: Self, amount: f64) -> Self { + a + (b - a) * amount as f32 + } +} + +impl Tweenable for f64 { + fn interpolate(a: Self, b: Self, amount: f64) -> Self { + a + (b - a) * amount + } +} + +impl Tweenable for Vec3 { + fn interpolate(a: Self, b: Self, amount: f64) -> Self { + a + (b - a) * amount as f32 + } +} + +impl Tweenable for Quat { + fn interpolate(a: Self, b: Self, amount: f64) -> Self { + a.slerp(b, amount as f32) + } +} + +impl Tweenable for Duration { + fn interpolate(a: Self, b: Self, amount: f64) -> Self { + let a_secs = a.as_secs_f64(); + let b_secs = b.as_secs_f64(); + Duration::from_secs_f64(a_secs + (b_secs - a_secs) * amount) + } +} diff --git a/third_party/kira/src/value.rs b/third_party/kira/src/value.rs new file mode 100644 index 00000000..bb0827ca --- /dev/null +++ b/third_party/kira/src/value.rs @@ -0,0 +1,337 @@ +use std::{ + ops::{Add, Div, Mul, Neg, Rem, Sub}, + time::Duration, +}; + +use crate::{Easing, Tweenable, info::Info, modulator::ModulatorId}; + +/// A value that a parameter can be linked to. +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum Value { + /// A fixed value. + Fixed(T), + /// The value of a [`modulator`](crate::modulator). + FromModulator { + /// The modulator to link to. + id: ModulatorId, + /// How the modulator's value should be converted to the parameter's value. + mapping: Mapping, + }, + /// The distance of the current spatial track to a listener. + FromListenerDistance(Mapping), +} + +impl Value { + /// Creates a `Value::FromModulator` from a modulator ID or handle. + #[must_use] + pub fn from_modulator(id: impl Into, mapping: Mapping) -> Self { + Self::FromModulator { + id: id.into(), + mapping, + } + } + + /// Converts a `Value` to a `Value`. + #[must_use = "This method returns a new Value and does not mutate the original."] + pub fn to_>(self) -> Value { + match self { + Value::Fixed(value) => Value::Fixed(value.into()), + Value::FromModulator { id, mapping } => Value::FromModulator { + id, + mapping: mapping.to_(), + }, + Value::FromListenerDistance(mapping) => Value::FromListenerDistance(mapping.to_()), + } + } +} + +impl Value +where + T: Tweenable, +{ + pub(crate) fn raw_value(self, info: &Info) -> Option { + match self { + Value::Fixed(value) => Some(value), + Value::FromModulator { id, mapping } => { + info.modulator_value(id).map(|value| mapping.map(value)) + } + Value::FromListenerDistance(mapping) => info + .listener_distance() + .map(|value| mapping.map(value.into())), + } + } +} + +impl From for Value { + fn from(value: f32) -> Self { + Self::Fixed(value) + } +} + +impl From for Value { + fn from(value: f64) -> Self { + Self::Fixed(value) + } +} + +impl From for Value> +where + T: Into>, +{ + fn from(value: T) -> Self { + Self::Fixed(value.into()) + } +} + +impl From for Value> +where + T: Into>, +{ + fn from(value: T) -> Self { + Self::Fixed(value.into()) + } +} + +impl From for Value { + fn from(value: Duration) -> Self { + Self::Fixed(value) + } +} + +impl Default for Value { + fn default() -> Self { + Self::Fixed(T::default()) + } +} + +impl Add for Value +where + T: Add, + Rhs: Copy, +{ + type Output = Self; + + fn add(self, rhs: Rhs) -> Self::Output { + match self { + Value::Fixed(value) => Self::Fixed(value + rhs), + Value::FromModulator { id, mapping } => Value::FromModulator { + id, + mapping: mapping.add_output(rhs), + }, + Value::FromListenerDistance(mapping) => { + Value::FromListenerDistance(mapping.add_output(rhs)) + } + } + } +} + +impl Sub for Value +where + T: Sub, + Rhs: Copy, +{ + type Output = Self; + + fn sub(self, rhs: Rhs) -> Self::Output { + match self { + Value::Fixed(value) => Self::Fixed(value - rhs), + Value::FromModulator { id, mapping } => Value::FromModulator { + id, + mapping: mapping.sub_output(rhs), + }, + Value::FromListenerDistance(mapping) => { + Value::FromListenerDistance(mapping.sub_output(rhs)) + } + } + } +} + +impl Mul for Value +where + T: Mul, + Rhs: Copy, +{ + type Output = Self; + + fn mul(self, rhs: Rhs) -> Self::Output { + match self { + Value::Fixed(value) => Self::Fixed(value * rhs), + Value::FromModulator { id, mapping } => Value::FromModulator { + id, + mapping: mapping.mul_output(rhs), + }, + Value::FromListenerDistance(mapping) => { + Value::FromListenerDistance(mapping.mul_output(rhs)) + } + } + } +} + +impl Div for Value +where + T: Div, + Rhs: Copy, +{ + type Output = Self; + + fn div(self, rhs: Rhs) -> Self::Output { + match self { + Value::Fixed(value) => Self::Fixed(value / rhs), + Value::FromModulator { id, mapping } => Value::FromModulator { + id, + mapping: mapping.div_output(rhs), + }, + Value::FromListenerDistance(mapping) => { + Value::FromListenerDistance(mapping.div_output(rhs)) + } + } + } +} + +impl Rem for Value +where + T: Rem, + Rhs: Copy, +{ + type Output = Self; + + fn rem(self, rhs: Rhs) -> Self::Output { + match self { + Value::Fixed(value) => Self::Fixed(value % rhs), + Value::FromModulator { id, mapping } => Value::FromModulator { + id, + mapping: mapping.rem_output(rhs), + }, + Value::FromListenerDistance(mapping) => { + Value::FromListenerDistance(mapping.rem_output(rhs)) + } + } + } +} + +impl Neg for Value +where + T: Neg, +{ + type Output = Self; + + fn neg(self) -> Self::Output { + match self { + Value::Fixed(value) => Self::Fixed(-value), + Value::FromModulator { id, mapping } => Value::FromModulator { + id, + mapping: mapping.neg_output(), + }, + Value::FromListenerDistance(mapping) => { + Value::FromListenerDistance(mapping.neg_output()) + } + } + } +} + +/// A transformation from a modulator's value to a parameter value. +#[derive(Debug, Clone, Copy, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct Mapping { + /// A range of values from a modulator. + pub input_range: (f64, f64), + /// The corresponding range of values of the parameter. + pub output_range: (T, T), + /// The curve to apply to the output. + pub easing: Easing, +} + +impl Mapping { + /// Converts a `Mapping` to a `Mapping`. + #[must_use = "This method returns a new Mapping and does not mutate the original."] + pub fn to_>(self) -> Mapping { + Mapping { + input_range: self.input_range, + output_range: (self.output_range.0.into(), self.output_range.1.into()), + easing: self.easing, + } + } + + /// Transforms an input value to an output value using this mapping. + #[must_use] + pub fn map(self, input: f64) -> T + where + T: Tweenable, + { + let mut amount = (input - self.input_range.0) / (self.input_range.1 - self.input_range.0); + amount = amount.clamp(0.0, 1.0); + amount = self.easing.apply(amount); + T::interpolate(self.output_range.0, self.output_range.1, amount) + } + + /// Adds `rhs` to the minimum and maximum values of the output range. + pub fn add_output(self, rhs: Rhs) -> Self + where + T: Add, + Rhs: Copy, + { + Self { + output_range: (self.output_range.0 + rhs, self.output_range.1 + rhs), + ..self + } + } + + /// Subtracts `rhs` from the minimum and maximum values of the output range. + pub fn sub_output(self, rhs: Rhs) -> Self + where + T: Sub, + Rhs: Copy, + { + Self { + output_range: (self.output_range.0 - rhs, self.output_range.1 - rhs), + ..self + } + } + + /// Multiplies the minimum and maximum values of the output range by `rhs`. + pub fn mul_output(self, rhs: Rhs) -> Self + where + T: Mul, + Rhs: Copy, + { + Self { + output_range: (self.output_range.0 * rhs, self.output_range.1 * rhs), + ..self + } + } + + /// Divides the minimum and maximum values of the output range by `rhs`. + pub fn div_output(self, rhs: Rhs) -> Self + where + T: Div, + Rhs: Copy, + { + Self { + output_range: (self.output_range.0 / rhs, self.output_range.1 / rhs), + ..self + } + } + + /// Sets the minimum and maximum values of the output range to `x % rhs`. + pub fn rem_output(self, rhs: Rhs) -> Self + where + T: Rem, + Rhs: Copy, + { + Self { + output_range: (self.output_range.0 % rhs, self.output_range.1 % rhs), + ..self + } + } + + /// Negates the minimum and maximum values of the output range. + pub fn neg_output(self) -> Self + where + T: Neg, + { + Self { + output_range: (-self.output_range.0, -self.output_range.1), + ..self + } + } +}