Tone generator

Sine, square and saw written sample by sample into an AudioStreamGenerator. The Line2D draws the same wave the buffer is being filled with.

The editor above is running this project. Change a line and it reloads.

Godot classes used

AudioStreamGeneratorAudioStreamPlayerLine2D

The code

scripts/main.gd
extends Node

const MIX_RATE: float = 22050.0
const POINTS: int = 260
const SHAPES: Array[String] = ['sine', 'square', 'saw']

var player: AudioStreamPlayer
var playback: AudioStreamGeneratorPlayback
var scope: Line2D
var readout: Label
var start_button: Button
var hz: float = 220.0
var shape: int = 0
var phase: float = 0.0
var scroll: float = 0.0
var box: Rect2


func _ready() -> void:
	_add_background()

	var view: Vector2 = get_viewport().get_visible_rect().size
	var size: Vector2 = Vector2(view.x * 0.74, view.y * 0.44)
	box = Rect2(Vector2((view.x - size.x) * 0.5, view.y * 0.24), size)

	_add_label('Tone generator', Vector2(box.position.x, view.y * 0.08), 26, Color('#ffffff'))
	_add_label('sine, square and saw written sample by sample into an AudioStreamGenerator', Vector2(box.position.x, view.y * 0.14), 15, Color('#999999'))

	_add_scope()
	_add_player()

	readout = _add_label('', Vector2(box.position.x, box.end.y + 16.0), 18, Color('#f3d48d'))

	_add_controls()
	_refresh()

	print('tone generator ready, press Start sound to hear the wave on screen')


func _process(delta: float) -> void:
	scroll = fmod(scroll + delta * 0.4, 1.0)
	_draw_scope()

	if playback != null:
		_fill_buffer()


func _fill_buffer() -> void:
	for i in playback.get_frames_available():
		phase = fmod(phase + hz / MIX_RATE, 1.0)
		var sample: float = _wave(phase) * 0.22
		playback.push_frame(Vector2(sample, sample))


func _wave(at: float) -> float:
	match shape:
		1:
			return 1.0 if at < 0.5 else -1.0
		2:
			return at * 2.0 - 1.0

	return sin(at * TAU)


func _draw_scope() -> void:
	var cycles: float = hz / 110.0
	var points: PackedVector2Array = PackedVector2Array()

	for i in POINTS:
		var at: float = float(i) / float(POINTS - 1)
		var sample: float = _wave(fmod(scroll + at * cycles, 1.0))
		points.append(Vector2(box.position.x + at * box.size.x, box.get_center().y - sample * box.size.y * 0.4))

	scope.points = points


func _toggle_sound() -> void:
	if playback != null:
		player.stop()
		playback = null
		start_button.text = 'Start sound'
		return

	player.play()
	# get_stream_playback() only answers after play()
	playback = player.get_stream_playback()
	start_button.text = 'Stop sound'


func _set_hz(value: float) -> void:
	hz = value
	_refresh()


func _set_shape(index: int) -> void:
	shape = index
	_refresh()


func _refresh() -> void:
	readout.text = '%d Hz   %s wave' % [int(hz), SHAPES[shape]]


func _add_player() -> void:
	var generator: AudioStreamGenerator = AudioStreamGenerator.new()
	generator.mix_rate = MIX_RATE
	generator.buffer_length = 0.1

	player = AudioStreamPlayer.new()
	player.stream = generator
	add_child(player)


func _add_scope() -> void:
	var panel: ColorRect = ColorRect.new()
	panel.color = Color('#2a2f3d')
	panel.position = box.position
	panel.size = box.size
	add_child(panel)

	var axis: ColorRect = ColorRect.new()
	axis.color = Color('#8da5f3', 0.25)
	axis.position = Vector2(box.position.x, box.get_center().y - 1.0)
	axis.size = Vector2(box.size.x, 2.0)
	add_child(axis)

	scope = Line2D.new()
	scope.width = 3.0
	scope.default_color = Color('#8da5f3')
	scope.joint_mode = Line2D.LINE_JOINT_ROUND
	add_child(scope)


func _add_controls() -> void:
	var row: HBoxContainer = HBoxContainer.new()
	row.add_theme_constant_override('separation', 18)
	row.position = Vector2(box.position.x, box.end.y + 58.0)
	add_child(row)

	var slider: HSlider = HSlider.new()
	slider.min_value = 110.0
	slider.max_value = 880.0
	slider.value = hz
	slider.custom_minimum_size = Vector2(280, 28)
	slider.value_changed.connect(_set_hz)
	row.add_child(slider)

	var group: ButtonGroup = ButtonGroup.new()

	for i in SHAPES.size():
		var button: Button = Button.new()
		button.text = SHAPES[i]
		button.toggle_mode = true
		button.button_group = group
		button.button_pressed = i == shape
		button.add_theme_color_override('font_pressed_color', Color('#8da5f3'))
		button.add_theme_color_override('font_hover_pressed_color', Color('#8da5f3'))
		button.pressed.connect(_set_shape.bind(i))
		row.add_child(button)

	# the browser keeps audio silent until a real click, so the tone waits for this button
	start_button = Button.new()
	start_button.text = 'Start sound'
	start_button.pressed.connect(_toggle_sound)
	row.add_child(start_button)


func _add_background() -> void:
	var background: ColorRect = ColorRect.new()
	background.color = Color('#12141a')
	add_child(background)
	background.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)


func _add_label(text: String, at: Vector2, size: int, color: Color) -> Label:
	var label: Label = Label.new()
	label.text = text
	label.position = at
	label.add_theme_font_size_override('font_size', size)
	label.add_theme_color_override('font_color', color)
	add_child(label)

	return label

More Audio examples