From ce0c11680050209c164764f518123aea78afe164 Mon Sep 17 00:00:00 2001 From: dnesov Date: Mon, 17 Feb 2025 00:04:42 +0100 Subject: [PATCH] WIP: waveform visualization --- AudioEditor.csproj | 3 ++ Controls/AudioClip.tscn | 15 +++++- Scripts/AudioClip.gd | 2 + Scripts/Timeline.gd | 11 ++-- Source/AudioFileAnalyzer.cs | 88 +++++++++++++++++++++++++++++++ Source/ProjectController.cs | 33 ++++++++++-- Source/Waveform.cs | 101 ++++++++++++++++++++++++++++++++++++ Views/MainView.tscn | 1 + project.godot | 4 ++ 9 files changed, 250 insertions(+), 8 deletions(-) create mode 100644 Source/AudioFileAnalyzer.cs create mode 100644 Source/Waveform.cs diff --git a/AudioEditor.csproj b/AudioEditor.csproj index 53cc1f1..37f6f33 100644 --- a/AudioEditor.csproj +++ b/AudioEditor.csproj @@ -5,4 +5,7 @@ net8.0 true + + + \ No newline at end of file diff --git a/Controls/AudioClip.tscn b/Controls/AudioClip.tscn index 3279a03..41bc0c4 100644 --- a/Controls/AudioClip.tscn +++ b/Controls/AudioClip.tscn @@ -1,6 +1,7 @@ -[gd_scene load_steps=3 format=3 uid="uid://dmmgalpx4fcc7"] +[gd_scene load_steps=4 format=3 uid="uid://dmmgalpx4fcc7"] [ext_resource type="Script" path="res://Scripts/AudioClip.gd" id="1_iy5jd"] +[ext_resource type="Script" path="res://Source/Waveform.cs" id="2_43oho"] [sub_resource type="StyleBoxFlat" id="StyleBoxFlat_8tb17"] border_width_left = 2 @@ -65,4 +66,16 @@ offset_right = 40.0 offset_bottom = 23.0 text = "Clip properties!" +[node name="Waveform" type="Control" parent="."] +unique_name_in_owner = true +modulate = Color(0.779291, 0.779291, 0.779291, 1) +layout_mode = 1 +anchor_top = 0.312 +anchor_right = 1.0 +anchor_bottom = 1.0 +offset_top = 0.0319996 +grow_horizontal = 2 +grow_vertical = 2 +script = ExtResource("2_43oho") + [connection signal="on_double_click" from="." to="Window" method="popup_centered"] diff --git a/Scripts/AudioClip.gd b/Scripts/AudioClip.gd index 6216937..c97f42d 100644 --- a/Scripts/AudioClip.gd +++ b/Scripts/AudioClip.gd @@ -10,6 +10,8 @@ signal on_selected signal on_deselected signal on_double_click +var clip_path: String + var timeline: Timeline var dragging: bool diff --git a/Scripts/Timeline.gd b/Scripts/Timeline.gd index cd2bc39..6fac0ae 100644 --- a/Scripts/Timeline.gd +++ b/Scripts/Timeline.gd @@ -20,6 +20,8 @@ extends Container @export var audio_clip_scene: PackedScene +signal clip_added(clip: Control) + func format_time_ms_hours(ms: float) -> String: var total_seconds = ms / 1000 var hours = int(total_seconds / 3600) @@ -141,21 +143,24 @@ func local_x_to_timeline(x: float) -> float: return (x + time_offset) / get_pixels_per_unit() * time_interval -func clip_dropped(at_position: Vector2, clip_name: String, clip_start_time: float, clip_end_time: float): +func clip_dropped(at_position: Vector2, path: String, clip_name: String, clip_start_time: float, clip_end_time: float): var local_position = at_position - global_position var timeline_position = local_x_to_timeline(local_position.x) var track_idx = get_track_idx_by_y(at_position.y) - add_audio_clip(clip_name, track_idx, timeline_position + clip_start_time, timeline_position + clip_end_time) + add_audio_clip(path, clip_name, track_idx, timeline_position + clip_start_time, timeline_position + clip_end_time) pass -func add_audio_clip(clip_name: String, track_idx: int, clip_start_time: float, clip_end_time: float): +func add_audio_clip(path: String, clip_name: String, track_idx: int, clip_start_time: float, clip_end_time: float): var audio_clip = audio_clip_scene.instantiate() as AudioClip + audio_clip.clip_path = path audio_clip.clip_name = clip_name audio_clip.track_idx = track_idx audio_clip.start_time = clip_start_time audio_clip.end_time = clip_end_time add_child(audio_clip) + + clip_added.emit(audio_clip) queue_redraw() pass \ No newline at end of file diff --git a/Source/AudioFileAnalyzer.cs b/Source/AudioFileAnalyzer.cs new file mode 100644 index 0000000..987826f --- /dev/null +++ b/Source/AudioFileAnalyzer.cs @@ -0,0 +1,88 @@ +using System; +using System.Collections.Generic; +using System.IO; +using Godot; +using NAudio.Wave; + +namespace AudioEditor; + +public partial class AudioFileAnalyzer : Node +{ + public static AudioFileAnalyzer Instance { get; private set; } + + public override void _Ready() + { + base._Ready(); + Instance = this; + } + + public void AnalyzeFile(string path, out WaveformInfo waveformInfo) + { + waveformInfo = new(); + + if (_waveformInfoCache.ContainsKey(path)) + { + waveformInfo = _waveformInfoCache[path]; + return; + } + + var ext = Path.GetExtension(path); + + switch (ext) + { + case ".wav": + GD.Print("Analyzing a WAV file..."); + AnalyzeWav(path, out waveformInfo); + break; + default: + GD.Print($"Format {ext} is not supported!"); + break; + } + + _waveformInfoCache.Add(path, waveformInfo); + } + + public bool TryGetFromCache(string path, out WaveformInfo waveformInfo) + { + return _waveformInfoCache.TryGetValue(path, out waveformInfo); + } + + private void AnalyzeWav(string path, out WaveformInfo waveformInfo) + { + waveformInfo = new WaveformInfo(); + using (var reader = new WaveFileReader(path)) + { + waveformInfo.Length = reader.TotalTime; + waveformInfo.SampleRate = reader.WaveFormat.SampleRate; + + long sampleCount = reader.Length / reader.WaveFormat.BlockAlign; + waveformInfo.Samples = new float[sampleCount]; + + // Render the waveform + byte[] buffer = new byte[reader.WaveFormat.BlockAlign]; + for (int i = 0; i < sampleCount; i++) + { + int sampleIndex = i * reader.WaveFormat.BlockAlign; + while (reader.Position < reader.Length) + { + // Read the raw bytes for the current sample + reader.Read(buffer, 0, buffer.Length); + + // Convert the byte array to a 16-bit sample (assuming 16-bit PCM) + short sample = BitConverter.ToInt16(buffer, 0); // Assuming 16-bit signed samples + + waveformInfo.Samples[sampleIndex++] = (float)sample / short.MaxValue; + } + } + } + } + + private Dictionary _waveformInfoCache = new(); +} + +public class WaveformInfo +{ + public int SampleRate { get; set; } + public TimeSpan Length { get; set; } + public float[] Samples { get; set; } +} \ No newline at end of file diff --git a/Source/ProjectController.cs b/Source/ProjectController.cs index 4a65fc4..1e2368f 100644 --- a/Source/ProjectController.cs +++ b/Source/ProjectController.cs @@ -6,21 +6,46 @@ namespace AudioEditor; [GlobalClass] public partial class ProjectController : Node { - [Signal] public delegate void AudioClipDroppedEventHandler(Vector2 atPosition, string clipName, double startTime, double endTime); + [Signal] public delegate void AudioClipDroppedEventHandler(Vector2 atPosition, string path, string clipName, double startTime, double endTime); public override void _Ready() { GetWindow().FilesDropped += FilesDropped; } + public void CreateAudioClipPreview(Control audioClip) + { + // GD.Print(audioClip.Clas()); + var path = audioClip.Get("clip_path").AsString(); + + if (string.IsNullOrEmpty(path)) + { + return; + } + + if (!AudioFileAnalyzer.Instance.TryGetFromCache(path, out WaveformInfo waveformInfo)) + { + return; + } + + if (audioClip.HasNode("%Waveform")) + { + var waveform = audioClip.GetNode("%Waveform"); + waveform.SetWaveformInfo(waveformInfo); + } + } + private void FilesDropped(string[] files) { var mousePosition = GetViewport().GetMousePosition(); - var fileName = Path.GetFileName(files[0]); + var path = files[0]; + var fileName = Path.GetFileName(path); - GD.Print(fileName); + AudioFileAnalyzer.Instance.AnalyzeFile(path, out WaveformInfo waveformInfo); - EmitSignal(SignalName.AudioClipDropped, mousePosition, fileName, 0, 10 * 1000); + GD.Print(path); + + EmitSignal(SignalName.AudioClipDropped, mousePosition, path, fileName, 0, waveformInfo.Length.TotalMilliseconds); } } \ No newline at end of file diff --git a/Source/Waveform.cs b/Source/Waveform.cs new file mode 100644 index 0000000..75486b7 --- /dev/null +++ b/Source/Waveform.cs @@ -0,0 +1,101 @@ +using Godot; +using System; + +namespace AudioEditor; + +public partial class Waveform : Control +{ + private WaveformInfo _waveform; + + public void SetWaveformInfo(WaveformInfo waveform) + { + _waveform = waveform; + QueueRedraw(); + } + + public override void _Draw() + { + if (_waveform == null) return; + base._Draw(); + + int width = (int)Size.X; + int height = (int)Size.Y; + + // Downsample the waveform to get a reasonable number of peaks to draw + float[] averagePeaks = DownsampleToAveragePeaks(_waveform.Samples, width, _waveform.SampleRate); + + // Begin drawing the waveform + for (int i = 0; i < width; i++) + { + float averagePeak = averagePeaks[i]; + + // Normalize the average peak to fit within the height of the control + int y = (int)(averagePeak * height / 2); + + // Drawing the line in the positive and negative direction from the center + DrawLine(new Vector2(i, height / 2 - y), new Vector2(i, height / 2 + y), new Color(1, 1, 1, 0.7f)); // White with transparency + } + } + + private float[] DownsampleToAveragePeaks(float[] samples, int targetWidth, int sampleRate) + { + int totalSamples = samples.Length; + float durationInSeconds = totalSamples / (float)sampleRate; // Calculate the total duration in seconds + int stepSize = totalSamples / targetWidth; // Larger chunks for downsampling + + // If stepSize is 0 (due to rounding), default to 1 to prevent division by zero + if (stepSize == 0) stepSize = 1; + + float[] averagePeaks = new float[targetWidth]; + + for (int i = 0; i < targetWidth; i++) + { + int startIndex = i * stepSize; + int endIndex = Math.Min(startIndex + stepSize, totalSamples); + + // Calculate the average or peak for this window (you can use GetPeak if needed) + averagePeaks[i] = GetAverage(samples, startIndex, endIndex); + } + + return averagePeaks; + } + + // Get the average value of samples in the current window + private float GetAverage(float[] samples, int startIndex, int endIndex) + { + float sum = 0f; + int count = 0; + for (int i = startIndex; i < endIndex; i++) + { + sum += Math.Abs(samples[i]); // Use absolute values for consistency + count++; + } + return count > 0 ? sum / count : 0f; + } + + // Optional: You could use this method if you prefer showing the peak values + private float GetPeak(float[] samples, int startIndex, int endIndex) + { + float maxPeak = 0f; + for (int i = startIndex; i < endIndex; i++) + { + maxPeak = Math.Max(maxPeak, Math.Abs(samples[i])); + } + return maxPeak; + } + + // This can be an alternative to GetAverage if you want to show average peak + private float GetAveragePeak(float[] samples, int startIndex, int endIndex) + { + float sum = 0f; + int count = 0; + + for (int i = startIndex; i < endIndex; i++) + { + sum += Math.Abs(samples[i]); // Use absolute value for a more stable peak + count++; + } + + return count > 0 ? sum / count : 0f; // Return the average + } +} \ No newline at end of file diff --git a/Views/MainView.tscn b/Views/MainView.tscn index eef06f8..54c61fa 100644 --- a/Views/MainView.tscn +++ b/Views/MainView.tscn @@ -183,4 +183,5 @@ theme_override_styles/panel = SubResource("StyleBoxFlat_3f3qp") [node name="ProjectController" type="Node" parent="."] script = ExtResource("11_gc3ui") +[connection signal="clip_added" from="VBoxContainer/VSplitContainer/HSplitContainer/Timeline" to="ProjectController" method="CreateAudioClipPreview"] [connection signal="AudioClipDropped" from="ProjectController" to="VBoxContainer/VSplitContainer/HSplitContainer/Timeline" method="clip_dropped"] diff --git a/project.godot b/project.godot index 97f81be..e59f50a 100644 --- a/project.godot +++ b/project.godot @@ -21,6 +21,10 @@ config/icon="res://icon.svg" driver/driver="Dummy" +[autoload] + +AudioFileAnalyzer="*res://Source/AudioFileAnalyzer.cs" + [display] window/size/viewport_width=1920