WIP: waveform visualization
This commit is contained in:
@@ -5,4 +5,7 @@
|
|||||||
<TargetFramework Condition=" '$(GodotTargetPlatform)' == 'ios' ">net8.0</TargetFramework>
|
<TargetFramework Condition=" '$(GodotTargetPlatform)' == 'ios' ">net8.0</TargetFramework>
|
||||||
<EnableDynamicLoading>true</EnableDynamicLoading>
|
<EnableDynamicLoading>true</EnableDynamicLoading>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="NAudio" Version="2.2.1" />
|
||||||
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
@@ -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://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"]
|
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_8tb17"]
|
||||||
border_width_left = 2
|
border_width_left = 2
|
||||||
@@ -65,4 +66,16 @@ offset_right = 40.0
|
|||||||
offset_bottom = 23.0
|
offset_bottom = 23.0
|
||||||
text = "Clip properties!"
|
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"]
|
[connection signal="on_double_click" from="." to="Window" method="popup_centered"]
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ signal on_selected
|
|||||||
signal on_deselected
|
signal on_deselected
|
||||||
signal on_double_click
|
signal on_double_click
|
||||||
|
|
||||||
|
var clip_path: String
|
||||||
|
|
||||||
var timeline: Timeline
|
var timeline: Timeline
|
||||||
|
|
||||||
var dragging: bool
|
var dragging: bool
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ extends Container
|
|||||||
|
|
||||||
@export var audio_clip_scene: PackedScene
|
@export var audio_clip_scene: PackedScene
|
||||||
|
|
||||||
|
signal clip_added(clip: Control)
|
||||||
|
|
||||||
func format_time_ms_hours(ms: float) -> String:
|
func format_time_ms_hours(ms: float) -> String:
|
||||||
var total_seconds = ms / 1000
|
var total_seconds = ms / 1000
|
||||||
var hours = int(total_seconds / 3600)
|
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
|
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 local_position = at_position - global_position
|
||||||
var timeline_position = local_x_to_timeline(local_position.x)
|
var timeline_position = local_x_to_timeline(local_position.x)
|
||||||
|
|
||||||
var track_idx = get_track_idx_by_y(at_position.y)
|
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
|
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
|
var audio_clip = audio_clip_scene.instantiate() as AudioClip
|
||||||
|
audio_clip.clip_path = path
|
||||||
audio_clip.clip_name = clip_name
|
audio_clip.clip_name = clip_name
|
||||||
audio_clip.track_idx = track_idx
|
audio_clip.track_idx = track_idx
|
||||||
audio_clip.start_time = clip_start_time
|
audio_clip.start_time = clip_start_time
|
||||||
audio_clip.end_time = clip_end_time
|
audio_clip.end_time = clip_end_time
|
||||||
|
|
||||||
add_child(audio_clip)
|
add_child(audio_clip)
|
||||||
|
|
||||||
|
clip_added.emit(audio_clip)
|
||||||
queue_redraw()
|
queue_redraw()
|
||||||
pass
|
pass
|
||||||
88
Source/AudioFileAnalyzer.cs
Normal file
88
Source/AudioFileAnalyzer.cs
Normal file
@@ -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<string, WaveformInfo> _waveformInfoCache = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
public class WaveformInfo
|
||||||
|
{
|
||||||
|
public int SampleRate { get; set; }
|
||||||
|
public TimeSpan Length { get; set; }
|
||||||
|
public float[] Samples { get; set; }
|
||||||
|
}
|
||||||
@@ -6,21 +6,46 @@ namespace AudioEditor;
|
|||||||
[GlobalClass]
|
[GlobalClass]
|
||||||
public partial class ProjectController : Node
|
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()
|
public override void _Ready()
|
||||||
{
|
{
|
||||||
GetWindow().FilesDropped += FilesDropped;
|
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");
|
||||||
|
waveform.SetWaveformInfo(waveformInfo);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private void FilesDropped(string[] files)
|
private void FilesDropped(string[] files)
|
||||||
{
|
{
|
||||||
var mousePosition = GetViewport().GetMousePosition();
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
101
Source/Waveform.cs
Normal file
101
Source/Waveform.cs
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -183,4 +183,5 @@ theme_override_styles/panel = SubResource("StyleBoxFlat_3f3qp")
|
|||||||
[node name="ProjectController" type="Node" parent="."]
|
[node name="ProjectController" type="Node" parent="."]
|
||||||
script = ExtResource("11_gc3ui")
|
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"]
|
[connection signal="AudioClipDropped" from="ProjectController" to="VBoxContainer/VSplitContainer/HSplitContainer/Timeline" method="clip_dropped"]
|
||||||
|
|||||||
@@ -21,6 +21,10 @@ config/icon="res://icon.svg"
|
|||||||
|
|
||||||
driver/driver="Dummy"
|
driver/driver="Dummy"
|
||||||
|
|
||||||
|
[autoload]
|
||||||
|
|
||||||
|
AudioFileAnalyzer="*res://Source/AudioFileAnalyzer.cs"
|
||||||
|
|
||||||
[display]
|
[display]
|
||||||
|
|
||||||
window/size/viewport_width=1920
|
window/size/viewport_width=1920
|
||||||
|
|||||||
Reference in New Issue
Block a user