4track 0.1.9 → 0.1.12

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.
@@ -1,4 +1,4 @@
1
1
  import type { AudioEngineConfig } from "../types.js";
2
- export declare const PLAYBACK_TICK_MS = 50;
2
+ export declare const PLAYBACK_TICK_MS = 32;
3
3
  export declare const DEFAULT_CONFIG: AudioEngineConfig;
4
4
  export declare const AUDIO_CONSTRAINTS: MediaTrackConstraints;
@@ -1,6 +1,6 @@
1
1
  // Default configuration and constraints for the audio engine.
2
2
  import workletUrl from "../assets/recorder-worklet.js?url";
3
- export const PLAYBACK_TICK_MS = 50;
3
+ export const PLAYBACK_TICK_MS = 32; // 30 fps
4
4
  export const DEFAULT_CONFIG = {
5
5
  sampleRate: 32000, // 44100 | 48000 | 96000
6
6
  bitDepth: 16, // 8 = lo-fi, 16 = CD quality, 32 = float (uncompressed)
@@ -1,4 +1,4 @@
1
- import type { AudioEngineConfig, MicStatus, PlayState } from "../types.js";
1
+ import type { AudioEngineConfig, MicStatus, PlayState, ProjectMeta } from "../types.js";
2
2
  import { Track } from "./track.svelte.js";
3
3
  export declare class AudioEngine {
4
4
  playState: PlayState;
@@ -8,7 +8,10 @@ export declare class AudioEngine {
8
8
  latencyInfo: string;
9
9
  trimValue: number;
10
10
  recordingVolume: number;
11
+ meta: ProjectMeta;
11
12
  tracks: Track[];
13
+ /** Rounds a time value to 2 decimal places for position display. */
14
+ private roundPosition;
12
15
  private config;
13
16
  private audioContext;
14
17
  private masterGainNode;
@@ -16,7 +16,12 @@ export class AudioEngine {
16
16
  latencyInfo = $state("");
17
17
  trimValue = $state(-1);
18
18
  recordingVolume = $state(0.75);
19
+ meta = $state({ artist: "", title: "", comment: "" });
19
20
  tracks;
21
+ /** Rounds a time value to 2 decimal places for position display. */
22
+ roundPosition(seconds) {
23
+ return Math.round(seconds * 100) / 100;
24
+ }
20
25
  // ─── Private state ──────────────────────────────────────────────────
21
26
  config;
22
27
  // Web Audio graph
@@ -172,7 +177,7 @@ export class AudioEngine {
172
177
  const max = this.getMaxDuration();
173
178
  const clamped = Math.max(0, Math.min(seconds, max));
174
179
  this.playbackOffset = clamped;
175
- this.position = Math.round(clamped * 10) / 10;
180
+ this.position = this.roundPosition(clamped);
176
181
  if (this.playState === "playing") {
177
182
  this.stopSources(this.activePlaybackSources);
178
183
  this.clearPlaybackTick();
@@ -219,14 +224,14 @@ export class AudioEngine {
219
224
  }
220
225
  this.playbackStartTime = startTime;
221
226
  this.playbackOffset = offsetSeconds;
222
- this.position = Math.round(offsetSeconds * 10) / 10;
227
+ this.position = this.roundPosition(offsetSeconds);
223
228
  this.playState = "playing";
224
229
  this.playbackTickId = window.setInterval(() => {
225
230
  const elapsed = ctx.currentTime - this.playbackStartTime;
226
- this.position = Math.round((this.playbackOffset + elapsed) * 10) / 10;
231
+ this.position = this.roundPosition(this.playbackOffset + elapsed);
227
232
  if (elapsed >= effectiveDuration) {
228
233
  this.playbackOffset = maxDuration;
229
- this.position = Math.round(maxDuration * 10) / 10;
234
+ this.position = this.roundPosition(maxDuration);
230
235
  this.clearPlaybackTick();
231
236
  this.playState = "stopped";
232
237
  }
@@ -242,7 +247,7 @@ export class AudioEngine {
242
247
  this.stopSources(this.activePlaybackSources);
243
248
  }
244
249
  this.clearPlaybackTick();
245
- this.position = Math.round(this.playbackOffset * 10) / 10;
250
+ this.position = this.roundPosition(this.playbackOffset);
246
251
  this.playState = "paused";
247
252
  }
248
253
  /** Stops playback or recording. If recording, finalizes and merges the recorded audio. */
@@ -258,7 +263,7 @@ export class AudioEngine {
258
263
  this.stopSources(this.activePlaybackSources);
259
264
  }
260
265
  this.clearPlaybackTick();
261
- this.position = Math.round(this.playbackOffset * 10) / 10;
266
+ this.position = this.roundPosition(this.playbackOffset);
262
267
  }
263
268
  this.playState = "stopped";
264
269
  }
@@ -457,7 +462,7 @@ export class AudioEngine {
457
462
  this.recordingTrackIndex = trackIndex;
458
463
  this.recordingLatencySeconds = recordLatencySeconds;
459
464
  this.punchInOffset = this.playbackOffset;
460
- this.position = Math.round(this.punchInOffset * 10) / 10;
465
+ this.position = this.roundPosition(this.punchInOffset);
461
466
  this.playState = "recording";
462
467
  // Play other tracks for overdub monitoring, start meters and position timer
463
468
  this.playOtherTracksForMonitoring(trackIndex, this.punchInOffset);
@@ -573,12 +578,13 @@ export class AudioEngine {
573
578
  // ─── Save / Load ────────────────────────────────────────────────────
574
579
  /** Serializes all tracks and settings into a compressed .4trk binary blob. */
575
580
  exportProject() {
576
- return _exportProject(this.tracks, this.config, this.masterVolume);
581
+ return _exportProject(this.tracks, this.config, this.masterVolume, this.meta);
577
582
  }
578
583
  /** Loads a .4trk file, restoring all track buffers, mixer settings, and master volume. */
579
584
  async importProject(file) {
580
- const { masterVolume } = await _importProject(file, this.tracks, () => this.ensureContext());
585
+ const { masterVolume, meta } = await _importProject(file, this.tracks, () => this.ensureContext());
581
586
  this.setMasterVolume(masterVolume);
587
+ this.meta = meta;
582
588
  this.rewind();
583
589
  }
584
590
  // ─── Cleanup ────────────────────────────────────────────────────────
@@ -1,6 +1,7 @@
1
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): Promise<Blob>;
2
+ import type { AudioEngineConfig, ProjectMeta } from '../types.js';
3
+ export declare function exportProject(tracks: Track[], config: AudioEngineConfig, masterVolume: number, meta?: ProjectMeta): Promise<Blob>;
4
4
  export declare function importProject(file: File | Blob, tracks: Track[], ensureContext: () => AudioContext): Promise<{
5
5
  masterVolume: number;
6
+ meta: ProjectMeta;
6
7
  }>;
@@ -3,7 +3,7 @@
3
3
  // Uses integer quantization from ./pcm.ts for compact storage.
4
4
  import { Track } from './track.svelte.js';
5
5
  import { quantizePCM, dequantizePCM } from './pcm.js';
6
- export async function exportProject(tracks, config, masterVolume) {
6
+ export async function exportProject(tracks, config, masterVolume, meta = { artist: '', title: '', comment: '' }) {
7
7
  const trackMeta = [];
8
8
  const pcmParts = [];
9
9
  for (const track of tracks) {
@@ -37,6 +37,12 @@ export async function exportProject(tracks, config, masterVolume) {
37
37
  masterVolume,
38
38
  tracks: trackMeta,
39
39
  };
40
+ if (meta.artist)
41
+ metadata.artist = meta.artist.slice(0, 64);
42
+ if (meta.title)
43
+ metadata.title = meta.title.slice(0, 64);
44
+ if (meta.comment)
45
+ metadata.comment = meta.comment.slice(0, 256);
40
46
  const encoder = new TextEncoder();
41
47
  const metaBytes = encoder.encode(JSON.stringify(metadata));
42
48
  const metaLength = new Uint32Array([metaBytes.length]);
@@ -84,5 +90,12 @@ export async function importProject(file, tracks, ensureContext) {
84
90
  if (track.panNode)
85
91
  track.panNode.pan.value = track.pan;
86
92
  }
87
- return { masterVolume: metadata.masterVolume ?? 1.0 };
93
+ return {
94
+ masterVolume: metadata.masterVolume ?? 1.0,
95
+ meta: {
96
+ artist: metadata.artist ?? '',
97
+ title: metadata.title ?? '',
98
+ comment: metadata.comment ?? '',
99
+ },
100
+ };
88
101
  }
@@ -1,6 +1,6 @@
1
1
  <script lang="ts">
2
2
  import { AudioEngine } from "../audio/engine.svelte.js"
3
- import type { HiddenTrackConfig, LoadStatus } from "../types.js"
3
+ import type { HiddenTrackConfig, LoadStatus, ProjectMeta } from "../types.js"
4
4
  import casetteHissUrl from "../assets/casette_hiss_compressed.mp3"
5
5
  import noiseImg from "../assets/noise_50.jpg"
6
6
  import logoImg from "../assets/logo.svg?url"
@@ -23,6 +23,7 @@
23
23
  initialProject,
24
24
  status = $bindable<LoadStatus>("idle"),
25
25
  loadProgress = $bindable(0),
26
+ meta = $bindable<ProjectMeta>({ artist: "", title: "", comment: "" }),
26
27
  }: {
27
28
  hiddenTracks?: HiddenTrackConfig[]
28
29
  onready?: (detail: { engine: AudioEngine }) => void
@@ -31,6 +32,7 @@
31
32
  initialProject?: string | File
32
33
  status?: LoadStatus
33
34
  loadProgress?: number
35
+ meta?: ProjectMeta
34
36
  } = $props()
35
37
 
36
38
  let engine: AudioEngine | null = $state(null)
@@ -39,6 +41,7 @@
39
41
  let recordEngaged = $state(false)
40
42
  let resetTransport: (() => void) | undefined = $state()
41
43
 
44
+
42
45
  async function fetchWithProgress(url: string): Promise<File> {
43
46
  const response = await fetch(url)
44
47
  const contentLength = response.headers.get("Content-Length")
@@ -76,7 +79,10 @@
76
79
  engine = new AudioEngine({ hiddenTracks })
77
80
  engine.initAudioContext()
78
81
 
79
- save = () => engine!.exportProject()
82
+ save = () => {
83
+ engine!.meta = { ...meta }
84
+ return engine!.exportProject()
85
+ }
80
86
  load = async (source: File | string) => {
81
87
  status = "loading"
82
88
  loadProgress = 0
@@ -84,6 +90,7 @@
84
90
  const file =
85
91
  typeof source === "string" ? await fetchWithProgress(source) : source
86
92
  await engine!.importProject(file)
93
+ meta = { ...engine!.meta }
87
94
  resetTransport?.()
88
95
  status = "ready"
89
96
  } catch (e) {
@@ -1,5 +1,5 @@
1
1
  import { AudioEngine } from "../audio/engine.svelte.js";
2
- import type { HiddenTrackConfig, LoadStatus } from "../types.js";
2
+ import type { HiddenTrackConfig, LoadStatus, ProjectMeta } from "../types.js";
3
3
  type $$ComponentProps = {
4
4
  hiddenTracks?: HiddenTrackConfig[];
5
5
  onready?: (detail: {
@@ -10,7 +10,8 @@ type $$ComponentProps = {
10
10
  initialProject?: string | File;
11
11
  status?: LoadStatus;
12
12
  loadProgress?: number;
13
+ meta?: ProjectMeta;
13
14
  };
14
- declare const FourTrack: import("svelte").Component<$$ComponentProps, {}, "save" | "load" | "status" | "loadProgress">;
15
+ declare const FourTrack: import("svelte").Component<$$ComponentProps, {}, "meta" | "save" | "load" | "status" | "loadProgress">;
15
16
  type FourTrack = ReturnType<typeof FourTrack>;
16
17
  export default FourTrack;
@@ -43,8 +43,9 @@
43
43
  $effect(() => {
44
44
  let timer: ReturnType<typeof setInterval> | undefined
45
45
 
46
- // Trigger stop when rewinding and reaching the end
46
+ // Trigger stop when rewinding and reaching the beginning
47
47
  if (speed < 0 && engine.position + speed / 50 <= 0) {
48
+ engine.seek(0)
48
49
  clicky("stop")
49
50
  }
50
51
  if (engine.position + speed / 50 > engine.duration) {
@@ -1,48 +1,41 @@
1
1
  <script lang="ts">
2
- let { digit = 0 } = $props()
2
+ let { timestamp = 0, index = 0 } = $props()
3
3
 
4
4
  let roller: HTMLDivElement
5
- let prev = -1
6
- let wrapping = false
7
5
  const step = 100 / 12
6
+
8
7
  const pos = (d: number) => `translateY(${-step * (1 + d)}%)`
9
8
 
10
- function jumpTo(transform: string) {
11
- roller.style.transition = "none"
12
- roller.style.transform = transform
13
- roller.offsetHeight
14
- roller.style.transition = ""
9
+ function sigmoidEase(x: number, k: number, midpoint: number) {
10
+ const f = (v: number) => 1 / (1 + Math.exp(-k * (v - midpoint)))
11
+ return (f(x) - f(0)) / (f(1) - f(0))
15
12
  }
16
13
 
17
14
  $effect(() => {
18
- const d = +digit
19
15
  if (!roller) return
20
16
 
21
- if (prev === -1) {
22
- jumpTo(pos(d))
23
- } else if (prev === 9 && d === 0) {
24
- wrapping = true
25
- roller.style.transform = `translateY(${-step * 11}%)`
26
- } else if (prev === 0 && d === 9) {
27
- wrapping = true
28
- roller.style.transform = `translateY(0%)`
29
- } else {
30
- wrapping = false
31
- roller.style.transform = pos(d)
32
- }
17
+ // Get the value at this digit position (index 0=hundreds, 1=tens, 2=ones)
18
+ const divisor = Math.pow(10, 2 - index) //1, 10, 100
19
+ const value = (timestamp / divisor) % 10
33
20
 
34
- prev = d
35
- })
21
+ // Split into whole digit and fractional part for smooth rolling
22
+ const digit = Math.floor(value)
23
+ const fraction = value - digit
36
24
 
37
- function onTransitionEnd() {
38
- if (!wrapping) return
39
- wrapping = false
40
- jumpTo(pos(+digit))
41
- }
25
+ const eased =
26
+ divisor == 1
27
+ ? fraction
28
+ : sigmoidEase(
29
+ fraction,
30
+ divisor == 10 ? 12 : 50,
31
+ divisor == 10 ? 0.98 : 0.9995,
32
+ )
33
+ roller.style.transform = pos(digit + eased)
34
+ })
42
35
  </script>
43
36
 
44
37
  <div class="digits">
45
- <div class="roller" bind:this={roller} ontransitionend={onTransitionEnd}>
38
+ <div class="roller" bind:this={roller}>
46
39
  {#each [9, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 0] as n}
47
40
  <div class="digit"><span>{n}</span></div>
48
41
  {/each}
@@ -69,9 +62,9 @@
69
62
  transform: translateY(-2cqw);
70
63
  }
71
64
  }
72
- .roller {
65
+ /* .roller {
73
66
  transition: 0.4s ease transform;
74
- }
67
+ } */
75
68
  .digit {
76
69
  font-size: 35cqh;
77
70
  /* letter-spacing: 7cqw; */
@@ -1,5 +1,6 @@
1
1
  declare const DigitRoller: import("svelte").Component<{
2
- digit?: number;
2
+ timestamp?: number;
3
+ index?: number;
3
4
  }, {}, "">;
4
5
  type DigitRoller = ReturnType<typeof DigitRoller>;
5
6
  export default DigitRoller;
@@ -4,30 +4,21 @@
4
4
  <script lang="ts">
5
5
  let { timestamp } = $props()
6
6
  import { playFx } from "../../fx/soundfx"
7
+ import { Tween } from "svelte/motion"
7
8
  import DigitRoller from "./DigitRoller.svelte"
8
- import counterBgImg from '../../assets/counter_bg.png'
9
+ import counterBgImg from "../../assets/counter_bg.png"
9
10
 
10
- let correction = $state(0)
11
- function count_to_str(nr: number) {
12
- var cor_nr = Math.floor(nr - correction)
13
- if (cor_nr < 0) cor_nr += 1000
11
+ const correction = new Tween(0)
14
12
 
15
- if (cor_nr > 99) {
16
- return cor_nr.toString()
17
- } else if (cor_nr > 9) {
18
- return "0" + cor_nr.toString()
19
- } else {
20
- return "00" + cor_nr.toString()
21
- }
22
- }
23
-
24
- function get_digit(nr, i) {
25
- return count_to_str(nr).charAt(i)
26
- }
13
+ let corrected = $derived.by(() => {
14
+ let v = timestamp - correction.current
15
+ if (v < 0) v += 1000
16
+ return v % 1000
17
+ })
27
18
 
28
19
  function reset() {
29
20
  playFx("counter")
30
- correction = timestamp
21
+ correction.set(timestamp, { duration: 400 })
31
22
  }
32
23
  </script>
33
24
 
@@ -37,9 +28,9 @@
37
28
  <div class="counter">
38
29
  <a onmousedown={() => reset()}>&nbsp;</a>
39
30
  <div class="number-ticker">
40
- <DigitRoller digit={get_digit(timestamp, 0)} />
41
- <DigitRoller digit={get_digit(timestamp, 1)} />
42
- <DigitRoller digit={get_digit(timestamp, 2)} />
31
+ <DigitRoller timestamp={corrected} index={0} />
32
+ <DigitRoller timestamp={corrected} index={1} />
33
+ <DigitRoller timestamp={corrected} index={2} />
43
34
  </div>
44
35
  </div>
45
36
  </div>
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
1
  export { AudioEngine } from './audio/engine.svelte.js';
2
2
  export { Track } from './audio/track.svelte.js';
3
3
  export { default as FourTrack } from './components/FourTrack.svelte';
4
- export type { AudioEngineConfig, HiddenTrackConfig, TrimFxConfig, ProjectMetadata, TrackMeta, PlayState, LoadStatus } from './types.js';
4
+ export type { AudioEngineConfig, HiddenTrackConfig, TrimFxConfig, ProjectMetadata, ProjectMeta, TrackMeta, PlayState, LoadStatus } from './types.js';
package/dist/types.d.ts CHANGED
@@ -34,6 +34,14 @@ export interface ProjectMetadata {
34
34
  bitDepth: number;
35
35
  masterVolume: number;
36
36
  tracks: TrackMeta[];
37
+ artist?: string;
38
+ title?: string;
39
+ comment?: string;
40
+ }
41
+ export interface ProjectMeta {
42
+ artist: string;
43
+ title: string;
44
+ comment: string;
37
45
  }
38
46
  export type PlayState = 'stopped' | 'playing' | 'paused' | 'recording';
39
47
  export type MicStatus = 'unsupported' | 'prompt' | 'denied' | 'no-device' | 'inactive' | 'active' | 'error';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "4track",
3
- "version": "0.1.9",
3
+ "version": "0.1.12",
4
4
  "description": "A 4-track cassette recorder component for Svelte 5",
5
5
  "license": "GPL-3.0-only",
6
6
  "author": "Andre Boekhorst",