4track 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/LICENSE +674 -0
  2. package/README.md +80 -0
  3. package/dist/assets/btn_fwd.svg +30 -0
  4. package/dist/assets/btn_normal.png +0 -0
  5. package/dist/assets/btn_pause.svg +30 -0
  6. package/dist/assets/btn_play.svg +25 -0
  7. package/dist/assets/btn_pressed.png +0 -0
  8. package/dist/assets/btn_rec.svg +25 -0
  9. package/dist/assets/btn_rew.svg +30 -0
  10. package/dist/assets/btn_stop.svg +25 -0
  11. package/dist/assets/casette_hiss.mp3 +0 -0
  12. package/dist/assets/casette_hiss_compressed.mp3 +0 -0
  13. package/dist/assets/cassette.jpg +0 -0
  14. package/dist/assets/counter_bg.png +0 -0
  15. package/dist/assets/fx/counter.wav +0 -0
  16. package/dist/assets/fx/ffwd.wav +0 -0
  17. package/dist/assets/fx/pause.wav +0 -0
  18. package/dist/assets/fx/play.wav +0 -0
  19. package/dist/assets/fx/record.wav +0 -0
  20. package/dist/assets/fx/stop.wav +0 -0
  21. package/dist/assets/fx/track.wav +0 -0
  22. package/dist/assets/logo.svg +51 -0
  23. package/dist/assets/noise_50.jpg +0 -0
  24. package/dist/assets/openstudio.svg +38 -0
  25. package/dist/assets/recorder-worklet.d.ts +8 -0
  26. package/dist/assets/recorder-worklet.js +30 -0
  27. package/dist/assets/rotator.png +0 -0
  28. package/dist/assets/slider-indicator.svg +139 -0
  29. package/dist/assets/slider.png +0 -0
  30. package/dist/assets/slideselect-indicator.svg +64 -0
  31. package/dist/assets/slideselect-thumb.png +0 -0
  32. package/dist/assets/svg-icons.d.ts +6 -0
  33. package/dist/assets/svg-icons.js +8 -0
  34. package/dist/assets.d.ts +34 -0
  35. package/dist/audio/constants.d.ts +4 -0
  36. package/dist/audio/constants.js +27 -0
  37. package/dist/audio/engine.svelte.d.ts +90 -0
  38. package/dist/audio/engine.svelte.js +604 -0
  39. package/dist/audio/input-fx.d.ts +8 -0
  40. package/dist/audio/input-fx.js +44 -0
  41. package/dist/audio/metering.d.ts +3 -0
  42. package/dist/audio/metering.js +20 -0
  43. package/dist/audio/pcm.d.ts +2 -0
  44. package/dist/audio/pcm.js +43 -0
  45. package/dist/audio/project-io.d.ts +6 -0
  46. package/dist/audio/project-io.js +85 -0
  47. package/dist/audio/recording.d.ts +2 -0
  48. package/dist/audio/recording.js +80 -0
  49. package/dist/audio/track.svelte.d.ts +13 -0
  50. package/dist/audio/track.svelte.js +17 -0
  51. package/dist/components/Cassette.svelte +179 -0
  52. package/dist/components/Cassette.svelte.d.ts +9 -0
  53. package/dist/components/FourTrack.svelte +443 -0
  54. package/dist/components/FourTrack.svelte.d.ts +16 -0
  55. package/dist/components/Mixer.svelte +105 -0
  56. package/dist/components/Mixer.svelte.d.ts +7 -0
  57. package/dist/components/TransportButtons.svelte +299 -0
  58. package/dist/components/TransportButtons.svelte.d.ts +10 -0
  59. package/dist/components/els/DigitRoller.svelte +82 -0
  60. package/dist/components/els/DigitRoller.svelte.d.ts +5 -0
  61. package/dist/components/els/Knob.svelte +267 -0
  62. package/dist/components/els/Knob.svelte.d.ts +12 -0
  63. package/dist/components/els/Light.svelte +104 -0
  64. package/dist/components/els/Light.svelte.d.ts +8 -0
  65. package/dist/components/els/Lights.svelte +101 -0
  66. package/dist/components/els/Lights.svelte.d.ts +11 -0
  67. package/dist/components/els/SlideSelect.svelte +159 -0
  68. package/dist/components/els/SlideSelect.svelte.d.ts +15 -0
  69. package/dist/components/els/Slider.svelte +139 -0
  70. package/dist/components/els/Slider.svelte.d.ts +21 -0
  71. package/dist/components/els/Timestamp.svelte +92 -0
  72. package/dist/components/els/Timestamp.svelte.d.ts +5 -0
  73. package/dist/fx/soundfx.d.ts +14 -0
  74. package/dist/fx/soundfx.js +65 -0
  75. package/dist/index.d.ts +4 -0
  76. package/dist/index.js +3 -0
  77. package/dist/types.d.ts +40 -0
  78. package/dist/types.js +1 -0
  79. package/package.json +48 -0
