Skip to content

Conversation

richiemcilroy
Copy link
Member

@richiemcilroy richiemcilroy commented Sep 5, 2025

This PR adds per-segment audio mute and playback speed controls to the timeline editor, allowing users to individually adjust speed and mute audio for each segment.

Demo:
https://cap.link/jg1p9a7zgwdysek

Summary by CodeRabbit

  • New Features
    • Control playback speed per clip (0.5×–5×) with a slider, presets, and a live speed readout.
    • Mute audio per clip with a simple toggle.
    • Timeline clip lengths now adjust to reflect the selected playback speed.
    • Audio playback honors per-clip mute in addition to global mute.

Copy link
Contributor

coderabbitai bot commented Sep 5, 2025

Walkthrough

Adds per-segment audio mute support and playback speed controls. Extends TimelineSegment with mute flags, updates project config creation, UI to toggle mute and adjust speed, timeline rendering to account for timescale in segment length, and audio engine to mute output when current segment is muted. Adds a segment lookup API.

Changes

Cohort / File(s) Summary
Project configuration model & API
crates/project/src/configuration.rs
Adds mute_audio: bool (serde default) to TimelineSegment. Introduces TimelineConfiguration::get_segment_at_time(frame_time: f64) -> Option<&TimelineSegment> to retrieve the active segment by accumulated duration.
Audio engine mute integration
crates/editor/src/audio.rs
Computes segment_muted via timeline.get_segment_at_time(...). Extends muting condition to `project.audio.mute
Desktop project creation (tauri)
apps/desktop/src-tauri/src/recording.rs
Initializes TimelineSegment with mute_audio: false when building ProjectConfiguration.timeline.segments.
Type surface for desktop app
apps/desktop/src/utils/tauri.ts
Extends exported TimelineSegment type with optional muteAudio?: boolean.
Editor UI: clip segment controls
apps/desktop/src/routes/editor/ConfigSidebar.tsx
Adds playback speed UI (slider, presets) writing to segment.timescale with clamping [0.5, 5]. Adds per-segment Audio toggle binding to segment.muteAudio. Imports gauge and volume-x icons.
Timeline rendering adjustment
apps/desktop/src/routes/editor/Timeline/ClipTrack.tsx
Computes relative segment end as prevDuration() + (segment.end - segment.start) / segment.timescale, aligning visual length with timescale.

Sequence Diagram(s)

sequenceDiagram
  autonumber
  participant U as User
  participant UI as Editor UI (ConfigSidebar)
  participant TL as Timeline State
  participant PR as Project Config
  participant AE as Audio Engine

  U->>UI: Toggle "Audio" / adjust "Playback Speed"
  UI->>TL: Update segment.muteAudio / segment.timescale
  TL->>PR: Persist updated TimelineSegment

  rect rgba(230,245,255,0.5)
    note over AE,PR: During playback/render
    AE->>PR: get_segment_at_time(playhead)
    PR-->>AE: TimelineSegment { mute_audio, timescale, ... }
    AE->>AE: If mute_audio || global mute → silence frame
  end
Loading
sequenceDiagram
  autonumber
  participant UI as Timeline View (ClipTrack)
  participant TL as Timeline State

  UI->>TL: Read segment {start,end,timescale}
  TL-->>UI: Segment data
  UI->>UI: Compute width = (end-start)/timescale
  UI-->>UI: Render clip with adjusted length
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

  • Automatic zoom segment generation #558 — Also modifies apps/desktop/src-tauri/src/recording.rs, specifically project_config_from_recording; closely related to how timeline segments are constructed.

Suggested labels

codex

Suggested reviewers

  • Brendonovich

Poem

A hare taps sliders, swift and neat,
Nips sound to hush, makes time repeat.
Clips stretch thin or snap back tight—
Mute by moon, speed by night.
Burrowed code, carrot-bright glow,
Hop, render, hush—on with the show! 🥕🐇

✨ Finishing Touches
  • 📝 Generate Docstrings
🧪 Generate unit tests
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch speed-change

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share
🪧 Tips

Chat

There are 3 ways to chat with CodeRabbit:

  • Review comments: Directly reply to a review comment made by CodeRabbit. Example:
    • I pushed a fix in commit <commit_id>, please review it.
    • Open a follow-up GitHub issue for this discussion.
  • Files and specific lines of code (under the "Files changed" tab): Tag @coderabbitai in a new review comment at the desired location with your query.
  • PR comments: Tag @coderabbitai in a new PR comment to ask questions about the PR branch. For the best results, please provide a very specific query, as very limited context is provided in this mode. Examples:
    • @coderabbitai gather interesting stats about this repository and render them as a table. Additionally, render a pie chart showing the language distribution in the codebase.
    • @coderabbitai read the files in the src/scheduler package and generate a class diagram using mermaid and a README in the markdown format.

Support

Need help? Create a ticket on our support page for assistance with any issues or questions.

CodeRabbit Commands (Invoked using PR/Issue comments)

Type @coderabbitai help to get the list of available commands.

Other keywords and placeholders

  • Add @coderabbitai ignore or @coderabbit ignore anywhere in the PR description to prevent this PR from being reviewed.
  • Add @coderabbitai summary to generate the high-level summary at a specific location in the PR description.
  • Add @coderabbitai anywhere in the PR title to generate the title automatically.

CodeRabbit Configuration File (.coderabbit.yaml)

  • You can programmatically configure CodeRabbit by adding a .coderabbit.yaml file to the root of your repository.
  • Please see the configuration documentation for more information.
  • If your editor has YAML language server enabled, you can add the path at the top of this file to enable auto-completion and validation: # yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json

Status, Documentation and Community

  • Visit our Status Page to check the current availability of CodeRabbit.
  • Visit our Documentation for detailed information on how to use CodeRabbit.
  • Join our Discord Community to get help, request features, and share feedback.
  • Follow us on X/Twitter for updates and announcements.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
apps/desktop/src/routes/editor/Timeline/ClipTrack.tsx (1)

32-37: Waveform rendering ignores timescale — visual desync when speed ≠ 1.

Waveform X mapping uses source seconds; segment width uses timeline seconds (divided by timescale). Result: waveform stretches/compresses incorrectly for speeds. Adjust X mapping to account for timescale.

-function WaveformCanvas(props: {
-  systemWaveform?: number[];
-  micWaveform?: number[];
-  segment: { start: number; end: number };
-  secsPerPixel: number;
-}) {
+function WaveformCanvas(props: {
+  systemWaveform?: number[];
+  micWaveform?: number[];
+  segment: { start: number; end: number; timescale?: number };
+  secsPerPixel: number;
+}) {
@@
-      const x = (xTime - props.segment.start) / secsPerPixel();
+      const x = ((xTime - props.segment.start) / (props.segment.timescale ?? 1)) / secsPerPixel();
@@
-      const prevX = (xTime - 0.1 - props.segment.start) / secsPerPixel();
+      const prevX = ((xTime - 0.1 - props.segment.start) / (props.segment.timescale ?? 1)) / secsPerPixel();
@@
-    ctx.lineTo(
-      (props.segment.end + 0.3 - props.segment.start) / secsPerPixel(),
-      h,
-    );
+    ctx.lineTo(
+      ((props.segment.end + 0.3 - props.segment.start) / (props.segment.timescale ?? 1)) / secsPerPixel(),
+      h,
+    );

Also applies to: 78-82, 93-95

🧹 Nitpick comments (8)
crates/project/src/configuration.rs (2)

506-517: Handle negative times and reduce duplication with existing lookup.

  • Edge case: negative frame_time currently resolves to the first segment. Consider returning None early for < 0.0.
  • Minor duplication with get_segment_time; factor a small helper to DRY.

Apply:

-    pub fn get_segment_at_time(&self, frame_time: f64) -> Option<&TimelineSegment> {
-        let mut accum_duration = 0.0;
+    pub fn get_segment_at_time(&self, frame_time: f64) -> Option<&TimelineSegment> {
+        if frame_time < 0.0 {
+            return None;
+        }
+        let mut accum_duration = 0.0;
         for segment in self.segments.iter() {
             if frame_time < accum_duration + segment.duration() {
                 return Some(segment);
             }
             accum_duration += segment.duration();
         }
         None
     }

441-443: Guard against zero/negative timescale in duration.

Division by zero yields inf and can skew lookups. Consider clamping to a small epsilon.

-    pub fn duration(&self) -> f64 {
-        (self.end - self.start) / self.timescale
+    pub fn duration(&self) -> f64 {
+        let ts = if self.timescale <= 0.0 { 1.0 } else { self.timescale };
+        (self.end - self.start) / ts
     }
apps/desktop/src/routes/editor/Timeline/ClipTrack.tsx (1)

472-474: Show timeline duration instead of source duration (optional).

For speed-changed clips, consider displaying timeline duration: ((end - start) / timescale).toFixed(1).

- { (segment.end - segment.start).toFixed(1) }s
+ { ((segment.end - segment.start) / (segment.timescale ?? 1)).toFixed(1) }s
crates/editor/src/audio.rs (1)

184-190: Avoid O(n) segment search per audio frame.

You already track cursor.segment_index. Use it to read mute_audio directly instead of scanning timeline each render.

-        let segment_muted = project
-            .timeline
-            .as_ref()
-            .and_then(|t| t.get_segment_at_time(self.elapsed_samples_to_playhead()))
-            .map(|s| s.mute_audio)
-            .unwrap_or(false);
+        let segment_muted = project
+            .timeline
+            .as_ref()
+            .and_then(|t| t.segments.get(self.cursor.segment_index as usize))
+            .map(|s| s.mute_audio)
+            .unwrap_or(false);
apps/desktop/src/routes/editor/ConfigSidebar.tsx (4)

52-52: Remove manual lucide icon imports; rely on unplugin-icons auto-imports.

Per desktop guidelines, avoid manual icon imports. Let unplugin-icons auto-import these components.

-import IconLucideGauge from "~icons/lucide/gauge";
-...
-import IconLucideVolumeX from "~icons/lucide/volume-x";
+// Use <IconLucideGauge /> and <IconLucideVolumeX /> without manual imports.

Also applies to: 55-55


2247-2257: Presets look good; consider centralizing MIN/MAX speed constants.

You duplicate 0.5 and 5 elsewhere. Define once to keep UI/logic in sync.

 const speedPresets = [
@@
   { label: "5×", value: 5 },
 ];
+
+const MIN_SPEED = 0.5;
+const MAX_SPEED = 5;

2265-2275: Clamp using shared constants; consider grouping history for preset clicks.

Use MIN/MAX constants. For preset clicks, optionally pause/resume history to keep a single entry.

-const handleSpeedChange = (speed: number) => {
-  // Clamp the speed value to ensure it's within valid bounds
-  const clampedSpeed = Math.min(Math.max(speed, 0.5), 5);
+const handleSpeedChange = (speed: number) => {
+  const clampedSpeed = Math.min(Math.max(speed, MIN_SPEED), MAX_SPEED);
   setProject(
     "timeline",
     "segments",
     props.segmentIndex,
     "timescale",
     clampedSpeed,
   );
 };

2343-2346: Make preset highlight tolerant to float precision.

Strict equality may miss due to rounding.

- currentSpeed() === preset.value
+ Math.abs(currentSpeed() - preset.value) < 1e-6
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

💡 Knowledge Base configuration:

  • MCP integration is disabled by default for public repositories
  • Jira integration is disabled by default for public repositories
  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between 790c89f and ccf1f1d.

📒 Files selected for processing (6)
  • apps/desktop/src-tauri/src/recording.rs (1 hunks)
  • apps/desktop/src/routes/editor/ConfigSidebar.tsx (3 hunks)
  • apps/desktop/src/routes/editor/Timeline/ClipTrack.tsx (1 hunks)
  • apps/desktop/src/utils/tauri.ts (1 hunks)
  • crates/editor/src/audio.rs (1 hunks)
  • crates/project/src/configuration.rs (2 hunks)
🧰 Additional context used
📓 Path-based instructions (5)
**/tauri.ts

📄 CodeRabbit inference engine (CLAUDE.md)

Never edit auto-generated IPC bindings file: tauri.ts

Files:

  • apps/desktop/src/utils/tauri.ts
apps/desktop/**/*.{ts,tsx}

📄 CodeRabbit inference engine (CLAUDE.md)

apps/desktop/**/*.{ts,tsx}: In the desktop app, rely on unplugin-icons auto-imports; do not manually import icon modules
Use generated tauri_specta commands/events (commands, events) in the desktop frontend; listen to generated events directly
Use @tanstack/solid-query for server state in the desktop app

Files:

  • apps/desktop/src/utils/tauri.ts
  • apps/desktop/src/routes/editor/Timeline/ClipTrack.tsx
  • apps/desktop/src/routes/editor/ConfigSidebar.tsx
{apps/desktop,packages/ui-solid}/**/*.{ts,tsx}

📄 CodeRabbit inference engine (CLAUDE.md)

Component naming (Solid): components in PascalCase; hooks/utilities in camelCase starting with 'use' where applicable

Files:

  • apps/desktop/src/utils/tauri.ts
  • apps/desktop/src/routes/editor/Timeline/ClipTrack.tsx
  • apps/desktop/src/routes/editor/ConfigSidebar.tsx
**/*.{ts,tsx}

📄 CodeRabbit inference engine (CLAUDE.md)

Use strict TypeScript and avoid any; prefer shared types from packages

Files:

  • apps/desktop/src/utils/tauri.ts
  • apps/desktop/src/routes/editor/Timeline/ClipTrack.tsx
  • apps/desktop/src/routes/editor/ConfigSidebar.tsx
apps/desktop/src-tauri/**/*.rs

📄 CodeRabbit inference engine (CLAUDE.md)

Use tauri_specta on the Rust side for IPC (typed commands/events) and emit events via generated types

Files:

  • apps/desktop/src-tauri/src/recording.rs
🧬 Code graph analysis (2)
crates/project/src/configuration.rs (1)
apps/desktop/src/utils/tauri.ts (1)
  • TimelineSegment (442-442)
apps/desktop/src/routes/editor/ConfigSidebar.tsx (2)
apps/desktop/src/routes/editor/ui.tsx (3)
  • Field (25-47)
  • Slider (65-147)
  • Subfield (49-63)
apps/desktop/src/components/Toggle.tsx (1)
  • Toggle (37-50)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (3)
  • GitHub Check: Build Desktop (x86_64-pc-windows-msvc, windows-latest)
  • GitHub Check: Build Desktop (aarch64-apple-darwin, macos-latest)
  • GitHub Check: Analyze (rust)
🔇 Additional comments (7)
crates/project/src/configuration.rs (1)

427-429: Good additive field; serde default + camelCase is correct.

mute_audio with #[serde(default)] integrates safely with older configs and maps to muteAudio via rename_all = "camelCase". No breaking changes.

apps/desktop/src/routes/editor/Timeline/ClipTrack.tsx (1)

195-198: Timescale-aware width: LGTM.

Using (end - start) / timescale + prevDuration() fixes segment width on the timeline.

apps/desktop/src-tauri/src/recording.rs (1)

1014-1015: Defaulting new field: LGTM.

Initializing mute_audio: false for each segment is consistent with the Rust model (#[serde(default)]).

apps/desktop/src/utils/tauri.ts (1)

442-442: Auto-generated type updated appropriately.

TimelineSegment now includes optional muteAudio. Ensure this file remains generated only (no manual edits).

crates/editor/src/audio.rs (1)

196-201: Mute logic extension: LGTM.

project.audio.mute || segment_muted correctly applies per-segment muting without changing other gain paths.

apps/desktop/src/routes/editor/ConfigSidebar.tsx (2)

2263-2264: LGTM: derived speed getter is straightforward.


2259-2262: Bind muteAudio directly to the store; remove local state & any cast.

- const [muteAudio, setMuteAudio] = createSignal(
-   (props.segment as any).muteAudio ?? false,
- );
+ const segmentMuted = () => props.segment.muteAudio ?? false;

- const handleMuteToggle = (muted: boolean) => {
-   setMuteAudio(muted);
-   setProject("timeline", "segments", props.segmentIndex, "muteAudio" as any, muted);
- };
+ const handleMuteToggle = (muted: boolean) =>
+   setProject("timeline", "segments", props.segmentIndex, "muteAudio", muted);

- <Field
-   name="Audio"
-   icon={<IconLucideVolumeX class="size-4" />}
-   value={<Toggle checked={muteAudio()} onChange={handleMuteToggle} />}
- >
+ <Field
+   name="Audio"
+   icon={<IconLucideVolumeX class="size-4" />}
+   value={<Toggle checked={segmentMuted()} onChange={handleMuteToggle} />}
+ >

Use the typed props.segment.muteAudio?: boolean (no cast needed).

Comment on lines +2317 to +2357
<Field name="Playback Speed" icon={<IconLucideGauge class="size-4" />}>
<div class="space-y-3">
<div class="flex items-center gap-2">
<Slider
class="flex-1"
minValue={0.5}
maxValue={5}
step={0.05}
value={[currentSpeed()]}
onChange={(value: number[]) => {
let speed = value[0];
if (speed > 5) {
speed = speed / 10;
}
handleSpeedChange(speed);
}}
/>
<span class="text-sm font-medium text-gray-11 min-w-[3rem] text-right">
{currentSpeed().toFixed(2)}×
</span>
</div>
<div class="flex flex-wrap gap-1.5">
<For each={speedPresets}>
{(preset) => (
<button
class={cx(
"px-2.5 py-1 text-xs rounded-md border transition-colors",
currentSpeed() === preset.value
? "bg-blue-9 text-white border-blue-9"
: "bg-gray-3 text-gray-11 border-gray-4 hover:border-gray-6",
)}
onClick={() => handleSpeedChange(preset.value)}
>
{preset.label}
</button>
)}
</For>
</div>
</div>
</Field>

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Remove unreachable divide-by-10 path; rely on clamp. Add tooltip, reuse MIN/MAX.

Slider already has max=5; dividing by 10 is surprising and can mask bugs.

 <Slider
   class="flex-1"
-  minValue={0.5}
-  maxValue={5}
+  minValue={MIN_SPEED}
+  maxValue={MAX_SPEED}
   step={0.05}
   value={[currentSpeed()]}
-  onChange={(value: number[]) => {
-    let speed = value[0];
-    if (speed > 5) {
-      speed = speed / 10;
-    }
-    handleSpeedChange(speed);
-  }}
+  formatTooltip={(v) => `${v.toFixed(2)}×`}
+  onChange={(value: number[]) => {
+    handleSpeedChange(value[0]);
+  }}
 />
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<Field name="Playback Speed" icon={<IconLucideGauge class="size-4" />}>
<div class="space-y-3">
<div class="flex items-center gap-2">
<Slider
class="flex-1"
minValue={0.5}
maxValue={5}
step={0.05}
value={[currentSpeed()]}
onChange={(value: number[]) => {
let speed = value[0];
if (speed > 5) {
speed = speed / 10;
}
handleSpeedChange(speed);
}}
/>
<span class="text-sm font-medium text-gray-11 min-w-[3rem] text-right">
{currentSpeed().toFixed(2)}×
</span>
</div>
<div class="flex flex-wrap gap-1.5">
<For each={speedPresets}>
{(preset) => (
<button
class={cx(
"px-2.5 py-1 text-xs rounded-md border transition-colors",
currentSpeed() === preset.value
? "bg-blue-9 text-white border-blue-9"
: "bg-gray-3 text-gray-11 border-gray-4 hover:border-gray-6",
)}
onClick={() => handleSpeedChange(preset.value)}
>
{preset.label}
</button>
)}
</For>
</div>
</div>
</Field>
<Slider
class="flex-1"
minValue={MIN_SPEED}
maxValue={MAX_SPEED}
step={0.05}
value={[currentSpeed()]}
formatTooltip={(v) => `${v.toFixed(2)}×`}
onChange={(value: number[]) => {
handleSpeedChange(value[0]);
}}
/>
🤖 Prompt for AI Agents
In apps/desktop/src/routes/editor/ConfigSidebar.tsx around lines 2317 to 2357,
the Slider onChange handler contains an unreachable divide-by-10 branch and uses
hard-coded min/max values; remove the conditional that divides speed by 10,
instead clamp the selected value to the established MIN and MAX playback speed
constants (reuse the existing minValue/maxValue or extract constants if not
present), add a tooltip on the speed display/button to show the exact numeric
value, and ensure handleSpeedChange is called with the clamped value only.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

1 participant