Functional Core
Overview
Sound Forge Alchemy follows a functional core / imperative shell architecture. Pure business logic – URL parsing, model validation, feature validation, status transitions – lives in modules with no side effects. These pure functions are composed by context modules (the “shell”) that handle database operations, port communication, and PubSub broadcasts.
This document covers the pure logic modules: what they compute, how they are structured, and how to test them.
Spotify URL Parser
The SoundForge.Spotify.URLParser module is a pure function module that extracts Spotify resource type and ID from URLs. It has no dependencies on the database, HTTP client, or application configuration.
Implementation
# lib/sound_forge/spotify/url_parser.ex
defmodule SoundForge.Spotify.URLParser do
@moduledoc """
Parses Spotify URLs to extract type and ID.
Supports standard Spotify URLs for tracks, albums, and playlists.
"""
@spotify_regex ~r{(?:https?://)?(?:open\.)?spotify\.com/(?:intl-\w+/)?(track|album|playlist)/([a-zA-Z0-9]+)}
@spec parse(String.t()) ::
{:ok, %{type: String.t(), id: String.t()}} | {:error, :invalid_spotify_url}
def parse(url) when is_binary(url) do
case Regex.run(@spotify_regex, url) do
[_, type, id] -> {:ok, %{type: type, id: id}}
_ -> {:error, :invalid_spotify_url}
end
end
def parse(_), do: {:error, :invalid_spotify_url}
end
URL Patterns Supported
The regex handles the following Spotify URL formats:
| Format | Example | Extracted |
|---|---|---|
HTTPS with open subdomain |
https://open.spotify.com/track/abc123 |
%{type: "track", id: "abc123"} |
Without https:// |
open.spotify.com/track/abc123 |
%{type: "track", id: "abc123"} |
Without open subdomain |
https://spotify.com/track/abc123 |
%{type: "track", id: "abc123"} |
| With international locale | https://open.spotify.com/intl-de/track/abc123 |
%{type: "track", id: "abc123"} |
| Album URLs | https://open.spotify.com/album/xyz789 |
%{type: "album", id: "xyz789"} |
| Playlist URLs | https://open.spotify.com/playlist/list456 |
%{type: "playlist", id: "list456"} |
| With query parameters | https://open.spotify.com/track/abc123?si=xxx&nd=1 |
%{type: "track", id: "abc123"} |
The regex captures only alphanumeric characters for the ID ([a-zA-Z0-9]+), which naturally strips query parameters since ? is not alphanumeric.
Rejected Inputs
| Input | Result | Reason |
|---|---|---|
"https://example.com/track/123" |
{:error, :invalid_spotify_url} |
Not a Spotify domain |
"https://open.spotify.com/artist/abc" |
{:error, :invalid_spotify_url} |
artist is not a supported type |
"" |
{:error, :invalid_spotify_url} |
Empty string |
nil |
{:error, :invalid_spotify_url} |
Non-string input |
123 |
{:error, :invalid_spotify_url} |
Non-string input |
Test Coverage
The URL parser has comprehensive test coverage in test/sound_forge/spotify_test.exs:
# test/sound_forge/spotify_test.exs (URLParser tests)
describe "URLParser.parse/1" do
test "parses valid track URL with https" do
assert {:ok, %{type: "track", id: "abc123"}} =
URLParser.parse("https://open.spotify.com/track/abc123")
end
test "parses valid track URL without https" do
assert {:ok, %{type: "track", id: "xyz789"}} =
URLParser.parse("open.spotify.com/track/xyz789")
end
test "parses valid album URL" do
assert {:ok, %{type: "album", id: "album456"}} =
URLParser.parse("https://open.spotify.com/album/album456")
end
test "parses valid playlist URL" do
assert {:ok, %{type: "playlist", id: "playlist789"}} =
URLParser.parse("https://open.spotify.com/playlist/playlist789")
end
test "parses URL with international locale" do
assert {:ok, %{type: "track", id: "track123"}} =
URLParser.parse("https://open.spotify.com/intl-de/track/track123")
end
test "parses URL without 'open' subdomain" do
assert {:ok, %{type: "track", id: "track123"}} =
URLParser.parse("https://spotify.com/track/track123")
end
test "returns error for invalid URL format" do
assert {:error, :invalid_spotify_url} = URLParser.parse("https://example.com/track/123")
end
test "returns error for empty string" do
assert {:error, :invalid_spotify_url} = URLParser.parse("")
end
test "returns error for non-string input" do
assert {:error, :invalid_spotify_url} = URLParser.parse(nil)
assert {:error, :invalid_spotify_url} = URLParser.parse(123)
end
test "returns error for Spotify URL with invalid type" do
assert {:error, :invalid_spotify_url} =
URLParser.parse("https://open.spotify.com/artist/abc123")
end
end
These tests run with async: true because the module is pure – no shared state, no database, no processes.
Job Status Validation
Current Implementation
Status validation currently relies on Ecto.Enum and validate_inclusion/3 in each job schema’s changeset:
# Shared across DownloadJob, ProcessingJob, and AnalysisJob
@status_values [:queued, :downloading, :processing, :completed, :failed]
def changeset(job, attrs) do
job
|> cast(attrs, [:status, ...])
|> validate_inclusion(:status, @status_values)
end
This validates that a status is a valid value but does not validate that a transition is valid. For example, nothing currently prevents transitioning from :completed back to :queued.
Planned: Status Transition Guard
A pure function module for validating status transitions:
# Planned: lib/sound_forge/jobs/status.ex
defmodule SoundForge.Jobs.Status do
@moduledoc """
Pure functions for job status validation and transitions.
No database access -- operates on values only.
"""
@type status :: :queued | :downloading | :processing | :completed | :failed
@valid_transitions %{
queued: [:downloading, :processing, :failed],
downloading: [:processing, :completed, :failed],
processing: [:completed, :failed],
completed: [],
failed: []
}
@doc """
Checks if a status transition is valid.
## Examples
iex> Status.valid_transition?(:queued, :downloading)
true
iex> Status.valid_transition?(:completed, :queued)
false
iex> Status.valid_transition?(:failed, :processing)
false
"""
@spec valid_transition?(status(), status()) :: boolean()
def valid_transition?(from, to) do
to in Map.get(@valid_transitions, from, [])
end
@doc """
Returns the list of valid next statuses from the given status.
## Examples
iex> Status.valid_next_statuses(:queued)
[:downloading, :processing, :failed]
iex> Status.valid_next_statuses(:completed)
[]
"""
@spec valid_next_statuses(status()) :: [status()]
def valid_next_statuses(status) do
Map.get(@valid_transitions, status, [])
end
@doc """
Returns true if the status is a terminal state (no further transitions possible).
## Examples
iex> Status.terminal?(:completed)
true
iex> Status.terminal?(:failed)
true
iex> Status.terminal?(:processing)
false
"""
@spec terminal?(status()) :: boolean()
def terminal?(status) do
valid_next_statuses(status) == []
end
@doc """
Returns true if the status indicates active work is in progress.
## Examples
iex> Status.active?(:downloading)
true
iex> Status.active?(:processing)
true
iex> Status.active?(:queued)
false
"""
@spec active?(status()) :: boolean()
def active?(status) when status in [:downloading, :processing], do: true
def active?(_), do: false
end
Integration with Changesets
Once implemented, the status transition guard would be called from a custom changeset validation:
# Planned addition to job changesets
def transition_changeset(job, new_status) do
if SoundForge.Jobs.Status.valid_transition?(job.status, new_status) do
changeset(job, %{status: new_status})
else
job
|> change()
|> add_error(:status, "invalid transition from #{job.status} to #{new_status}")
end
end
Test Pattern for Status Transitions
# Planned: test/sound_forge/jobs/status_test.exs
defmodule SoundForge.Jobs.StatusTest do
use ExUnit.Case, async: true
alias SoundForge.Jobs.Status
describe "valid_transition?/2" do
test "queued can transition to downloading" do
assert Status.valid_transition?(:queued, :downloading)
end
test "queued can transition to processing" do
assert Status.valid_transition?(:queued, :processing)
end
test "queued can transition to failed (timeout, cancellation)" do
assert Status.valid_transition?(:queued, :failed)
end
test "downloading can transition to completed" do
assert Status.valid_transition?(:downloading, :completed)
end
test "downloading can transition to processing" do
assert Status.valid_transition?(:downloading, :processing)
end
test "downloading can transition to failed" do
assert Status.valid_transition?(:downloading, :failed)
end
test "processing can transition to completed" do
assert Status.valid_transition?(:processing, :completed)
end
test "processing can transition to failed" do
assert Status.valid_transition?(:processing, :failed)
end
test "completed cannot transition to anything" do
refute Status.valid_transition?(:completed, :queued)
refute Status.valid_transition?(:completed, :downloading)
refute Status.valid_transition?(:completed, :processing)
refute Status.valid_transition?(:completed, :failed)
end
test "failed cannot transition to anything" do
refute Status.valid_transition?(:failed, :queued)
refute Status.valid_transition?(:failed, :downloading)
refute Status.valid_transition?(:failed, :processing)
refute Status.valid_transition?(:failed, :completed)
end
test "no backward transitions" do
refute Status.valid_transition?(:processing, :downloading)
refute Status.valid_transition?(:downloading, :queued)
refute Status.valid_transition?(:completed, :processing)
end
end
describe "terminal?/1" do
test "completed is terminal" do
assert Status.terminal?(:completed)
end
test "failed is terminal" do
assert Status.terminal?(:failed)
end
test "queued is not terminal" do
refute Status.terminal?(:queued)
end
test "downloading is not terminal" do
refute Status.terminal?(:downloading)
end
test "processing is not terminal" do
refute Status.terminal?(:processing)
end
end
end
Model Selection
Demucs Model Configuration
The SoundForge.Processing.Demucs module provides a pure data source for available models:
# lib/sound_forge/processing/demucs.ex
defmodule SoundForge.Processing.Demucs do
@models [
%{
name: "htdemucs",
description: "Hybrid Transformer Demucs - default 4-stem model (vocals, drums, bass, other)",
stems: 4
},
%{
name: "htdemucs_ft",
description: "Fine-tuned Hybrid Transformer Demucs - higher quality, slower",
stems: 4
},
%{
name: "htdemucs_6s",
description: "6-stem model (vocals, drums, bass, guitar, piano, other)",
stems: 6
},
%{
name: "mdx_extra",
description: "MDX-Net Extra - alternative architecture, good for vocals",
stems: 4
}
]
@spec list_models() :: [map()]
def list_models, do: @models
end
DemucsPort Model Validation
The Audio.DemucsPort validates models against a hardcoded list:
# lib/sound_forge/audio/demucs_port.ex
@valid_models ~w(htdemucs htdemucs_ft mdx_extra)
def validate_model(model) when model in @valid_models, do: :ok
def validate_model(model), do: {:error, {:invalid_model, model}}
def valid_models, do: @valid_models
This is a pure guard function – no side effects, no state. It uses a compile-time when guard for pattern matching against the module attribute.
AnalyzerPort Feature Validation
Similarly, the Audio.AnalyzerPort validates requested features:
# lib/sound_forge/audio/analyzer_port.ex
@valid_features ~w(tempo key energy spectral mfcc chroma all)
def validate_features(features) do
invalid = Enum.reject(features, &(&1 in @valid_features))
if Enum.empty?(invalid) do
:ok
else
{:error, invalid}
end
end
def valid_features, do: @valid_features
This function takes a list of feature names, filters out any that are not in the valid set, and returns either :ok or an error tuple containing the invalid feature names. It has no side effects.
Test Patterns for Validation Functions
# Testing model validation (pure, async-safe)
describe "DemucsPort.validate_model/1" do
test "accepts htdemucs" do
assert :ok = DemucsPort.validate_model("htdemucs")
end
test "accepts htdemucs_ft" do
assert :ok = DemucsPort.validate_model("htdemucs_ft")
end
test "accepts mdx_extra" do
assert :ok = DemucsPort.validate_model("mdx_extra")
end
test "rejects unknown models" do
assert {:error, {:invalid_model, "unknown"}} = DemucsPort.validate_model("unknown")
end
test "rejects empty string" do
assert {:error, {:invalid_model, ""}} = DemucsPort.validate_model("")
end
end
# Testing feature validation (pure, async-safe)
describe "AnalyzerPort.validate_features/1" do
test "accepts all valid features" do
assert :ok = AnalyzerPort.validate_features(["tempo", "key", "energy"])
end
test "accepts single feature" do
assert :ok = AnalyzerPort.validate_features(["tempo"])
end
test "accepts 'all' feature" do
assert :ok = AnalyzerPort.validate_features(["all"])
end
test "rejects invalid features and returns them" do
assert {:error, ["invalid_feature"]} =
AnalyzerPort.validate_features(["tempo", "invalid_feature"])
end
test "accepts empty list" do
assert :ok = AnalyzerPort.validate_features([])
end
end
Audio Feature Calculations
Stem Count Derivation
A pure function to determine expected stem count from model name:
# Derivable from SoundForge.Processing.Demucs.list_models/0
defp stems_for_model(model_name) do
case Enum.find(Demucs.list_models(), &(&1.name == model_name)) do
%{stems: count} -> count
nil -> {:error, :unknown_model}
end
end
Stem Type Lists
The expected stem types for each model, derivable from the model configuration:
# 4-stem models: htdemucs, htdemucs_ft, mdx_extra
@four_stem_types [:vocals, :drums, :bass, :other]
# 6-stem models: htdemucs_6s
@six_stem_types [:vocals, :drums, :bass, :guitar, :piano, :other]
def stem_types_for_model("htdemucs_6s"), do: @six_stem_types
def stem_types_for_model(_), do: @four_stem_types
Feature Normalization
Audio analysis results from the Python script need normalization before storage. These are pure transformation functions:
# Planned: lib/sound_forge/audio/features.ex
defmodule SoundForge.Audio.Features do
@moduledoc """
Pure functions for normalizing and transforming audio feature data
returned from the Python analyzer.
"""
@doc """
Normalizes raw analyzer output into an AnalysisResult-compatible map.
Takes the JSON-decoded output from analyzer.py and maps it to the
column names used by the AnalysisResult schema.
"""
@spec normalize(map()) :: map()
def normalize(raw) when is_map(raw) do
%{
tempo: get_float(raw, "tempo"),
key: get_string(raw, "key"),
energy: get_float(raw, "energy"),
spectral_centroid: get_float(raw, "spectral_centroid"),
spectral_rolloff: get_float(raw, "spectral_rolloff"),
zero_crossing_rate: get_float(raw, "zero_crossing_rate"),
features: extract_extended_features(raw)
}
end
defp get_float(map, key) do
case Map.get(map, key) do
value when is_float(value) -> value
value when is_integer(value) -> value / 1
_ -> nil
end
end
defp get_string(map, key) do
case Map.get(map, key) do
value when is_binary(value) -> value
_ -> nil
end
end
defp extract_extended_features(raw) do
# Pull out MFCC, chroma, and any non-standard fields
standard_keys = ~w(tempo key energy spectral_centroid spectral_rolloff zero_crossing_rate)
Map.drop(raw, standard_keys)
end
end
Spotify Client Behaviour
The SoundForge.Spotify.Client behaviour defines a pure interface contract with no implementation logic:
# lib/sound_forge/spotify/client.ex
defmodule SoundForge.Spotify.Client do
@callback fetch_track(String.t()) :: {:ok, map()} | {:error, term()}
@callback fetch_album(String.t()) :: {:ok, map()} | {:error, term()}
@callback fetch_playlist(String.t()) :: {:ok, map()} | {:error, term()}
end
This behaviour enables swapping the implementation via application config:
# In SoundForge.Spotify context
defp spotify_client do
Application.get_env(:sound_forge, :spotify_client, SoundForge.Spotify.HTTPClient)
end
In tests, Mox creates a mock implementation:
# test/support setup
Mox.defmock(SoundForge.Spotify.MockClient, for: SoundForge.Spotify.Client)
# test/sound_forge/spotify_test.exs
expect(MockClient, :fetch_track, fn "track123" ->
{:ok, %{"id" => "track123", "name" => "Test Song"}}
end)
Changeset Validation as Pure Logic
Ecto changesets are pure data transformations. They do not touch the database – that only happens when Repo.insert/1 or Repo.update/1 is called. This means changeset functions are testable without a database:
# Pure changeset validation (no database needed)
test "Track changeset requires title" do
changeset = Track.changeset(%Track{}, %{title: nil})
refute changeset.valid?
assert %{title: ["can't be blank"]} = errors_on(changeset)
end
test "Track changeset accepts valid attributes" do
changeset = Track.changeset(%Track{}, %{title: "Test", artist: "Artist"})
assert changeset.valid?
end
test "DownloadJob changeset validates progress range" do
changeset = DownloadJob.changeset(%DownloadJob{}, %{track_id: Ecto.UUID.generate(), progress: 150})
refute changeset.valid?
assert %{progress: ["must be less than or equal to 100"]} = errors_on(changeset)
end
test "Stem changeset validates stem_type inclusion" do
changeset = Stem.changeset(%Stem{}, %{
track_id: Ecto.UUID.generate(),
processing_job_id: Ecto.UUID.generate(),
stem_type: :invalid_type
})
refute changeset.valid?
end
The unique_constraint on spotify_id and foreign_key_constraint on track_id are only enforced at the database level during Repo.insert/update, so those validations require DataCase tests with a real database connection.
Testing Patterns for Pure Functions
Async Tests
All pure function tests should use async: true:
defmodule SoundForge.Spotify.URLParserTest do
use ExUnit.Case, async: true
# No database, no GenServer, no shared state
end
Property-Based Testing Opportunities
The URL parser and validation functions are candidates for property-based testing:
# Potential property test for URL parser
property "always extracts type and ID from well-formed URLs" do
check all type <- member_of(["track", "album", "playlist"]),
id <- string(:alphanumeric, min_length: 10, max_length: 30) do
url = "https://open.spotify.com/#{type}/#{id}"
assert {:ok, %{type: ^type, id: ^id}} = URLParser.parse(url)
end
end
# Potential property test for feature validation
property "validate_features accepts any subset of valid features" do
check all features <- list_of(member_of(AnalyzerPort.valid_features()), min_length: 1) do
assert :ok = AnalyzerPort.validate_features(features)
end
end
Data-Driven Tests
Validation functions work well with data-driven test patterns:
@valid_urls [
{"https://open.spotify.com/track/abc123", %{type: "track", id: "abc123"}},
{"spotify.com/album/xyz", %{type: "album", id: "xyz"}},
{"https://open.spotify.com/intl-fr/playlist/list1", %{type: "playlist", id: "list1"}}
]
for {url, expected} <- @valid_urls do
test "parses #{url}" do
assert {:ok, unquote(Macro.escape(expected))} = URLParser.parse(unquote(url))
end
end
@invalid_urls [
"",
"not a url",
"https://example.com/track/123",
"https://open.spotify.com/artist/abc",
"https://open.spotify.com/show/abc"
]
for url <- @invalid_urls do
test "rejects #{inspect(url)}" do
assert {:error, :invalid_spotify_url} = URLParser.parse(unquote(url))
end
end
Summary of Pure Modules
| Module | Pure Functions | Side Effects |
|---|---|---|
Spotify.URLParser |
parse/1 |
None |
Spotify.Client |
Behaviour only (no functions) | N/A |
Processing.Demucs |
list_models/0 |
None (compile-time data) |
Audio.DemucsPort |
validate_model/1, valid_models/0 |
separate/2 uses GenServer + Port |
Audio.AnalyzerPort |
validate_features/1, valid_features/0 |
analyze/2 uses GenServer + Port |
Music.Track |
changeset/2 |
None (changesets are pure) |
Music.DownloadJob |
changeset/2 |
None |
Music.ProcessingJob |
changeset/2 |
None |
Music.AnalysisJob |
changeset/2 |
None |
Music.Stem |
changeset/2 |
None |
Music.AnalysisResult |
changeset/2 |
None |
Jobs.Status (planned) |
valid_transition?/2, terminal?/1, active?/1, valid_next_statuses/1 |
None |
Audio.Features (planned) |
normalize/1 |
None |
All pure functions are safe to call from async: true tests, can be composed without worrying about ordering, and serve as the stable foundation upon which the imperative shell (context modules, workers, GenServers) is built.