@@ -0,0 +1,43 @@
1
+ // PCM encoding for project file persistence.
2
+ // Converts between Float32 audio samples and compact integer formats.
3
+ export function quantizePCM(float32, bitDepth) {
4
+ if (bitDepth === 32) {
5
+ const out = new Float32Array(float32.length);
6
+ out.set(float32);
7
+ return out.buffer;
8
+ }
9
+ if (bitDepth === 16) {
10
+ const out = new Int16Array(float32.length);
11
+ for (let i = 0; i < float32.length; i++) {
12
+ const s = Math.max(-1, Math.min(1, float32[i]));
13
+ out[i] = s < 0 ? s * 0x8000 : s * 0x7fff;
14
+ }
15
+ return out.buffer;
16
+ }
17
+ // 8-bit unsigned
18
+ const out = new Uint8Array(float32.length);
19
+ for (let i = 0; i < float32.length; i++) {
20
+ const s = Math.max(-1, Math.min(1, float32[i]));
21
+ out[i] = Math.round((s + 1) * 0.5 * 255);
22
+ }
23
+ return out.buffer;
24
+ }
25
+ export function dequantizePCM(data, bitDepth) {
26
+ if (bitDepth === 32)
27
+ return new Float32Array(data);
28
+ if (bitDepth === 16) {
29
+ const int16 = new Int16Array(data);
30
+ const out = new Float32Array(int16.length);
31
+ for (let i = 0; i < int16.length; i++) {
32
+ out[i] = int16[i] < 0 ? int16[i] / 0x8000 : int16[i] / 0x7fff;
33
+ }
34
+ return out;
35
+ }
36
+ // 8-bit unsigned
37
+ const uint8 = new Uint8Array(data);
38
+ const out = new Float32Array(uint8.length);
39
+ for (let i = 0; i < uint8.length; i++) {
40
+ out[i] = (uint8[i] / 255) * 2 - 1;
41
+ }
42
+ return out;
43
+ }
@@ -0,0 +1,6 @@
1
+ import { Track } from './track.svelte.js';
2
+ import type { AudioEngineConfig } from '../types.js';
3
+ export declare function exportProject(tracks: Track[], config: AudioEngineConfig, masterVolume: number): Blob;
4
+ export declare function importProject(file: File | Blob, tracks: Track[], ensureContext: () => AudioContext): Promise<{
5
+ masterVolume: number;
6
+ }>;
@@ -0,0 +1,85 @@
1
+ // Binary project format (.4trk):
2
+ // [4 bytes meta length][JSON metadata][PCM track data...]
3
+ // Uses integer quantization from ./pcm.ts for compact storage.
4
+ import { Track } from './track.svelte.js';
5
+ import { quantizePCM, dequantizePCM } from './pcm.js';
6
+ export function exportProject(tracks, config, masterVolume) {
7
+ const trackMeta = [];
8
+ const pcmParts = [];
9
+ for (const track of tracks) {
10
+ if (track.buffer) {
11
+ const float32 = track.buffer.getChannelData(0);
12
+ const quantized = quantizePCM(float32, config.bitDepth);
13
+ pcmParts.push(quantized);
14
+ trackMeta.push({
15
+ samples: float32.length,
16
+ volume: track.volume,
17
+ pan: track.pan,
18
+ trimStart: track.trimStart,
19
+ hidden: track.hidden || undefined,
20
+ });
21
+ }
22
+ else {
23
+ pcmParts.push(new ArrayBuffer(0));
24
+ trackMeta.push({
25
+ samples: 0,
26
+ volume: track.volume,
27
+ pan: track.pan,
28
+ trimStart: track.trimStart,
29
+ hidden: track.hidden || undefined,
30
+ });
31
+ }
32
+ }
33
+ const metadata = {
34
+ filetypeVersion: 1,
35
+ sampleRate: config.sampleRate,
36
+ bitDepth: config.bitDepth,
37
+ masterVolume,
38
+ tracks: trackMeta,
39
+ };
40
+ const encoder = new TextEncoder();
41
+ const metaBytes = encoder.encode(JSON.stringify(metadata));
42
+ const metaLength = new Uint32Array([metaBytes.length]);
43
+ return new Blob([metaLength, metaBytes, ...pcmParts], { type: 'application/octet-stream' });
44
+ }
45
+ export async function importProject(file, tracks, ensureContext) {
46
+ const arrayBuffer = await file.arrayBuffer();
47
+ const view = new DataView(arrayBuffer);
48
+ const metaLength = view.getUint32(0, true);
49
+ const metaBytes = new Uint8Array(arrayBuffer, 4, metaLength);
50
+ const metadata = JSON.parse(new TextDecoder().decode(metaBytes));
51
+ const ctx = ensureContext();
52
+ const bytesPerSample = metadata.bitDepth / 8;
53
+ let offset = 4 + metaLength;
54
+ for (let i = 0; i < metadata.tracks.length; i++) {
55
+ const t = metadata.tracks[i];
56
+ // Grow the tracks array if the file has more tracks than the engine
57
+ while (i >= tracks.length) {
58
+ tracks.push(new Track(t.hidden ?? false));
59
+ }
60
+ const track = tracks[i];
61
+ track.hidden = t.hidden ?? false;
62
+ if (t.samples > 0) {
63
+ const byteLen = t.samples * bytesPerSample;
64
+ const pcmSlice = arrayBuffer.slice(offset, offset + byteLen);
65
+ offset += byteLen;
66
+ const float32 = dequantizePCM(pcmSlice, metadata.bitDepth);
67
+ const audioBuf = ctx.createBuffer(1, float32.length, metadata.sampleRate);
68
+ audioBuf.getChannelData(0).set(float32);
69
+ track.buffer = audioBuf;
70
+ track.hasContent = true;
71
+ }
72
+ else {
73
+ track.buffer = null;
74
+ track.hasContent = false;
75
+ }
76
+ track.trimStart = t.trimStart ?? 0;
77
+ track.volume = t.volume ?? 1.0;
78
+ track.pan = t.pan ?? 0;
79
+ if (track.gainNode)
80
+ track.gainNode.gain.value = track.volume;
81
+ if (track.panNode)
82
+ track.panNode.pan.value = track.pan;
83
+ }
84
+ return { masterVolume: metadata.masterVolume ?? 1.0 };
85
+ }
@@ -0,0 +1,2 @@
1
+ export declare function measureRecordLatency(stream: MediaStream, ctx: AudioContext): number;
2
+ export declare function mergeRecordingIntoBuffer(ctx: AudioContext, chunks: Float32Array[], trimSamples: number, existingBuffer: AudioBuffer | null, existingTrimStart: number, punchInSeconds: number): AudioBuffer | null;
@@ -0,0 +1,80 @@
1
+ // Buffer construction and latency measurement for recordings.
2
+ // Handles latency trimming and punch-in/overdub merging.
3
+ // Measure round-trip audio latency from stream and context properties.
4
+ // Capped at 200ms; falls back to 30ms if nothing is reported.
5
+ export function measureRecordLatency(stream, ctx) {
6
+ let inputLatency = 0;
7
+ const audioTrack = stream.getAudioTracks()[0];
8
+ if (audioTrack) {
9
+ const s = audioTrack.getSettings();
10
+ if (typeof s.latency === 'number' && s.latency > 0)
11
+ inputLatency = s.latency;
12
+ }
13
+ let outputLatency = 0;
14
+ if (typeof ctx.outputLatency === 'number') {
15
+ outputLatency = ctx.outputLatency;
16
+ }
17
+ else if (typeof ctx.baseLatency === 'number') {
18
+ outputLatency = ctx.baseLatency;
19
+ }
20
+ const total = inputLatency + outputLatency;
21
+ return total > 0 ? Math.min(total, 0.2) : 0.03;
22
+ }
23
+ // Concatenate PCM chunks into a single Float32Array, skipping
24
+ // the first `trimSamples` to compensate for round-trip latency.
25
+ function trimAndConcat(chunks, trimSamples) {
26
+ const totalSamples = chunks.reduce((sum, c) => sum + c.length, 0);
27
+ const length = Math.max(0, totalSamples - trimSamples);
28
+ if (length === 0)
29
+ return null;
30
+ const result = new Float32Array(length);
31
+ let offset = 0;
32
+ let skip = trimSamples;
33
+ for (const c of chunks) {
34
+ if (skip > 0) {
35
+ const take = Math.min(c.length, skip);
36
+ skip -= take;
37
+ if (skip === 0 && take < c.length) {
38
+ result.set(c.subarray(take), offset);
39
+ offset += c.length - take;
40
+ }
41
+ }
42
+ else {
43
+ result.set(c, offset);
44
+ offset += c.length;
45
+ }
46
+ }
47
+ return result;
48
+ }
49
+ // Merge a new recording into a track's existing buffer at a punch-in point.
50
+ // Preserves audio before and after the recorded region (A-B-C splice).
51
+ // If no existing buffer, creates a fresh one at the punch-in offset.
52
+ export function mergeRecordingIntoBuffer(ctx, chunks, trimSamples, existingBuffer, existingTrimStart, punchInSeconds) {
53
+ const newPCM = trimAndConcat(chunks, trimSamples);
54
+ if (!newPCM)
55
+ return null;
56
+ const punchInSample = Math.max(0, Math.round((punchInSeconds + (existingBuffer ? existingTrimStart : 0)) * ctx.sampleRate));
57
+ const existingLength = existingBuffer ? existingBuffer.length : 0;
58
+ const resultLength = Math.max(existingLength, punchInSample + newPCM.length);
59
+ const resultBuffer = ctx.createBuffer(1, resultLength, ctx.sampleRate);
60
+ const resultChannel = resultBuffer.getChannelData(0);
61
+ if (existingBuffer) {
62
+ const existingData = existingBuffer.getChannelData(0);
63
+ // Region A: existing audio before the punch-in point
64
+ const regionAEnd = Math.min(punchInSample, existingLength);
65
+ if (regionAEnd > 0) {
66
+ resultChannel.set(existingData.subarray(0, regionAEnd));
67
+ }
68
+ // Region B: the new recording
69
+ resultChannel.set(newPCM, punchInSample);
70
+ // Region C: existing audio after the new recording ends
71
+ const regionCStart = punchInSample + newPCM.length;
72
+ if (regionCStart < existingLength) {
73
+ resultChannel.set(existingData.subarray(regionCStart), regionCStart);
74
+ }
75
+ }
76
+ else {
77
+ resultChannel.set(newPCM, punchInSample);
78
+ }
79
+ return resultBuffer;
80
+ }
@@ -0,0 +1,13 @@
1
+ export declare class Track {
2
+ hidden: boolean;
3
+ volume: number;
4
+ pan: number;
5
+ level: number;
6
+ hasContent: boolean;
7
+ buffer: AudioBuffer | null;
8
+ trimStart: number;
9
+ gainNode: GainNode | null;
10
+ panNode: StereoPannerNode | null;
11
+ analyserNode: AnalyserNode | null;
12
+ constructor(hidden?: boolean);
13
+ }
@@ -0,0 +1,17 @@
1
+ // Per-track reactive state. Each track owns its audio buffer,
2
+ // mixer settings, and the Web Audio nodes for its channel strip.
3
+ export class Track {
4
+ hidden;
5
+ volume = $state(1.0);
6
+ pan = $state(0);
7
+ level = $state(0);
8
+ hasContent = $state(false);
9
+ buffer = null;
10
+ trimStart = 0;
11
+ gainNode = null;
12
+ panNode = null;
13
+ analyserNode = null;
14
+ constructor(hidden = false) {
15
+ this.hidden = hidden;
16
+ }
17
+ }
@@ -0,0 +1,179 @@
1
+ <script lang="ts">
2
+ import cassetteImg from "../assets/cassette.jpg"
3
+ import rotatorImg from "../assets/rotator.png"
4
+
5
+ let { speed, time, max, onchange, isRecording } = $props()
6
+ let containerEl = $state()
7
+ let dragging = $state(false)
8
+ let startPos = $state()
9
+ let _dragPercentage = $state(0)
10
+ let xPos = $state()
11
+
12
+ // should be disabled whern ffwding...
13
+
14
+ function startDrag(e) {
15
+ dragging = true
16
+ _dragPercentage = 0
17
+ xPos = e.clientX
18
+ startPos = time
19
+ // needed to keep tracking pointermove, even if it is outside
20
+ e.target.setPointerCapture(e.pointerId)
21
+ }
22
+
23
+ const drag = (e) => {
24
+ // trigggered continously
25
+ if (!dragging) return
26
+ if (isRecording) return
27
+ if (speed > 1 || speed < 0) return //disable when ffwd
28
+
29
+ const rect = containerEl.getBoundingClientRect()
30
+ const xMove = e.clientX - xPos // it should be the difference from where it initially started.
31
+
32
+ // never drag more than 1, altought we later also need to
33
+ let _dragPercentage = xMove / rect.width / 16
34
+
35
+ let _time = Math.max(0, Math.min(startPos + _dragPercentage * max, max))
36
+ onchange?.(_time)
37
+ }
38
+
39
+ const stopDrag = (e) => {
40
+ dragging = false
41
+ e.target.releasePointerCapture?.(e.pointerId)
42
+ }
43
+ </script>
44
+
45
+ <div class="casette_gutter">
46
+ <div class="casette">
47
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
48
+ <div
49
+ class="window"
50
+ onpointerdown={startDrag}
51
+ onpointermove={drag}
52
+ onpointerup={stopDrag}
53
+ onpointerleave={stopDrag}
54
+ bind:this={containerEl}
55
+ style:--bg-cassette="url({cassetteImg})"
56
+ style:--bg-rotator="url({rotatorImg})"
57
+ >
58
+ <div class="window_inset">
59
+ <div class="shadow"></div>
60
+ <div class="rotaters">
61
+ <div
62
+ class="rotater rot1"
63
+ style:transform={"rotate(" + (time * 270) / 10 + "deg)"}
64
+ ></div>
65
+ <div
66
+ class="rotater rot2"
67
+ style:transform={"rotate(" + (time * 180) / 10 + "deg)"}
68
+ ></div>
69
+ </div>
70
+ </div>
71
+ </div>
72
+ </div>
73
+ </div>
74
+
75
+ <style>
76
+ .casette_gutter {
77
+ height: 95%;
78
+ border-radius: 0.8cqw 0 0 0.8cqw;
79
+ background: #1a1a1b;
80
+ padding: 0.8cqh 0 0.8cqh 0.35cqw;
81
+ box-shadow: inset 0cqw -0.15cqw 0 0 rgba(136, 132, 132, 0.761);
82
+ }
83
+ .casette {
84
+ height: 100%;
85
+ container-type: size;
86
+ /* border-top: 0.8cqh solid #1a1a1b00;
87
+ border-left: 0.3cqw solid #1a1a1b00;
88
+ border-bottom: 0.8cqh solid #1a1a1b00; */
89
+ border-radius: 0.4cqw 0 0 0.8cqw;
90
+ box-shadow: inset 0.15cqw 0.15cqw 0 0 rgb(136 132 132);
91
+ background: radial-gradient(ellipse at top left, #5d6066, #383840);
92
+ }
93
+
94
+ .window {
95
+ background-color: #212124;
96
+ border-radius: 1cqw;
97
+ margin: 36cqh 10cqw 20cqh 5cqw;
98
+ padding: 1.5cqw 8cqw;
99
+ position: relative;
100
+ cursor: ew-resize;
101
+ box-shadow:
102
+ inset 0.2cqw 0.2cqw 0.2cqw 0 rgba(0, 0, 0, 0.5),
103
+ inset -0.1cqw -0.1cqw 0.2cqw 0 rgba(255, 255, 255, 0.5);
104
+ &:active {
105
+ cursor: col-resize;
106
+ }
107
+
108
+ .window_inset {
109
+ background: var(--bg-cassette);
110
+ background-size: 109%;
111
+ background-position: -5cqh -15cqw;
112
+ border-radius: 2.5cqw;
113
+ width: 100%;
114
+ aspect-ratio: 5.7 / 1;
115
+ position: relative;
116
+ margin: 0 auto;
117
+ }
118
+
119
+ .shadow {
120
+ position: absolute;
121
+ width: 100%;
122
+ height: 100%;
123
+ box-shadow:
124
+ inset 0.25cqw 0.25cqw 0 0 rgb(0 0 0),
125
+ inset 10cqw 5cqw 5cqw rgb(0 0 0 / 95%),
126
+ inset -1.25cqw -2.5cqw 2.5cqw rgb(0 0 0 / 25%),
127
+ inset -0.25cqw -0.25cqw 0.25cqw 0 rgba(255, 255, 255, 0.25);
128
+ border: 0.25cqw solid #171718;
129
+ z-index: 1;
130
+ border-radius: 1.25cqw;
131
+ }
132
+
133
+ .rotaters {
134
+ display: flex;
135
+ gap: 16cqw;
136
+ overflow: hidden;
137
+ height: 23cqh;
138
+ justify-content: center;
139
+ }
140
+
141
+ .rotater {
142
+ width: 16cqw;
143
+ height: 16cqw;
144
+ background: var(--bg-rotator);
145
+ background-size: contain;
146
+ background-repeat: no-repeat;
147
+ margin-top: -2.5cqh;
148
+ }
149
+
150
+ &:after {
151
+ position: absolute;
152
+ content: " ";
153
+ display: block;
154
+ width: 100%;
155
+ height: 100%;
156
+ left: 0;
157
+ top: 0;
158
+ background: linear-gradient(-35deg, #000000 70%, #ffffff 71%);
159
+ z-index: 100;
160
+ opacity: 0.05;
161
+ mix-blend-mode: screen;
162
+ border-radius: 1.25cqw;
163
+ }
164
+ &:before {
165
+ position: absolute;
166
+ content: " ";
167
+ display: block;
168
+ width: 100%;
169
+ height: 100%;
170
+ left: 0;
171
+ top: 0;
172
+ background: linear-gradient(-45deg, #000000 60%, #ffffff 61%);
173
+ z-index: 100;
174
+ opacity: 0.1;
175
+ mix-blend-mode: screen;
176
+ border-radius: 1.25cqw;
177
+ }
178
+ }
179
+ </style>
@@ -0,0 +1,9 @@
1
+ declare const Cassette: import("svelte").Component<{
2
+ speed: any;
3
+ time: any;
4
+ max: any;
5
+ onchange: any;
6
+ isRecording: any;
7
+ }, {}, "">;
8
+ type Cassette = ReturnType<typeof Cassette>;
9
+ export default Cassette;